commit 72e2110987c1cd8bc9f1b53b0e008e7292f3e196 Author: wangziqi Date: Tue Jun 23 11:56:23 2026 +0800 chore: initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8c45d04 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +**/node_modules/ +**/.node/ +**/dist/ +**/pnpm-lock.yaml + diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..d3a6daf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,71 @@ +name: BUG 提交 +description: 提交产品缺陷帮助我们更好的改进 +title: "[BUG] " +labels: ["bug"] +assignees: ["zrfit"] +body: + - type: markdown + id: contacts_title + attributes: + value: "## 联系方式" + - type: input + id: contacts + validations: + required: false + attributes: + label: "联系方式" + description: "可以快速联系到您进一步沟通的方式:交流群号及昵称、邮箱等" + - type: markdown + id: environment + attributes: + value: "## 环境信息" + - type: input + id: version + validations: + required: true + attributes: + label: "Cordys CRM 版本" + description: "在系统右上角的下拉菜单中选择 `关于` 可查看当前 Cordys CRM 版本。" + - type: dropdown + id: database + validations: + required: true + attributes: + label: "使用外置数据库" + description: "如果使用外置数据库,请确认您的外置数据库版本,目前仅支持 MySQL 8.x 与 MariaDB 11.x。" + options: + - 否 + - 是 + - type: markdown + id: details + attributes: + value: "## 详细信息" + - type: textarea + id: what-happened + attributes: + label: "问题描述" + description: "简要描述您碰到的问题" + validations: + required: true + - type: textarea + id: how-happened + attributes: + label: "重现步骤" + description: "如果操作可以重现该问题" + validations: + required: true + - type: textarea + id: expect + attributes: + label: "期待的正确结果" + - type: textarea + id: logs + attributes: + label: "相关日志输出" + description: "请复制并粘贴任何相关的日志输出。 这将自动格式化为代码,因此无需反引号。" + render: shell + - type: textarea + id: additional-information + attributes: + label: "附加信息" + description: "如果你还有其他需要提供的信息,可以在这里填写(可以提供截图、视频等)。" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..9af4394 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: 对 Cordys CRM 项目有其他问题 + url: https://github.com/1Panel-dev/CordysCRM/ + about: 如果想要进一步了解 Cordys CRM 项目,欢迎发送邮件到 support@fit2cloud.com 进行提问。 + - name: 反馈安全问题 (Report Security Bug) + url: mailto://support@fit2cloud.com + about: 请通过邮箱 support@fit2cloud.com 反馈安全问题 (Please report security vulnerabilities to support@fit2cloud.com) diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..0f24189 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,43 @@ +name: 需求建议 +description: 提出针对本项目的想法和建议 +title: "[FEATURE] " +labels: ["需求"] +assignees: ["luty2018", "sakura1412"] +body: + - type: markdown + id: environment + attributes: + value: "## 环境信息" + - type: input + id: version + validations: + required: true + attributes: + label: "Cordys CRM 版本" + description: "在系统右上角的下拉菜单中选择 `关于` 可查看当前 Cordys CRM 版本。" + - type: input + id: contacts + validations: + required: false + attributes: + label: "联系方式" + description: "可以快速联系到您进一步沟通的方式:交流群号及昵称、邮箱等" + - type: markdown + id: details + attributes: + value: "## 详细信息" + - type: textarea + id: description + attributes: + label: "请描述您的需求或者改进建议" + validations: + required: true + - type: textarea + id: solution + attributes: + label: "请描述你建议的实现方案" + - type: textarea + id: additional-information + attributes: + label: "附加信息" + description: "如果你还有其他需要提供的信息,可以在这里填写(可以提供截图、视频等)。" diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..59a3dd5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,12 @@ +name: 问题咨询 +description: 提出针对本项目安装部署、使用及其他方面的相关问题 +title: "[QUESTION] " +labels: ["question"] +assignees: ["luty2018", "sakura1412"] +body: + - type: textarea + id: description + attributes: + label: "请描述您的问题" + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1106f42 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,9 @@ +#### What this PR does / why we need it? + +#### Summary of your change + +#### Please indicate you've done the following: + +- [ ] Made sure tests are passing and test coverage is added if needed. +- [ ] Made sure commit message follow the rule of [Conventional Commits specification](https://www.conventionalcommits.org/). +- [ ] Considered the docs impact and opened a new docs issue or PR with docs changes if needed. \ No newline at end of file diff --git a/.github/workflows/add-labels-for-pr.yml b/.github/workflows/add-labels-for-pr.yml new file mode 100644 index 0000000..907ff13 --- /dev/null +++ b/.github/workflows/add-labels-for-pr.yml @@ -0,0 +1,38 @@ +on: + workflow_dispatch: + +name: Add Labels to PR + +jobs: + add_labels: + runs-on: ubuntu-latest + + steps: + # Checkout the repository code + - name: Checkout code + uses: actions/checkout@v3 + + # Extract and format the labels from the comment as "username: label" + - name: Extract reviewer and labels + if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/') }} + id: extract_labels + run: | + comment_body="${{ github.event.comment.body }}" + reviewer="${{ github.event.comment.user.login }}" + # Clean up comment and extract labels + raw_labels=$(echo "$comment_body" | sed 's|^/||') + formatted_labels=$(echo "$raw_labels" | awk -v reviewer="$reviewer" '{split($0, arr, ","); for (i in arr) {gsub(/^ +| +$/, "", arr[i]); print reviewer ": " arr[i]}}') + + # Set the formatted labels as an environment variable (new way, not using set-output) + echo "labels=$formatted_labels" >> $GITHUB_ENV + + # Output labels for debugging + echo "Formatted labels: $formatted_labels" + + # Add the extracted labels to the PR + - name: Add labels via PR comment + if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/') }} + uses: actions-ecosystem/action-add-labels@v1 + with: + github_token: ${{ secrets.GH_TOKEN }} + labels: ${{ env.labels }} diff --git a/.github/workflows/build-and-push-base.yml b/.github/workflows/build-and-push-base.yml new file mode 100644 index 0000000..2cbd762 --- /dev/null +++ b/.github/workflows/build-and-push-base.yml @@ -0,0 +1,52 @@ +name: build-and-push-base + + +on: + workflow_dispatch: + inputs: + architecture: + description: 'Architecture' + required: true + default: 'linux/amd64' + type: choice + options: + - linux/amd64 + - linux/arm64 + - linux/amd64,linux/arm64 + +jobs: + build-and-push-base-to-ghcr: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: main + - name: Prepare + id: prepare + run: | + DOCKER_IMAGE=ghcr.io/cordys-dev/cordys-base + DOCKER_PLATFORMS=${{ github.event.inputs.architecture }} + TAG_NAME=latest + DOCKER_IMAGE_TAGS="--tag ${DOCKER_IMAGE}:${TAG_NAME} --tag ${DOCKER_IMAGE}:latest" + echo ::set-output name=docker_image::${DOCKER_IMAGE} + echo ::set-output name=version::${TAG_NAME} + echo ::set-output name=buildx_args::--platform ${DOCKER_PLATFORMS} --no-cache \ + --build-arg VERSION=${TAG_NAME} \ + --build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \ + --build-arg VCS_REF=${GITHUB_SHA::8} \ + ${DOCKER_IMAGE_TAGS} . + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GH_TOKEN }} + - name: Docker Buildx (build-and-push) + run: | + docker buildx build --output "type=image,push=true" ${{ steps.prepare.outputs.buildx_args }} -f installer/Dockerfile.base + diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000..8a464aa --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -0,0 +1,108 @@ +name: build-and-push + +run-name: 构建镜像并推送仓库 ${{ github.event.inputs.dockerImageTag }} (${{ github.event.inputs.registry }}) + +on: + workflow_dispatch: + inputs: + dockerImageTag: + description: 'Docker Image Tag' + default: 'dev' + required: true + architecture: + description: 'Architecture' + required: true + default: 'linux/amd64' + type: choice + options: + - linux/amd64 + - linux/arm64 + - linux/amd64,linux/arm64 + registry: + description: 'Push To Registry' + required: true + default: 'fit2cloud-registry' + type: choice + options: + - fit2cloud-registry + +jobs: + build-and-push-to-fit2cloud-registry: + if: ${{ contains(github.event.inputs.registry, 'fit2cloud') }} + runs-on: ubuntu-latest + steps: + - name: Checkout specific repository + uses: actions/checkout@v3 + with: + repository: cordys-dev/cordys-crm + token: ${{ secrets.GH_TOKEN }} # 使用 GitHub token + + - name: Prepare + id: prepare + run: | + DOCKER_IMAGE=${{ secrets.FIT2CLOUD_REGISTRY_HOST }}/cordys/cordys-crm-ce + DOCKER_PLATFORMS=${{ github.event.inputs.architecture }} + TAG_NAME=${{ github.event.inputs.dockerImageTag }} + SHORT_SHA=$(git rev-parse --short HEAD) + + if [[ ${TAG_NAME} == *dev* ]]; then + DOCKER_IMAGE_TAGS="--tag ${DOCKER_IMAGE}:${TAG_NAME}" + else + DOCKER_IMAGE_TAGS="--tag ${DOCKER_IMAGE}:${TAG_NAME} --tag ${DOCKER_IMAGE}:latest" + fi + echo ::set-output name=buildx_args::--platform ${DOCKER_PLATFORMS} \ + --build-arg DOCKER_IMAGE_TAG=${{ github.event.inputs.dockerImageTag }} --build-arg BUILD_AT=$(TZ=Asia/Shanghai date +'%Y-%m-%dT%H:%M') --build-arg GITHUB_COMMIT=${GITHUB_SHA::8} --no-cache \ + --build-arg FIT2CLOUD_MAVEN_USERNAME=${{ secrets.FIT2CLOUD_MAVEN_USERNAME }} --build-arg FIT2CLOUD_MAVEN_PASSWORD=${{ secrets.FIT2CLOUD_MAVEN_PASSWORD }} \ + --build-arg CRM_VERSION=${TAG_NAME}-${SHORT_SHA} \ + ${DOCKER_IMAGE_TAGS} . + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to FIT2CLOUD Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.FIT2CLOUD_REGISTRY_HOST }} + username: ${{ secrets.FIT2CLOUD_REGISTRY_USERNAME }} + password: ${{ secrets.FIT2CLOUD_REGISTRY_PASSWORD }} + + - name: Docker Buildx (build-and-push) + run: | + docker buildx build --output "type=image,push=true" ${{ steps.prepare.outputs.buildx_args }} -f installer/Dockerfile + # 创建或更新标签 + - name: Create or Re-create Tag + if: success() + run: | + git config --global user.name 'fit2-zhao' + git config --global user.email 'yong.zhao@fit2cloud.com' + + # 判断是否为dev标签,如果是则跳过打tag操作 + if [[ "${{ github.event.inputs.dockerImageTag }}" == "dev" ]]; then + echo "当前为dev标签,跳过打tag操作" + exit 0 + fi + + # 获取远程标签信息 + git fetch --prune --tags + + # 检查标签是否存在(本地或远程) + if git ls-remote --tags origin refs/tags/${{ github.event.inputs.dockerImageTag }} | grep -q "${{ github.event.inputs.dockerImageTag }}" || git tag -l "${{ github.event.inputs.dockerImageTag }}" | grep -q "${{ github.event.inputs.dockerImageTag }}"; then + echo "标签 ${{ github.event.inputs.dockerImageTag }} 已存在,正在删除..." + + # 删除本地标签(如果存在) + git tag -d ${{ github.event.inputs.dockerImageTag }} || true + + # 删除远程标签并确认结果 + git push --delete origin ${{ github.event.inputs.dockerImageTag }} || echo "远程标签可能不存在或已被删除" + + # 确认标签已被删除 + sleep 2 + git fetch --prune --tags + fi + + # 创建和推送新标签 + git tag -a ${{ github.event.inputs.dockerImageTag }} -m "Release ${{ github.event.inputs.dockerImageTag }}" + git push origin ${{ github.event.inputs.dockerImageTag }} \ No newline at end of file diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 0000000..eb71a37 --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,46 @@ +on: + pull_request: + branches: + - main + paths: + - "backend/**" + - "pom.xml" + +name: Code Coverage + +permissions: + pull-requests: write + +jobs: + generic_handler: + name: Code Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + token: ${{ secrets.GH_TOKEN }} + + # Cache Maven dependencies + - name: Cache Maven dependencies + uses: actions/cache@v3 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('backend/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: 21 + check-latest: false + + - name: Build with Maven - other + run: mvn -B package -DskipAntRunForJenkins --file pom.xml -pl '!frontend' + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: 1Panel-dev/CordysCRM \ No newline at end of file diff --git a/.github/workflows/create-pr-from-push.yml b/.github/workflows/create-pr-from-push.yml new file mode 100644 index 0000000..5fdad02 --- /dev/null +++ b/.github/workflows/create-pr-from-push.yml @@ -0,0 +1,17 @@ +on: + push: + branches: + - 'pr@**' + - 'repr@**' + +name: Auto Create PR + +jobs: + generic_handler: + name: Auto Create PR + runs-on: ubuntu-latest + steps: + - name: Create pull request + uses: jumpserver/action-generic-handler@master + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.github/workflows/frontend-build.yml b/.github/workflows/frontend-build.yml new file mode 100644 index 0000000..21e159f --- /dev/null +++ b/.github/workflows/frontend-build.yml @@ -0,0 +1,30 @@ +on: + pull_request: + branches: + - main + paths: + - "frontend/**" + +name: Frontend Code Checking + +permissions: + pull-requests: write + +jobs: + generic_handler: + name: Frontend Code Checking + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + token: ${{ secrets.GH_TOKEN }} + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: 21 + cache: 'maven' + cache-dependency-path: '**/pom.xml' # optional + check-latest: true + - name: Build with Maven - only frontend + run: mvn -T 1C -B package --file pom.xml -pl frontend diff --git a/.github/workflows/issue-close.yml b/.github/workflows/issue-close.yml new file mode 100644 index 0000000..aba226a --- /dev/null +++ b/.github/workflows/issue-close.yml @@ -0,0 +1,16 @@ +name: Issue Close Check + +on: + issues: + types: [closed] + +jobs: + issue-close-remove-labels: + runs-on: ubuntu-latest + steps: + - name: Remove labels + uses: actions-cool/issues-helper@v2 + if: ${{ !github.event.issue.pull_request }} + with: + actions: 'remove-labels' + labels: '待处理' \ No newline at end of file diff --git a/.github/workflows/issue-comment.yml b/.github/workflows/issue-comment.yml new file mode 100644 index 0000000..73ff876 --- /dev/null +++ b/.github/workflows/issue-comment.yml @@ -0,0 +1,38 @@ +on: + issue_comment: + types: [created] + +name: Add issues workflow labels + +jobs: + add-label-if-is-author: + runs-on: ubuntu-latest + if: ${{ (github.event.issue.user.id == github.event.comment.user.id) && (!github.event.issue.pull_request) }} + steps: + - name: Add require handle label + uses: actions-cool/issues-helper@v2 + with: + actions: 'add-labels' + labels: '待处理' + + - name: Remove require reply label + uses: actions-cool/issues-helper@v2 + with: + actions: 'remove-labels' + labels: '待用户反馈' + + add-label-if-not-author: + runs-on: ubuntu-latest + if: ${{ (github.event.issue.user.id != github.event.comment.user.id) && (!github.event.issue.pull_request) && (github.event.issue.state == 'open') }} + steps: + - name: Add require replay label + uses: actions-cool/issues-helper@v2 + with: + actions: 'add-labels' + labels: '待用户反馈' + + - name: Remove require handle label + uses: actions-cool/issues-helper@v2 + with: + actions: 'remove-labels' + labels: '待处理' diff --git a/.github/workflows/issue-open.yml b/.github/workflows/issue-open.yml new file mode 100644 index 0000000..869fe85 --- /dev/null +++ b/.github/workflows/issue-open.yml @@ -0,0 +1,16 @@ +name: Issue Open Check + +on: + issues: + types: [opened] + +jobs: + issue-open-add-labels: + runs-on: ubuntu-latest + steps: + - name: Add labels + uses: actions-cool/issues-helper@v2 + if: ${{ !github.event.issue.pull_request }} + with: + actions: 'add-labels' + labels: '待处理' \ No newline at end of file diff --git a/.github/workflows/issue-sync-to-tapd.yml b/.github/workflows/issue-sync-to-tapd.yml new file mode 100644 index 0000000..a828495 --- /dev/null +++ b/.github/workflows/issue-sync-to-tapd.yml @@ -0,0 +1,109 @@ +name: Sync GitHub Issues to TAPD + +on: + issues: + types: + - assigned + - reopened + +permissions: + issues: write + +jobs: + sync-to-tapd: + runs-on: ubuntu-latest + concurrency: + group: sync-issue-${{ github.event.issue.number }} + cancel-in-progress: false + + steps: + - name: Check if sync is needed (assignee + live labels) + id: check + run: | + ASSIGNEE="${{ github.event.issue.assignee.login }}" + + # Fetch live labels from GitHub API (not the snapshot in the event) + LABELS=$(gh issue view ${{ github.event.issue.number }} \ + --repo ${{ github.repository }} \ + --json labels \ + --jq '.labels[].name') + + if [ -z "$ASSIGNEE" ] || [ "$ASSIGNEE" != "luty2018" ]; then + echo "Issue is not assigned to luty2018 or has no assignee, skipping sync" + echo "should_sync=false" >> $GITHUB_OUTPUT + elif echo "$LABELS" | grep -qx "tapd-synced"; then + echo "Issue already has the tapd-synced label, already synced, skipping" + echo "should_sync=false" >> $GITHUB_OUTPUT + else + echo "Issue is assigned to luty2018 and not yet synced, proceeding to sync" + echo "should_sync=true" >> $GITHUB_OUTPUT + fi + env: + GH_TOKEN: ${{ github.token }} + + - name: Sync to TAPD + if: steps.check.outputs.should_sync == 'true' + env: + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_URL: ${{ github.event.issue.html_url }} + ISSUE_BODY: ${{ github.event.issue.body }} + TAPD_WORKSPACE_ID: ${{ secrets.TAPD_WORKSPACE_ID }} + TAPD_PROJECT_ID: ${{ secrets.TAPD_PROJECT_ID }} + TAPD_ITERATION_ID: ${{ secrets.TAPD_ITERATION_ID }} + TAPD_API_USERNAME: ${{ secrets.TAPD_API_USERNAME }} + TAPD_API_PASSWORD: ${{ secrets.TAPD_API_PASSWORD }} + TAPD_API_OWNER: ${{ secrets.TAPD_API_OWNER }} + run: | + # Fallback for empty body + BODY="${ISSUE_BODY:-No description}" + TITLE="[GitHub Issue #${ISSUE_NUMBER}] ${ISSUE_TITLE}" + + BODY_HTML="${BODY//$'\n'/
}" + DESCRIPTION="GitHub link: ${ISSUE_URL}

Description: ${BODY_HTML}" + + JSON=$(jq -n \ + --arg name "$TITLE" \ + --arg desc "$DESCRIPTION" \ + --arg proj "$TAPD_PROJECT_ID" \ + --arg ws "$TAPD_WORKSPACE_ID" \ + --arg iteration "$TAPD_ITERATION_ID" \ + --arg user "$TAPD_API_USERNAME" \ + --arg owner "$TAPD_API_OWNER" \ + '{ + name: $name, + description: $desc, + project_id: $proj, + workspace_id: $ws, + owner: $owner, + creator: $owner + } + if $iteration != "" then {iteration_id: $iteration} else {} end') + + echo "Creating TAPD story..." + echo "Title: $TITLE" + echo "Description: $DESCRIPTION" + + HTTP_CODE=$(curl -s -o /dev/stderr -w "%{http_code}" -X POST \ + "https://api.tapd.cn/stories?workspace_id=${TAPD_WORKSPACE_ID}" \ + -H "Content-Type: application/json" \ + -u "${TAPD_API_USERNAME}:${TAPD_API_PASSWORD}" \ + -d "$JSON") + + if [ "$HTTP_CODE" -ne 200 ] && [ "$HTTP_CODE" -ne 201 ]; then + echo "::error::TAPD API returned HTTP ${HTTP_CODE}, check the response details above" + exit 1 + fi + + echo "::notice::Sync completed, TAPD story created" + + - name: Add synced label + if: steps.check.outputs.should_sync == 'true' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['tapd-synced'] + }) \ No newline at end of file diff --git a/.github/workflows/llm-code-review.yml b/.github/workflows/llm-code-review.yml new file mode 100644 index 0000000..9517db9 --- /dev/null +++ b/.github/workflows/llm-code-review.yml @@ -0,0 +1,41 @@ +name: LLM Code Review + +permissions: + contents: write + pull-requests: write + +on: + workflow_dispatch: + +jobs: + llm-code-review: + runs-on: ubuntu-latest + + steps: + # 1️⃣ 拉代码 + - name: Checkout code + uses: actions/checkout@v3 + + # 2️⃣ LLM Code Review(发表评论) + - name: Run LLM Code Review + id: llm-code-review + uses: fit2cloud/LLM-CodeReview-Action@main + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + OPENAI_API_KEY: ${{ secrets.ALIYUN_LLM_API_KEY }} + OPENAI_API_ENDPOINT: https://dashscope.aliyuncs.com/compatible-mode/v1 + MODEL: qwen3-coder-plus + LANGUAGE: Chinese + PROMPT: | + 你是资深代码审查专家,严格检查以下代码差异。 + + ⚠️【必须遵守】 + - 只在代码质量存在问题时发表评论。 + - 不要解释、不添加多余文本。 + - 审查应专注于以下内容:逻辑错误、代码规范、可读性、性能优化等。 + + temperature: 0.2 + top_p: 1 + MAX_PATCH_LENGTH: 50000 + IGNORE_PATTERNS: "/node_modules,*.md,/dist,/.github" + FILE_PATTERNS: "*.java" diff --git a/.github/workflows/sync2gitee.yml b/.github/workflows/sync2gitee.yml new file mode 100644 index 0000000..5ed1508 --- /dev/null +++ b/.github/workflows/sync2gitee.yml @@ -0,0 +1,15 @@ +name: Synchronize to Gitee +on: [push] +jobs: + repo-sync: + runs-on: ubuntu-latest + steps: + - name: Mirror the Github organization repos to Gitee. + uses: Yikun/hub-mirror-action@master + with: + src: 'github/1Panel-dev' + dst: 'gitee/fit2cloud-feizhiyun' + dst_key: ${{ secrets.GITEE_PRIVATE_KEY }} + dst_token: ${{ secrets.GITEE_TOKEN }} + static_list: "CordysCRM" + force_update: true diff --git a/.github/workflows/typos-check.yml b/.github/workflows/typos-check.yml new file mode 100644 index 0000000..6b76edc --- /dev/null +++ b/.github/workflows/typos-check.yml @@ -0,0 +1,23 @@ +name: Typos Checking +on: + push: + branches: + - '**' + pull_request: + types: [opened, synchronize, reopened] + +permissions: + pull-requests: write + +jobs: + run: + name: Spell Check with Typos + runs-on: ubuntu-latest + steps: + - name: Checkout Actions Repository + uses: actions/checkout@v2 + + - name: Check spelling + uses: crate-ci/typos@master + with: + config: .github/workflows/typos-extend-exclude.toml diff --git a/.github/workflows/typos-extend-exclude.toml b/.github/workflows/typos-extend-exclude.toml new file mode 100644 index 0000000..1fe3b49 --- /dev/null +++ b/.github/workflows/typos-extend-exclude.toml @@ -0,0 +1,2 @@ +[files] +extend-exclude = ["*.json", "**/*.json", "**/*.js", "**/*.html", "**/*.xml", "**/config.ts", "**/*.sql", "**/*.conf", "**/en-US.ts", "OWNERS", "CODEOWNERS", "LICENSE", "NOTICE"] \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9eaa70e --- /dev/null +++ b/.gitignore @@ -0,0 +1,80 @@ +############################ +# Environment & Config +############################ + +# Local env files +.env +.env.local +.env.*.local +.venv + +# Maven +.mvn +!.mvn/maven-wrapper.properties +.flattened-pom.xml + +############################ +# Logs +############################ + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-error.log* + +############################ +# IDE / Editor +############################ + +# macOS +.DS_Store + +# IntelliJ IDEA +.idea/* +!.idea/icon.png +**/*.iml + +# Eclipse +.settings +.project +.classpath +.factorypath + +# VS Code +.vscode + +# Visual Studio +*.suo +*.ntvs* +*.njsproj +*.sln + +# Other +*.sw? +.history + +############################ +# Build Output +############################ + +target +.jython_cache + +############################ +# Static Resources (ignored) +############################ + +src/main/resources/static +src/main/resources/templates +/backend/crm/src/main/resources/static/ +/backend/app/src/main/resources/static/ + +############################ +# Custom Files +############################ + +qywx.json +.gitattributes + +# Claude Code +.claude diff --git a/.typos.toml b/.typos.toml new file mode 100644 index 0000000..1c0f02a --- /dev/null +++ b/.typos.toml @@ -0,0 +1,12 @@ +[default.extend-words] +AKE = "AKE" +[default.extend-identifiers] +maintain_column_froms = "maintain_column_froms" +RegistCapi="RegistCapi" +[files] +extend-exclude = [ + "frontend/public", + "frontend/packages/web/src/components/business/crm-city-select/config.ts", + "backend/crm/src/main/resources/region/region.json", + "iconfont.json" +] diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..3d7e21b --- /dev/null +++ b/BUILD.md @@ -0,0 +1,49 @@ +# 构建过程说明 + +本项目包含多个模块,构建分为基础配置安装、后端构建、前端构建和整体打包四个部分。 + +--- + +## 🔧 1. 安装基础 POM + +该命令会将 `parent pom` 安装到本地 Maven 仓库,使其他外部子工程可以获取最新的 `` 配置。 + +```bash +# 如果遇到网络问题,可以使用阿里云镜像加速 +./mvnw install -N +``` + 这是多模块项目的必要步骤,确保所有子模块能正确继承父POM的配置。 +--- + +## 🖥️ 2. 后端构建 + +执行以下命令构建后端模块(如 `backend` 中的 `framework`、`crm`、`app` 等)并安装到本地仓库: + +```bash +./mvnw clean install -DskipTests -DskipAntRunForJenkins --file backend/pom.xml +``` + +> ✅ 参数说明: +> +> * `-DskipTests`: 跳过测试用例执行。 +> * `-DskipAntRunForJenkins`: 跳过 Jenkins 使用的 Ant 任务。 + +--- + +## 💻 3. 前端构建 + +前端构建请参考 [`/frontend/REDEME.md`](./frontend/REDEME.md) 中的具体说明! + +> 📌 提示:确保运行环境已正确安装 Node.js 和依赖环境! + +--- + +## 📦 4. 整体打包 + +使用以下命令进行完整的构建与打包: + +```bash +./mvnw clean package +``` +> ✅ 备注: +> mvnw 为项目自带打包工具,也可使用本地部署的 mvn \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..fd45c9e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +support@fit2cloud.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5c9e1e0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# Contributing + +As a contributor, please acknowledge and agree that: + +- The project maintainers may adjust the open-source license terms as necessary to make them either more strict or more relaxed. +- Any code you contribute may be used for commercial purposes, including but not limited to the project’s cloud-based operations. + + +## Creating a Pull Request + +Pull requests (PRs) are always welcome — even small ones that fix typos or minor issues. +If your contribution involves significant work or new features, please open an issue first to start a discussion before you begin implementation. + +Please break down your changes into small, focused PRs whenever possible. +Large PRs with many features or extensive changes are difficult to review and may take longer to merge. Incremental submissions are strongly encouraged. + +These development guidelines provide information about the repository structure, how to set up your development environment, how to run the project, and more. + +**Note:** When splitting a feature into multiple PRs, please ensure that each individual PR can be safely merged into the `master` branch without breaking existing functionality. +If a PR introduces incomplete or non-functional changes, it will not be merged until the feature is complete. + +## Reporting Issues + +Reporting issues is a great way to contribute to the project. +Well-written and detailed bug reports are always appreciated! Please open a new issue and fill out all required information using the provided template. + +Before opening a new issue, please check the existing issue list to avoid duplicates. +If a similar issue already exists, you can subscribe to it to receive updates. +If you have additional information to share, please add it as a comment on that issue. + +When reporting an issue, please include the following information: + +- The version you are using +- Steps to reproduce the problem +- Screenshots or log files, if applicable + +Because all issues are public, please ensure that you remove any sensitive information (e.g., usernames, passwords, IP addresses, or company names) from your submissions. +Replace sensitive data with placeholders such as `REDACTED` or `****`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c00a530 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +This project is distributed under the GNU General Public License, version 3 (GPLv3), +with the following additional terms and conditions: + +1. Logos and Copyright + You may not remove, alter, or hide any logos, trademarks, or copyright + notices displayed in the web interface or within the source code. + +2. Contributor Terms + By contributing to this project, you agree that: + a. The project maintainer may update or revise this open-source license + to make it more permissive or more restrictive as needed. + b. Your contributions may be used for commercial purposes, including + but not limited to the project’s cloud and business operations. + +All other terms of the GNU General Public License, version 3 (GPLv3) remain in effect. +Full text: https://www.gnu.org/licenses/gpl-3.0.html + +Copyright (c) 2026-present FIT2CLOUD. diff --git a/OWNERS b/OWNERS new file mode 100644 index 0000000..baa6b3f --- /dev/null +++ b/OWNERS @@ -0,0 +1,21 @@ +reviewers: +- fit2-zhao +- xiaomeinvG +- song-tianyang +- AgAngle +- ba1q1 +- song-cc-rock +- WangXu10 +- fit2cloudwxx +- 1myuan + +approvers: +- fit2-zhao +- xiaomeinvG +- song-tianyang +- AgAngle +- ba1q1 +- song-cc-rock +- WangXu10 +- fit2cloudwxx +- 1myuan diff --git a/README.md b/README.md new file mode 100644 index 0000000..ecbda1c --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +

Cordys CRM

+

新一代的开源 AI CRM 系统

+

+ 1Panel-dev%2FCordysCRM | Trendshift +

+

+ Latest release + Stars + Download
+

+ +
+ +## 什么是 Cordys CRM? + +**Cordys CRM** 是新一代的开源 AI CRM 系统,是集信息化、数字化、智能化于一体的「客户关系管理系统」,由 [飞致云](https://fit2cloud.com/) 匠心出品。 + +Cordys CRM 能够帮助企业实现从线索到回款(L2C)的全流程精细化管理,覆盖线索获取、智能分配、客户与联系人管理、商机跟进、合同签约及回款执行,构建端到端的销售运营闭环。 + +Cordys CRM Overview + +## 核心优势 + +- **灵活配置 · 高效协同**:现代化架构,操作简洁流畅;精细权限与模块化配置,无缝集成主流办公平台,赋能团队高效协同; +- **安全自主 · 深度可控**:为私有化部署而生,数据100%自主可控;开放API与标准接口,支持深度集成与定制开发,筑牢安全与发展基石; +- **智能 BI · 决策赋能**:深度融合 DataEase 分析引擎,销售数据可视化呈现;支持自助探索与实时洞察,驱动精准决策与业绩增长; +- **AI 赋能 · 智能提效**:开放 CRM Skills 接口,让 AI 深入销售每个环节!OpenClaw、WorkBuddy 等智能助手 7×24 小时在线,从线索筛选到成单分析,全流程提效。 + +## 快速开始 + +### 安装部署 + +准备一台 Linux 服务器,安装好 [Docker](https://docs.docker.com/get-docker/) 后,执行以下一键安装脚本。 + +```bash +docker run -d \ + --name cordys-crm \ + --restart unless-stopped \ + -p 8081:8081 \ + -p 8082:8082 \ + -v ~/cordys:/opt/cordys \ + 1panel/cordys-crm +``` + +你也可以通过 [1Panel 应用商店](https://cordys.cn/docs/installation/1panel_installtion/) 来安装部署 Cordys CRM。 + +在无法联网的环境中,还可以通过 [离线安装包](https://cordys.cn/docs/installation/offline_installtion/) 来安装部署 Cordys CRM。 + +### 访问方式 + +- 在浏览器中打开: http://<你的服务器IP>:8081/ +- 用户名: `admin` +- 密码: `CordysCRM` + +### 联系我们 + +安装完成后,可以参考 [在线文档](https://cordys.cn/docs/) 来使用 Cordys CRM。 + +你可以通过下方的微信交流群,与 Cordys CRM 开源项目组进行交流和反馈。 + +Cordys CRM QRCode + +## UI 展示 + + + + + + + + + + + + + + +
SettingsRBAC
Opportunity ListOpportunity Detail
BIAI
+ +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=1Panel-dev/CordysCRM&type=date&legend=top-left)](https://www.star-history.com/#1Panel-dev/CordysCRM&type=date&legend=top-left) + +## Roadmap + +- [x] 2026.06: Cordys CRM 下载量突破 30 万次 +- [x] 2026.05.29:[v1.7.0 新增审批流、审批记录功能](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.7.0) 发布 +- [x] 2026.03.26:[v1.6.0 新增订单模块、计算组件函数功能增强](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.6.0) 发布 +- [x] 2026.03.11:[Cordys CRM Skills 正式发布](https://github.com/1Panel-dev/CordysCRM-skills) +- [x] 2026.01.29:[v1.5.0 新增工商抬头管理、发票管理、回款管理](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.5.0) +- [x] 2025.12.18:[v1.4.0 新增标讯、报价和合同模块](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.4.0) +- [x] 2025.12.12:[v1.3.6](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.3.6) 发布 +- [x] 2025.12.04:[v1.3.5](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.3.5) 发布 +- [x] 2025.11.28:[v1.3.4](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.3.4) 发布 +- [x] 2025.11.21:[v1.3.3](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.3.3) 发布 +- [x] 2025.11.14:[v1.3.2](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.3.2) 发布 +- [x] 2025.11.05:[v1.3.1](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.3.1) 发布 +- [x] 2025.11.03:[v1.3.0](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.3.0) 发布,代码正式开源 +- [x] 2025.10.17:[v1.2.3](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.2.3) 发布 +- [x] 2025.10.11:[v1.2.2](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.2.2) 发布 +- [x] 2025.09.26:[v1.2.1](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.2.1) 发布 +- [x] 2025.09.22:Cordys CRM 下载量突破 10 万次 +- [x] 2025.09.19:[v1.2.0](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.2.0) 发布,开放 MCP Server,并完成和 MaxKB 的对接 +- [x] 2025.09.12:[v1.1.9](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.1.9) 发布 +- [x] 2025.09.05:[v1.1.8](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.1.8) 发布 +- [x] 2025.09.01:[v1.1.7](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.1.7) 发布 +- [x] 2025.08.27:[v1.1.6](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.1.6) 发布 +- [x] 2025.08.27:[v1.1.5](https://github.com/1Panel-dev/CordysCRM/releases/tag/v1.1.5) 发布,开始公测 +- [x] 2025.08:完成与 SQLBot 和 DataEase 的对接 +- [x] 2025.07:吃自己的狗粮,成功替换飞致云使用 7 年的 Salesforce CRM +- [x] 2025.06:v1.0 开发完成 +- [x] 2024.09:写下第一行代码 + +## 技术栈 + +- 后端:[Spring Boot](https://spring.io/projects/spring-boot) +- 前端:[Vue.js](https://vuejs.org/) 、[Naive-UI](https://www.naiveui.com/) 、[Vant-UI](https://vant-ui.github.io/) +- 中间件:[MySQL](https://www.mysql.com/) , [Redis](https://redis.com/) +- 基础设施:[Docker](https://www.docker.com/) + +## 飞致云旗下的其他明星项目 + +- [JumpServer](https://github.com/jumpserver/jumpserver/) - 广受欢迎的开源堡垒机 +- [1Panel](https://github.com/1panel-dev/1panel/) - 现代化、开源的 Linux 服务器运维管理面板 +- [MaxKB](https://github.com/1panel-dev/MaxKB/) - 强大易用的企业级智能体平台 +- [DataEase](https://github.com/dataease/dataease/) - 人人可用的开源 BI 工具 +- [SQLBot](https://github.com/dataease/SQLBot/) - 基于大模型和 RAG 的智能问数系统 +- [MeterSphere](https://github.com/metersphere/metersphere/) - 新一代的开源持续测试工具 +- [Halo](https://github.com/halo-dev/halo/) - 强大易用的开源建站工具 + +## License + +本仓库遵循 [FIT2CLOUD Open Source License](LICENSE) 开源协议,该许可证本质上是 GPLv3,但有一些额外的限制。 + +你可以基于 Cordys CRM 的源代码进行二次开发,但是需要遵守以下规定: + +- 不能替换和修改 Cordys CRM 的 Logo 和版权信息; +- 二次开发后的衍生作品必须遵守 GPL V3 的开源义务。 + +如需商业授权,请联系:`support@fit2cloud.com`。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9ff802b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# 安全说明 + +如果您发现安全问题,请直接联系我们: + +- support@fit2cloud.com +- 400-052-0755 + +感谢您的支持! + +# Security Policy + +All security bugs should be reported to the contact as below: + +- support@fit2cloud.com +- 400-052-0755 + +Thanks for your support! diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..fdf8ce7 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,38 @@ +# Created by .ignore support plugin (hsz.mobi) +.DS_Store +node_modules +node/ +/dist + +# local env files +.env.local +.env.*.local + +# Log files +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor directories and files +.idea +*.iml +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + + +src/main/resources/static +src/main/resources/public + + +target +.settings +.project +.classpath +.factorypath +/crm/src/main/resources/packages/ +/app/src/main/resources/static/ +/crm/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/backend/app/pom.xml b/backend/app/pom.xml new file mode 100644 index 0000000..02a2939 --- /dev/null +++ b/backend/app/pom.xml @@ -0,0 +1,123 @@ + + + 4.0.0 + + cn.cordys + backend + ${revision} + + + app + ${revision} + app + + + + cn.cordys + framework + ${revision} + + + cn.cordys + crm + ${revision} + + + + + + + src/main/java + + **/*.properties + **/*.xml + **/*.json + **/*.tpl + **/*.js + + false + + + src/main/resources + + **/* + + false + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + CLASSIC + + + + + + maven-clean-plugin + + + + src/main/resources/static + + ** + + false + + + + + + + + org.apache.maven.plugins + maven-antrun-plugin + + + main-class-placement + generate-resources + + ${skipAntRunForJenkins} + + + + + + + + + + + + + run + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + alphabetical + ${argLine} -Dfile.encoding=UTF-8 + + + + + + + diff --git a/backend/app/src/main/java/cn/cordys/Application.java b/backend/app/src/main/java/cn/cordys/Application.java new file mode 100644 index 0000000..15beb0f --- /dev/null +++ b/backend/app/src/main/java/cn/cordys/Application.java @@ -0,0 +1,28 @@ +package cn.cordys; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.ldap.LdapAutoConfiguration; +import org.springframework.boot.autoconfigure.neo4j.Neo4jAutoConfiguration; +import org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration; +import org.springframework.boot.web.servlet.ServletComponentScan; +import org.springframework.context.annotation.PropertySource; + +@SpringBootApplication(exclude = { + QuartzAutoConfiguration.class, + LdapAutoConfiguration.class, + Neo4jAutoConfiguration.class +}) +@PropertySource(value = { + "classpath:commons.properties", + "file:/opt/cordys/conf/cordys-crm.properties", +}, encoding = "UTF-8", ignoreResourceNotFound = true) +@ServletComponentScan +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} + + diff --git a/backend/app/src/main/java/cn/cordys/listener/AppListener.java b/backend/app/src/main/java/cn/cordys/listener/AppListener.java new file mode 100644 index 0000000..b561b6b --- /dev/null +++ b/backend/app/src/main/java/cn/cordys/listener/AppListener.java @@ -0,0 +1,108 @@ +package cn.cordys.listener; + +import cn.cordys.common.service.DataInitService; +import cn.cordys.common.uid.impl.DefaultUidGenerator; +import cn.cordys.common.util.HikariCPUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.rsa.RsaKey; +import cn.cordys.common.util.rsa.RsaUtils; +import cn.cordys.crm.system.service.ExportTaskStopService; +import cn.cordys.crm.system.service.ExtScheduleService; +import cn.cordys.crm.system.service.SystemService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +class AppListener implements ApplicationRunner { + @Resource + private DefaultUidGenerator uidGenerator; + + @Resource + private ExtScheduleService extScheduleService; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + @Resource + private DataInitService dataInitService; + + @Resource + private ExportTaskStopService exportTaskStopService; + @Resource + private SystemService systemService; + + /** + * 应用启动后执行的初始化方法。 + *

+ * 此方法会依次初始化唯一 ID 生成器、MinIO 配置和 RSA 配置。 + *

+ * + * @param args 启动参数 + */ + @Override + public void run(ApplicationArguments args) { + log.info("===== 开始初始化配置 ====="); + + // 初始化唯一ID生成器 + uidGenerator.init(); + + // 初始化RSA配置 + log.info("初始化RSA配置"); + initializeRsaConfiguration(); + + log.info("初始化定时任务"); + extScheduleService.startEnableSchedules(); + + HikariCPUtils.printHikariCPStatus(); + + log.info("初始化默认组织数据"); + dataInitService.initOneTime(); + + log.info("停止导出任务"); + exportTaskStopService.stopPreparedAll(); + + log.info("清理表单缓存"); + systemService.clearFormCache(); + + log.info("===== 完成初始化配置 ====="); + } + + /** + * 初始化 RSA 配置。 + *

+ * 此方法首先尝试加载现有的 RSA 密钥。如果不存在,则生成新的 RSA 密钥并保存到文件系统。 + *

+ */ + private void initializeRsaConfiguration() { + String redisKey = "rsa:key"; + try { + // 从 Redis 获取 RSA 密钥 + String rsaStr = stringRedisTemplate.opsForValue().get(redisKey); + if (StringUtils.isNotBlank(rsaStr)) { + // 如果 RSA 密钥存在,反序列化并设置密钥 + RsaKey rsaKey = JSON.parseObject(rsaStr, RsaKey.class); + RsaUtils.setRsaKey(rsaKey); + return; + } + } catch (Exception e) { + log.error("从 Redis 获取 RSA 配置失败", e); + } + + try { + // 如果 Redis 中没有密钥,生成新的 RSA 密钥并保存到 Redis + RsaKey rsaKey = RsaUtils.getRsaKey(); + stringRedisTemplate.opsForValue().set(redisKey, JSON.toJSONString(rsaKey)); + RsaUtils.setRsaKey(rsaKey); + } catch (Exception e) { + log.error("初始化 RSA 配置失败", e); + } + } + + +} \ No newline at end of file diff --git a/backend/app/src/main/java/cn/cordys/listener/CustomError.java b/backend/app/src/main/java/cn/cordys/listener/CustomError.java new file mode 100644 index 0000000..57ad63c --- /dev/null +++ b/backend/app/src/main/java/cn/cordys/listener/CustomError.java @@ -0,0 +1,25 @@ +package cn.cordys.listener; + +import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * 自定义错误控制器类,用于处理应用中的错误页面请求。 + *

+ * 该控制器会将所有错误页面的请求重定向到网站的根页面("/")。 + *

+ */ +@Controller +public class CustomError implements ErrorController { + + /** + * 错误处理方法,当发生错误时,会将请求重定向到根页面。 + * + * @return 重定向到根页面 + */ + @GetMapping("/error") + public String redirectRoot() { + return "redirect:/"; + } +} diff --git a/backend/app/src/main/java/cn/cordys/listener/Index.java b/backend/app/src/main/java/cn/cordys/listener/Index.java new file mode 100644 index 0000000..f23ea88 --- /dev/null +++ b/backend/app/src/main/java/cn/cordys/listener/Index.java @@ -0,0 +1,44 @@ +package cn.cordys.listener; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * 主页控制器类,处理访问根页面("/")和登录页面("/login")的请求。 + *

+ * 该控制器负责将请求转发到 `index.html` 页面。 + *

+ */ +@Controller +public class Index { + /** + * 处理根路径("/")的请求,并返回首页 `index.html` 页面。 + * + * @return 返回首页的视图名称 + */ + @GetMapping("/web") + public String index() { + return "index.html"; + } + + /** + * 处理移动端根路径("/")的请求,并返回首页 `/mobile/index.html` 页面。 + * + * @return 返回首页的视图名称 + */ + @GetMapping("/mobile") + public String mobileIndex() { + return "mobile/index.html"; + } + + + /** + * 处理登录页面("/login")的请求,并返回 `index.html` 页面。 + * + * @return 返回登录页面的视图名称 + */ + @GetMapping(value = "/login") + public String login() { + return "/index.html"; + } +} diff --git a/backend/app/src/main/resources/banner.txt b/backend/app/src/main/resources/banner.txt new file mode 100644 index 0000000..f02d80e --- /dev/null +++ b/backend/app/src/main/resources/banner.txt @@ -0,0 +1,7 @@ + + ██████╗ ██████╗ ██████╗ ██████╗ ██╗ ██╗███████╗ ██████╗██████╗ ███╗ ███╗ +██╔════╝██╔═══██╗██╔══██╗██╔══██╗╚██╗ ██╔╝██╔════╝ ██╔════╝██╔══██╗████╗ ████║ +██║ ██║ ██║██████╔╝██║ ██║ ╚████╔╝ ███████╗ ██║ ██████╔╝██╔████╔██║ +██║ ██║ ██║██╔══██╗██║ ██║ ╚██╔╝ ╚════██║ ██║ ██╔══██╗██║╚██╔╝██║ +╚██████╗╚██████╔╝██║ ██║██████╔╝ ██║ ███████║ ╚██████╗██║ ██║██║ ╚═╝ ██║ + ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚═╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ diff --git a/backend/app/src/main/resources/commons.properties b/backend/app/src/main/resources/commons.properties new file mode 100644 index 0000000..404fadf --- /dev/null +++ b/backend/app/src/main/resources/commons.properties @@ -0,0 +1,93 @@ +# Application Settings +spring.application.name=cordys-crm +server.port=8081 + +# Compression Settings (gzip) +server.compression.enabled=true +server.compression.mime-types=application/json,application/xml,text/html,text/xml,text/plain,application/javascript,text/css,text/javascript,image/jpeg +server.compression.min-response-size=2048 + +# Logging Settings +logging.file.path=/opt/cordys/logs/cordys-crm + +# DataSource Configuration (HikariCP) +spring.datasource.type=com.zaxxer.hikari.HikariDataSource +spring.datasource.hikari.maximum-pool-size=100 +spring.datasource.hikari.minimum-idle=10 +spring.datasource.hikari.idle-timeout=300000 +spring.datasource.hikari.auto-commit=true +spring.datasource.hikari.pool-name=DatebookHikariCP +spring.datasource.hikari.max-lifetime=1800000 +spring.datasource.hikari.connection-timeout=30000 +spring.datasource.hikari.connection-test-query=SELECT 1 + +# Quartz Scheduler DataSource Settings +quartz.enabled=true +quartz.scheduler-name=cordys-crm-quartz +quartz.thread-count=10 +quartz.properties.org.quartz.jobStore.acquireTriggersWithinLock=true + +spring.datasource.quartz.url=${spring.datasource.url} +spring.datasource.quartz.username=${spring.datasource.username} +spring.datasource.quartz.password=${spring.datasource.password} +spring.datasource.quartz.hikari.maximum-pool-size=50 +spring.datasource.quartz.hikari.minimum-idle=10 +spring.datasource.quartz.hikari.idle-timeout=300000 +spring.datasource.quartz.hikari.auto-commit=true +spring.datasource.quartz.hikari.pool-name=DatebookHikariCP +spring.datasource.quartz.hikari.max-lifetime=1800000 +spring.datasource.quartz.hikari.connection-timeout=30000 +spring.datasource.quartz.hikari.connection-test-query=SELECT 1 + +# MyBatis Configuration +mybatis.configuration.cache-enabled=false +mybatis.configuration.lazy-loading-enabled=false +mybatis.configuration.aggressive-lazy-loading=true +mybatis.configuration.use-column-label=true +mybatis.configuration.auto-mapping-behavior=full +mybatis.configuration.default-statement-timeout=25000 +mybatis.configuration.map-underscore-to-camel-case=true + +# Virtual Thread Settings (for Thread Management) +spring.threads.virtual.enabled=true +spring.mvc.log-request-details=false + +# Flyway Database Migration Configuration +spring.flyway.enabled=true +spring.flyway.baseline-on-migrate=true +spring.flyway.locations=classpath:migration +spring.flyway.table=cordys_crm_version +spring.flyway.baseline-version=0 +spring.flyway.encoding=UTF-8 +spring.flyway.validate-on-migrate=false + +# File Upload Configuration +spring.servlet.multipart.max-file-size=1024MB +spring.servlet.multipart.max-request-size=1024MB + +# Redisson (Session Management with Redis) +spring.session.timeout=43200s +spring.session.redis.repository-type=indexed +spring.cache.type=redis + +#spring.redis.redisson.file=file:/opt/cordys/conf/redisson.yml + +# Template Engines (Freemarker, Groovy) +spring.freemarker.check-template-location=false +spring.groovy.template.check-template-location=false + +# Swagger Configuration (API Documentation) +springdoc.swagger-ui.enabled=true +springdoc.api-docs.enabled=true +springdoc.api-docs.groups.enabled=true + +# i18n +spring.messages.basename=i18n/cordys-crm + +# Enable whitelist functionality, if not enabled, access will not be restricted. +allowed.ip.ranges.enabled=false +# Enable whitelist functionality, if not enabled, access will not be restricted. +allowed.ip.ranges= + +# List of URLs that require XSS filtering, supports Ant-style path matching, e.g., /api/**. If no URLs need to be filtered, it can be left empty. +# xss.protection.url.list=/account/follow/**,/announcement/add \ No newline at end of file diff --git a/backend/app/src/main/resources/logback-spring.xml b/backend/app/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..9db3abf --- /dev/null +++ b/backend/app/src/main/resources/logback-spring.xml @@ -0,0 +1,185 @@ + + + + + + + + + + %d %5p %40.40c:%4L - %m%n + + + + + + + + TRACE + ACCEPT + DENY + + ${logging.file.path}/trace.log + + ${logging.file.path}/history/trace.%d{yyyyMMdd}-%i.log + ${logger.max.history:-30} + 50MB + + + UTF-8 + %d [%thread] %-5level %logger{36} %line - %msg%n + + + + + + DEBUG + ACCEPT + DENY + + ${logging.file.path}/debug.log + + ${logging.file.path}/history/debug.%d{yyyyMMdd}-%i.log + ${logger.max.history:-30} + 50MB + + + UTF-8 + %d [%thread] %-5level %logger{36} %line - %msg%n + + + + + + INFO + ACCEPT + DENY + + ${logging.file.path}/info.log + + ${logging.file.path}/history/info.%d{yyyyMMdd}-%i.log + ${logger.max.history:-30} + 50MB + + + UTF-8 + %d [%thread] %-5level %logger{36} %line - %msg%n + + + + + + WARN + ACCEPT + DENY + + ${logging.file.path}/warn.log + + ${logging.file.path}/history/warn.%d{yyyyMMdd}-%i.log + ${logger.max.history:-30} + 50MB + + + UTF-8 + %d [%thread] %-5level %logger{36} %line - %msg%n + + + + + + ERROR + ACCEPT + DENY + + ${logging.file.path}/error.log + + ${logging.file.path}/history/error.%d{yyyyMMdd}-%i.log + ${logger.max.history:-30} + 50MB + + + UTF-8 + %d [%thread] %-5level %logger{36} %line - %msg%n + + + + + + + + TRACE + ACCEPT + DENY + + 10000 + + + + + + DEBUG + ACCEPT + DENY + + 10000 + + + + + + INFO + ACCEPT + DENY + + 10000 + + + + + + WARN + ACCEPT + DENY + + 10000 + true + + + + + + ERROR + ACCEPT + DENY + + 10000 + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/crm/pom.xml b/backend/crm/pom.xml new file mode 100644 index 0000000..7100ae7 --- /dev/null +++ b/backend/crm/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + + cn.cordys + backend + ${revision} + + + crm + ${revision} + + + + cn.cordys + framework + ${revision} + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 21 + 21 + + + + + diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/BusinessModuleField.java b/backend/crm/src/main/java/cn/cordys/common/constants/BusinessModuleField.java new file mode 100644 index 0000000..41ebcf0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/BusinessModuleField.java @@ -0,0 +1,401 @@ +package cn.cordys.common.constants; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.SubField; +import lombok.Getter; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.Strings; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 业务模块字段(定义在主表中,有特定业务含义)(标准字段) + * + * @Author: jianxing + * @CreateTime: 2025-02-18 17:27 + */ +@Getter +public enum BusinessModuleField { + + /*------ start: CUSTOMER ------*/ + /** + * 客户名称 + */ + CUSTOMER_NAME("customerName", "name", Set.of("rules.required", "mobile", "readable"), FormKey.CUSTOMER.getKey()), + /** + * 负责人 + */ + CUSTOMER_OWNER("customerOwner", "owner", Set.of("rules.required", "mobile", "readable"), FormKey.CUSTOMER.getKey()), + /*------ end: CUSTOMER ------*/ + + /*------ start: CLUE ------*/ + /** + * 线索名称 + */ + CLUE_NAME("clueName", "name", Set.of("rules.required", "mobile", "readable"), FormKey.CLUE.getKey()), + /** + * 负责人 + */ + CLUE_OWNER("clueOwner", "owner", Set.of("rules.required", "mobile", "readable"), FormKey.CLUE.getKey()), + /** + * 联系人 + */ + CLUE_CONTACT("clueContactName", "contact", Set.of(), FormKey.CLUE.getKey()), + /** + * 联系人电话 + */ + CLUE_CONTACT_PHONE("clueContactPhone", "phone", Set.of(), FormKey.CLUE.getKey()), + /** + * 意向产品 + */ + CLUE_PRODUCTS("clueProduct", "products", Set.of(), FormKey.CLUE.getKey()), + /*------ end: CUSTOMER ------*/ + + /*------ start: CUSTOMER_MANAGEMENT_CONTACT ------*/ + /** + * 联系人客户id + */ + CUSTOMER_CONTACT_CUSTOMER("contactCustomer", "customerId", Set.of(), FormKey.CONTACT.getKey()), + /** + * 联系人责任人 + */ + CUSTOMER_CONTACT_OWNER("contactOwner", "owner", Set.of("rules.required", "mobile", "readable"), FormKey.CONTACT.getKey()), + /** + * 联系人名称 + */ + CUSTOMER_CONTACT_NAME("contactName", "name", Set.of("rules.required", "mobile", "readable"), FormKey.CONTACT.getKey()), + /** + * 联系人电话 + */ + CUSTOMER_CONTACT_PHONE("contactPhone", "phone", Set.of(), FormKey.CONTACT.getKey()), + /*------ end: CUSTOMER_MANAGEMENT_CONTACT ------*/ + + + /*------ start: OPPORTUNITY ------*/ + /** + * 商机名称 + */ + OPPORTUNITY_NAME("opportunityName", "name", Set.of("rules.required", "mobile", "readable"), FormKey.OPPORTUNITY.getKey()), + /** + * 客户名称 + */ + OPPORTUNITY_CUSTOMER_NAME("opportunityCustomer", "customerId", Set.of(), FormKey.OPPORTUNITY.getKey()), + /** + * 商机金额 + */ + OPPORTUNITY_AMOUNT("opportunityPrice", "amount", Set.of(), FormKey.OPPORTUNITY.getKey()), + /** + * 可能性 + */ + OPPORTUNITY_POSSIBLE("opportunityWinRate", "possible", Set.of(), FormKey.OPPORTUNITY.getKey()), + /** + * 结束时间 + */ + OPPORTUNITY_END_TIME("opportunityEndTime", "expectedEndTime", Set.of(), FormKey.OPPORTUNITY.getKey()), + /** + * 意向产品 + */ + OPPORTUNITY_PRODUCTS("opportunityProduct", "products", Set.of(), FormKey.OPPORTUNITY.getKey()), + /** + * 联系人 + */ + OPPORTUNITY_CONTACT("opportunityContact", "contactId", Set.of(), FormKey.OPPORTUNITY.getKey()), + + /** + * 负责人 + */ + OPPORTUNITY_OWNER("opportunityOwner", "owner", Set.of("rules.required", "mobile", "readable"), FormKey.OPPORTUNITY.getKey()), + /*------ end: OPPORTUNITY ------*/ + + + + /*------ start: FOLLOW_UP_RECORD ------*/ + /** + * 跟进类型 + */ + FOLLOW_RECORD_TYPE("recordType", "type", Set.of("options", "rules.required", "mobile", "readable"), FormKey.FOLLOW_RECORD.getKey()), + /** + * 客户id + */ + FOLLOW_RECORD_CUSTOMER("recordCustomer", "customerId", Set.of("rules.required", "mobile", "readable"), FormKey.FOLLOW_RECORD.getKey()), + /** + * 商机id + */ + FOLLOW_RECORD_OPPORTUNITY("recordOpportunity", "opportunityId", Set.of(), FormKey.FOLLOW_RECORD.getKey()), + /** + * 线索id + */ + FOLLOW_RECORD_CLUE("recordClue", "clueId", Set.of("rules.required", "mobile", "readable"), FormKey.FOLLOW_RECORD.getKey()), + /** + * 责任人id + */ + FOLLOW_RECORD_OWNER("recordOwner", "owner", Set.of("rules.required", "mobile", "readable"), FormKey.FOLLOW_RECORD.getKey()), + /** + * 联系人id + */ + FOLLOW_RECORD_CONTACT("recordContact", "contactId", Set.of(), FormKey.FOLLOW_RECORD.getKey()), + /** + * 跟进内容 + */ + FOLLOW_RECORD_CONTENT("recordDescription", "content", Set.of(), FormKey.FOLLOW_RECORD.getKey()), + /** + * 跟进时间 + */ + FOLLOW_RECORD_TIME("recordTime", "followTime", Set.of(), FormKey.FOLLOW_RECORD.getKey()), + /** + * 跟进方式 + */ + FOLLOW_METHOD("recordMethod", "followMethod", Set.of(), FormKey.FOLLOW_RECORD.getKey()), + /*------ end: FOLLOW_UP_RECORD ------*/ + + + /*------ start: FOLLOW_UP_PLAN ------*/ + /** + * 跟进类型 + */ + FOLLOW_PLAN_TYPE("planType", "type", Set.of("options", "rules.required", "mobile", "readable"), FormKey.FOLLOW_PLAN.getKey()), + /** + * 客户id + */ + FOLLOW_PLAN_CUSTOMER("planCustomer", "customerId", Set.of("rules.required", "mobile", "readable"), FormKey.FOLLOW_PLAN.getKey()), + /** + * 商机id + */ + FOLLOW_PLAN_OPPORTUNITY("planOpportunity", "opportunityId", Set.of(), FormKey.FOLLOW_PLAN.getKey()), + /** + * 线索id + */ + FOLLOW_PLAN_CLUE("planClue", "clueId", Set.of("rules.required", "mobile", "readable"), FormKey.FOLLOW_PLAN.getKey()), + /** + * 责任人id + */ + FOLLOW_PLAN_OWNER("planOwner", "owner", Set.of("rules.required", "mobile", "readable"), FormKey.FOLLOW_PLAN.getKey()), + /** + * 联系人id + */ + FOLLOW_PLAN_CONTACT("planContact", "contactId", Set.of(), FormKey.FOLLOW_PLAN.getKey()), + /** + * 预计开始时间 + */ + FOLLOW_PLAN_ESTIMATED_TIME("planStartTime", "estimatedTime", Set.of(), FormKey.FOLLOW_PLAN.getKey()), + /** + * 预计沟通内容 + */ + FOLLOW_PLAN_CONTENT("planContent", "content", Set.of(), FormKey.FOLLOW_PLAN.getKey()), + /** + * 跟进方式 + */ + FOLLOW_PLAN_METHOD("planMethod", "method", Set.of(), FormKey.FOLLOW_PLAN.getKey()), + + + /*------ end: FOLLOW_UP_PLAN ------*/ + + /*------ start: PRODUCT ------*/ + PRODUCT_NAME("productName", "name", Set.of("rules.required", "mobile", "readable"), FormKey.PRODUCT.getKey()), + PRODUCT_PRICE("productPrice", "price", Set.of(), FormKey.PRODUCT.getKey()), + PRODUCT_STATUS("productStatus", "status", Set.of("rules.required", "mobile", "readable"), FormKey.PRODUCT.getKey()), + /*------ end: PRODUCT ------*/ + + /** + * 价格表单 (修改价格子表格为自定义时, 注意处理对应详情解析逻辑) + */ + PRICE_NAME("priceName", "name", Set.of("rules.required", "mobile", "readable"), FormKey.PRICE.getKey()), + PRICE_STATUS("priceStatus", "status", Set.of("rules.required", "mobile", "readable"), FormKey.PRICE.getKey()), + PRICE_PRODUCT_TABLE("priceProducts", "products", Set.of("mobile", "readable"), FormKey.PRICE.getKey()), + PRICE_PRODUCT("priceProduct", "product", Set.of("rules.required", "mobile", "dataSourceType", "readable"), FormKey.PRICE.getKey()), + PRICE_PRODUCT_AMOUNT("priceProductAmount", "amount", Set.of("rules.required", "mobile", "readable"), FormKey.PRICE.getKey()), + + /** + * 报价单表单 + */ + QUOTATION_NAME("quotationName", "name", Set.of("rules.required", "mobile", "readable"), FormKey.QUOTATION.getKey()), + QUOTATION_OPPORTUNITY("quotationOpportunity", "opportunityId", Set.of("rules.required", "mobile", "dataSourceType", "readable"), FormKey.QUOTATION.getKey()), + QUOTATION_UNTIL_TIME("quotationUntilTime", "untilTime", Set.of("rules.required", "mobile", "readable"), FormKey.QUOTATION.getKey()), + QUOTATION_TOTAL_AMOUNT("quotationTotalAmount", "amount", Set.of("rules.required", "mobile", "readable"), FormKey.QUOTATION.getKey()), + + + /*------ start: CONTRACT_PAYMENT_PLAN ------*/ + /** + * 负责人 + */ + CONTRACT_PAYMENT_PLAN_OWNER("contractPaymentPlanOwner", "owner", Set.of("rules.required", "mobile", "readable"), FormKey.CONTRACT_PAYMENT_PLAN.getKey()), + /** + * 合同 + */ + CONTRACT_PAYMENT_PLAN_CONTRACT("contractPaymentPlanContract", "contractId", Set.of("rules.required", "dataSourceType", "mobile", "readable"), FormKey.CONTRACT_PAYMENT_PLAN.getKey()), + /** + * 计划回款金额 + */ + CONTRACT_PAYMENT_PLAN_PLAN_AMOUNT("contractPaymentPlanPlanAmount", "planAmount", Set.of("rules.required", "mobile", "readable"), FormKey.CONTRACT_PAYMENT_PLAN.getKey()), + /** + * 计划回款时间 + */ + CONTRACT_PAYMENT_PLAN_PLAN_END_TIME("contractPaymentPlanPlanEndTime", "planEndTime", Set.of("rules.required", "mobile", "readable"), FormKey.CONTRACT_PAYMENT_PLAN.getKey()), + /** + * 回款计划名称 + */ + CONTRACT_PAYMENT_PLAN_NAME("contractPaymentPlanName", "name", Set.of("rules.required", "mobile", "readable"), FormKey.CONTRACT_PAYMENT_PLAN.getKey()), + /*------ end: CONTRACT_PAYMENT_PLAN ------*/ + + + /*------ start: CONTRACT ------*/ + /** + * 合同名稱 + */ + CONTRACT_NAME("contractName", "name", Set.of("rules.required", "mobile", "readable"), FormKey.CONTRACT.getKey()), + + CONTRACT_CUSTOMER_NAME("contractCustomer", "customerId", Set.of("rules.required", "mobile", "readable", "dataSourceType"), FormKey.CONTRACT.getKey()), + CONTRACT_OWNER("contractOwner", "owner", Set.of("rules.required", "mobile", "readable"), FormKey.CONTRACT.getKey()), + CONTRACT_NO("contractNo", "number", Set.of("rules.required"), FormKey.CONTRACT.getKey()), + CONTRACT_START_TIME("contractStartTime", "startTime", Set.of("mobile", "readable"), FormKey.CONTRACT.getKey()), + CONTRACT_END_TIME("contractEndTime", "endTime", Set.of("mobile", "readable"), FormKey.CONTRACT.getKey()), + CONTRACT_TOTAL_AMOUNT("contractTotalAmount", "amount", Set.of("rules.required", "mobile", "readable"), FormKey.CONTRACT.getKey()), + + /*------ end: CONTRACT ------*/ + + /** + * 发票 + */ + /*------ start: CONTRACT_INVOICE ------*/ + INVOICE_NAME("invoiceName", "name", Set.of("rules.required", "mobile", "readable"), FormKey.INVOICE.getKey()), + INVOICE_OWNER("invoiceOwner", "owner", Set.of("rules.required", "mobile", "readable"), FormKey.INVOICE.getKey()), + INVOICE_AMOUNT("invoiceAmount", "amount", Set.of("rules.required", "readable", "mobile"), FormKey.INVOICE.getKey()), + INVOICE_CONTRACT_ID("invoiceContract", "contractId", Set.of("rules.required", "mobile", "readable", "dataSourceType"), FormKey.INVOICE.getKey()), + INVOICE_INVOICE_TYPE("invoiceType", "invoiceType", Set.of("rules.required", "readable", "mobile"), FormKey.INVOICE.getKey()), + INVOICE_TAX_RATE("invoiceTaxRate", "taxRate", Set.of("rules.required", "readable", "mobile"), FormKey.INVOICE.getKey()), + INVOICE_BUSINESS_TITLE_ID("invoiceBusinessTitle", "businessTitleId", Set.of("rules.required", "mobile", "readable", "dataSourceType"), FormKey.INVOICE.getKey()), + /*------ end: CONTRACT_INVOICE ------*/ + + + /*------ start: CONTRACT_PAYMENT_RECORD 合同回款记录 ------*/ + CONTRACT_PAYMENT_RECORD_NO("contractPaymentRecordNo", "no", Set.of("rules.required"), FormKey.CONTRACT_PAYMENT_RECORD.getKey()), + CONTRACT_PAYMENT_RECORD_NAME("contractPaymentRecordName", "name", Set.of("rules.required", "mobile", "readable"), FormKey.CONTRACT_PAYMENT_RECORD.getKey()), + CONTRACT_PAYMENT_RECORD_OWNER("contractPaymentRecordOwner", "owner", Set.of("rules.required", "mobile", "readable"), FormKey.CONTRACT_PAYMENT_RECORD.getKey()), + CONTRACT_PAYMENT_RECORD_CONTRACT("contractPaymentRecordContract", "contractId", Set.of("rules.required", "dataSourceType", "mobile", "readable"), FormKey.CONTRACT_PAYMENT_RECORD.getKey()), + CONTRACT_PAYMENT_RECORD_PLAN("contractPaymentRecordPlan", "paymentPlanId", Set.of("dataSourceType", "mobile", "readable"), FormKey.CONTRACT_PAYMENT_RECORD.getKey()), + CONTRACT_PAYMENT_RECORD_AMOUNT("contractPaymentRecordAmount", "recordAmount", Set.of("rules.required", "mobile", "readable"), FormKey.CONTRACT_PAYMENT_RECORD.getKey()), + CONTRACT_PAYMENT_RECORD_END_TIME("contractPaymentRecordEndTime", "recordEndTime", Set.of("rules.required", "mobile", "readable"), FormKey.CONTRACT_PAYMENT_RECORD.getKey()), + /*------ end: CONTRACT_PAYMENT_RECORD 合同回款记录 ------*/ + + /*------ start: ORDER ------*/ + ORDER_NAME("orderName", "name", Set.of("rules.required", "mobile", "readable"), FormKey.ORDER.getKey()), + ORDER_CUSTOMER("orderCustomer", "customerId", Set.of("dataSourceType"), FormKey.ORDER.getKey()), + ORDER_CONTRACT("orderContract", "contractId", Set.of("dataSourceType"), FormKey.ORDER.getKey()), + ORDER_OWNER("orderOwner", "owner", Set.of(), FormKey.ORDER.getKey()), + ORDER_NO("orderNo", "number", Set.of("rules.required"), FormKey.ORDER.getKey()), + ORDER_TOTAL_AMOUNT("orderAmount", "amount", Set.of("rules.required", "mobile", "readable"), FormKey.ORDER.getKey()), + /*------ end: ORDER ------*/ + + + /*------ start: ORDER ------*/ + CUSTOM_FORM_DATA_NAME("customFormDataName", "name", Set.of("rules.required", "mobile", "readable"), null), + CUSTOM_FORM_DATA_OWNER("customFormDataNOwner", "owner", Set.of("rules.required", "mobile", "readable"), null), + /*------ end: ORDER ------*/ + + ; + + /** + * 业务字段缓存 + */ + private static final Map INTERNAL_CACHE = new HashMap<>(); + + static { + for (BusinessModuleField field : values()) { + // 防止ofKey方法频繁调用 + INTERNAL_CACHE.put(field.key, field); + } + } + + /** + * 字段 key,field.json 中的 internalKey + */ + private final String key; + /** + * 业务字段 key + */ + private final String businessKey; + /** + * 禁止修改的参数列表 + */ + private final Set disabledProps; + /** + * 表单 key + */ + private final String formKey; + + BusinessModuleField(String key, String businessKey, Set disabledProps, String formKey) { + this.key = key; + this.businessKey = businessKey; + this.disabledProps = disabledProps; + this.formKey = formKey; + } + + /** + * 判断业务字段是否被删除 + * + * @param formKey 表单 key + * @param fields 字段集合 + * @return 是否被删除 + */ + public static boolean isBusinessDeleted(String formKey, List fields) { + List formBusinessFields; + if (FormKey.ofKey(formKey) == null) { + // 如果不是内置表单,则校验自定义表单字段必须要名字和负责人 + formBusinessFields = List.of(BusinessModuleField.CUSTOM_FORM_DATA_NAME, BusinessModuleField.CUSTOM_FORM_DATA_OWNER); + } else { + formBusinessFields = Arrays.stream(BusinessModuleField.values()).filter(field -> Strings.CS.equals(formKey, field.getFormKey())).toList(); + } + + if (CollectionUtils.isEmpty(formBusinessFields)) { + return false; + } + return formBusinessFields.stream() + .anyMatch(businessField -> + fields.stream().noneMatch(field -> Strings.CS.equals(businessField.getKey(), field.getInternalKey())) + && businessField.noneMatchOfSubFields(fields) + ); + } + + /** + * 判断子表字段中是否存在业务字段 + * + * @param fields 字段集合 + * @return 是否存在业务字段 + */ + private boolean noneMatchOfSubFields(List fields) { + boolean noneMatch = true; + for (BaseField field : fields) { + if (field instanceof SubField subField && CollectionUtils.isNotEmpty(subField.getSubFields())) { + noneMatch = subField.getSubFields().stream().noneMatch(sub -> Strings.CS.equals(this.getKey(), sub.getInternalKey())); + if (!noneMatch) { + break; + } + } + } + return noneMatch; + } + + /** + * 判断是否有重复的字段 + * + * @param fields 字段集合 + * @return 是否有重复的字段 + */ + public static boolean hasRepeatName(List fields) { + return fields.stream() + .collect(Collectors.groupingBy(BaseField::getName, Collectors.counting())) + .values().stream() + .anyMatch(count -> count > 1); + } + + /** + * 通过Key查询业务字段 + * + * @param internalKey 业务key + * @return 业务字段 + */ + public static BusinessModuleField ofKey(String internalKey) { + return INTERNAL_CACHE.get(internalKey); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/BusinessSearchType.java b/backend/crm/src/main/java/cn/cordys/common/constants/BusinessSearchType.java new file mode 100644 index 0000000..3228d8a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/BusinessSearchType.java @@ -0,0 +1,21 @@ +package cn.cordys.common.constants; + +/** + * 客户,线索,商机等业务数据的搜索类型 + * + * @author jianxing + */ +public enum BusinessSearchType { + /** + * 全部数据 + */ + ALL, + /** + * 负责人是我的数据 + */ + SELF, + /** + * 有数据权限的部门的数据 + */ + DEPARTMENT +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/ChartAggregateMethod.java b/backend/crm/src/main/java/cn/cordys/common/constants/ChartAggregateMethod.java new file mode 100644 index 0000000..71b74eb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/ChartAggregateMethod.java @@ -0,0 +1,13 @@ +package cn.cordys.common.constants; + +/** + * @Author: jianxing + * @CreateTime: 2025-10-14 10:11 + */ +public enum ChartAggregateMethod { + SUM, + AVG, + COUNT, + MAX, + MIN +} diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/CommonResultCode.java b/backend/crm/src/main/java/cn/cordys/common/constants/CommonResultCode.java new file mode 100644 index 0000000..bab4791 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/CommonResultCode.java @@ -0,0 +1,35 @@ +package cn.cordys.common.constants; + +import cn.cordys.common.exception.IResultCode; + +/** + * 通用功能状态码 + * 通用功能返回的状态码 + * + * @author jianxing + */ +public enum CommonResultCode implements IResultCode { + + FIELD_VALIDATE_ERROR(100001, "field_validate_error"), + FIELD_OPTION_VALUE_ERROR(100002, "field_option_value_error"), + APPROVAL_NOT_ENABLED_ERROR(100003, "approval.not.enabled"); + + + private final int code; + private final String message; + + CommonResultCode(int code, String message) { + this.code = code; + this.message = message; + } + + @Override + public int getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/FormKey.java b/backend/crm/src/main/java/cn/cordys/common/constants/FormKey.java new file mode 100644 index 0000000..27a7f2c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/FormKey.java @@ -0,0 +1,96 @@ +package cn.cordys.common.constants; + +import lombok.Getter; +import org.apache.commons.lang3.Strings; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * @author song-cc-rock + */ + +@Getter +public enum FormKey { + + /** + * 线索 + */ + CLUE("clue"), + /** + * 客户 + */ + CUSTOMER("customer"), + /** + * 联系人 + */ + CONTACT("contact"), + /** + * 跟进记录 + */ + FOLLOW_RECORD("record"), + /** + * 跟进计划 + */ + FOLLOW_PLAN("plan"), + /** + * 商机 + */ + OPPORTUNITY("opportunity"), + /** + * 产品 + */ + PRODUCT("product"), + /** + * 价格 + */ + PRICE("price"), + /** + * 报价单 + */ + QUOTATION("quotation"), + /** + * 合同 + */ + CONTRACT("contract"), + /** + * 发票 + */ + INVOICE("invoice"), + /** + * 合同回款计划 + */ + CONTRACT_PAYMENT_PLAN("contractPaymentPlan"), + /** + * 回款记录 + */ + CONTRACT_PAYMENT_RECORD("contractPaymentRecord"), + /** + * 订单 + */ + ORDER("order"); + + private final String key; + + FormKey(String key) { + this.key = key; + } + + public static List allKeys() { + return Arrays.stream(FormKey.values()).map(FormKey::getKey).collect(Collectors.toList()); + } + + public static FormKey ofKey(String key) { + for (FormKey formKey : FormKey.values()) { + if (Strings.CI.equals(formKey.getKey(), key)) { + return formKey; + } + } + return null; + } + + public boolean hasSnapshot() { + return Strings.CI.equalsAny(this.key, CONTRACT.getKey(), INVOICE.getKey(), QUOTATION.getKey(), ORDER.getKey()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/FormKeyConstants.java b/backend/crm/src/main/java/cn/cordys/common/constants/FormKeyConstants.java new file mode 100644 index 0000000..de79537 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/FormKeyConstants.java @@ -0,0 +1,25 @@ +package cn.cordys.common.constants; + +import lombok.Getter; + +/** + * @author song-cc-rock + */ + +@Getter +public class FormKeyConstants { + + public static final String ORDER = "order"; + public static final String CLUE = "clue"; + public static final String CUSTOMER = "customer"; + public static final String CONTRACT = "contract"; + public static final String CONTRACT_INVOICE = "contractInvoice"; + public static final String CONTRACT_PAYMENT_PLAN = "contractPaymentPlan"; + public static final String CONTRACT_PAYMENT_RECORD = "contractPaymentRecord"; + public static final String OPPORTUNITY = "opportunity"; + public static final String QUOTATION = "quotation"; + public static final String FOLLOW_PLAN = "plan"; + public static final String FOLLOW_RECORD = "record"; + + +} diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/HttpMethodConstants.java b/backend/crm/src/main/java/cn/cordys/common/constants/HttpMethodConstants.java new file mode 100644 index 0000000..ebbb14c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/HttpMethodConstants.java @@ -0,0 +1,13 @@ +package cn.cordys.common.constants; + +public enum HttpMethodConstants { + GET, + HEAD, + POST, + PUT, + PATCH, + DELETE, + OPTIONS, + TRACE, + CONNECT +} diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/InternalRole.java b/backend/crm/src/main/java/cn/cordys/common/constants/InternalRole.java new file mode 100644 index 0000000..3990946 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/InternalRole.java @@ -0,0 +1,22 @@ +package cn.cordys.common.constants; + +import lombok.Getter; + +/** + * 系统内置角色ID + * + * @author jianxing + */ +@Getter +public enum InternalRole { + ORG_ADMIN("org_admin"), + SALES_MANAGER("sales_manager"), + SALES_STAFF("sales_staff"); + + private final String value; + + InternalRole(String value) { + this.value = value; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/InternalUserView.java b/backend/crm/src/main/java/cn/cordys/common/constants/InternalUserView.java new file mode 100644 index 0000000..88a82a7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/InternalUserView.java @@ -0,0 +1,71 @@ +package cn.cordys.common.constants; + + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * @Author: jianxing + * @CreateTime: 2025-07-25 14:05 + */ +public enum InternalUserView { + /** + * 全部视图 + */ + ALL, + /** + * 个人视图 + */ + SELF, + /** + * 部门视图 + */ + DEPARTMENT, + /** + * 协作客户视图 + */ + CUSTOMER_COLLABORATION, + /** + * 赢单视图 + */ + OPPORTUNITY_SUCCESS; + + public static final String CURRENT_USER = "CURRENT_USER"; + + + public static List getCurrentUserArrayValue() { + List values = new ArrayList<>(0); + values.add(CURRENT_USER); + return values; + } + + public static boolean isInternalUserView(String viewId) { + if (StringUtils.isBlank(viewId)) { + return false; + } + return Arrays.stream(InternalUserView.values()) + .map(InternalUserView::name) + .collect(Collectors.toSet()).contains(viewId); + } + + public static boolean isAll(String searchType) { + return Strings.CS.equals(ALL.name(), searchType); + } + + public static boolean isSelf(String searchType) { + return Strings.CS.equals(SELF.name(), searchType); + } + + public static boolean isDepartment(String searchType) { + return Strings.CS.equals(DEPARTMENT.name(), searchType); + } + + public static boolean isVisible(String searchType) { + return Strings.CS.equals(CUSTOMER_COLLABORATION.name(), searchType); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/LinkScenarioKey.java b/backend/crm/src/main/java/cn/cordys/common/constants/LinkScenarioKey.java new file mode 100644 index 0000000..dc7e012 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/LinkScenarioKey.java @@ -0,0 +1,50 @@ +package cn.cordys.common.constants; + +/** + * 联动场景 + * + * @author song-cc-rock + */ +public enum LinkScenarioKey { + + /** + * 线索转客户 + */ + CLUE_TO_CUSTOMER, + /** + * 线索转联系人 + */ + CLUE_TO_CONTACT, + /** + * 线索转商机 + */ + CLUE_TO_OPPORTUNITY, + /** + * 客户转商机 + */ + CUSTOMER_TO_OPPORTUNITY, + /** + * 线索转记录 + */ + CLUE_TO_RECORD, + /** + * 客户转记录 + */ + CUSTOMER_TO_RECORD, + /** + * 商机转记录 + */ + OPPORTUNITY_TO_RECORD, + /** + * 计划转记录 + */ + PLAN_TO_RECORD, + /** + * 合同转发票 + */ + CONTRACT_TO_INVOICE, + /** + * 合同转订单 + */ + CONTRACT_TO_ORDER, +} diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/ModuleKey.java b/backend/crm/src/main/java/cn/cordys/common/constants/ModuleKey.java new file mode 100644 index 0000000..787ca12 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/ModuleKey.java @@ -0,0 +1,46 @@ +package cn.cordys.common.constants; + +import lombok.Getter; + +@Getter +public enum ModuleKey { + + /** + * 首页 + */ + HOME("home"), + /** + * 线索管理 + */ + CLUE("clue"), + /** + * 客户管理 + */ + CUSTOMER("customer"), + /** + * 商机管理 + */ + BUSINESS("business"), + /** + * 产品管理 + */ + PRODUCT("product"), + /** + * 系统设置 + */ + SETTING("setting"); + + + /** + * ******************************************* + * 注意: + * 新增菜单不要在moduleKey中添加了 + * ******************************************* + */ + + private final String key; + + ModuleKey(String key) { + this.key = key; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/PermissionConstants.java b/backend/crm/src/main/java/cn/cordys/common/constants/PermissionConstants.java new file mode 100644 index 0000000..e3d631d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/PermissionConstants.java @@ -0,0 +1,258 @@ +package cn.cordys.common.constants; + +/** + * @author jianxing + * @date 2025-01-03 11:31:40 + */ +public class PermissionConstants { + + /*------ start: SYSTEM_ROLE ------*/ + public static final String SYSTEM_ROLE_READ = "SYSTEM_ROLE:READ"; + public static final String SYSTEM_ROLE_ADD = "SYSTEM_ROLE:ADD"; + public static final String SYSTEM_ROLE_UPDATE = "SYSTEM_ROLE:UPDATE"; + public static final String SYSTEM_ROLE_DELETE = "SYSTEM_ROLE:DELETE"; + public static final String SYSTEM_ROLE_ADD_USER = "SYSTEM_ROLE:ADD_USER"; + public static final String SYSTEM_ROLE_REMOVE_USER = "SYSTEM_ROLE:REMOVE_USER"; + /*------ end: SYSTEM_ROLE------*/ + + + /*------ start: OPERATION_LOG ------*/ + public static final String OPERATION_LOG_READ = "OPERATION_LOG:READ"; + /*------ end: OPERATION_LOG ------*/ + + /*------ start: SYSTEM_NOTICE ------*/ + public static final String SYSTEM_NOTICE_READ = "SYSTEM_NOTICE:READ"; + public static final String SYSTEM_NOTICE_ADD = "SYSTEM_NOTICE:ADD"; + public static final String SYSTEM_NOTICE_UPDATE = "SYSTEM_NOTICE:UPDATE"; + public static final String SYSTEM_NOTICE_DELETE = "SYSTEM_NOTICE:DELETE"; + /*------ end: SYSTEM_NOTICE ------*/ + + /*------ start: SYS_DEPARTMENT ------*/ + public static final String SYS_ORGANIZATION_READ = "SYS_ORGANIZATION:READ"; + public static final String SYS_ORGANIZATION_ADD = "SYS_ORGANIZATION:ADD"; + public static final String SYS_ORGANIZATION_UPDATE = "SYS_ORGANIZATION:UPDATE"; + public static final String SYS_ORGANIZATION_DELETE = "SYS_ORGANIZATION:DELETE"; + public static final String SYS_ORGANIZATION_IMPORT = "SYS_ORGANIZATION:IMPORT"; + public static final String SYS_ORGANIZATION_SYNC = "SYS_ORGANIZATION:SYNC"; + public static final String SYS_ORGANIZATION_USER_RESET_PASSWORD = "SYS_ORGANIZATION_USER:RESET_PASSWORD"; + + /*------ end: SYS_DEPARTMENT ------*/ + + /*------ start: SYSTEM_SETTING ------*/ + public static final String SYSTEM_SETTING_READ = "SYSTEM_SETTING:READ"; + public static final String SYSTEM_SETTING_UPDATE = "SYSTEM_SETTING:UPDATE"; + public static final String SYSTEM_SETTING_ADD = "SYSTEM_SETTING:ADD"; + public static final String SYSTEM_SETTING_DELETE = "SYSTEM_SETTING:DELETE"; + /*------ end: SYSTEM_SETTING ------*/ + + /** + * module setting permission + */ + public static final String MODULE_SETTING_READ = "MODULE_SETTING:READ"; + public static final String MODULE_SETTING_UPDATE = "MODULE_SETTING:UPDATE"; + + + /*------ start: CUSTOMER_MANAGEMENT------*/ + public static final String CUSTOMER_MANAGEMENT_READ = "CUSTOMER_MANAGEMENT:READ"; + public static final String CUSTOMER_MANAGEMENT_ADD = "CUSTOMER_MANAGEMENT:ADD"; + public static final String CUSTOMER_MANAGEMENT_UPDATE = "CUSTOMER_MANAGEMENT:UPDATE"; + public static final String CUSTOMER_MANAGEMENT_TRANSFER = "CUSTOMER_MANAGEMENT:TRANSFER"; + public static final String CUSTOMER_MANAGEMENT_RECYCLE = "CUSTOMER_MANAGEMENT:RECYCLE"; + public static final String CUSTOMER_MANAGEMENT_DELETE = "CUSTOMER_MANAGEMENT:DELETE"; + public static final String CUSTOMER_MANAGEMENT_EXPORT = "CUSTOMER_MANAGEMENT:EXPORT"; + public static final String CUSTOMER_MANAGEMENT_IMPORT = "CUSTOMER_MANAGEMENT:IMPORT"; + public static final String CUSTOMER_MANAGEMENT_MERGE = "CUSTOMER_MANAGEMENT:MERGE"; + /*------ end: CUSTOMER_MANAGEMENT ------*/ + + + /*------ start: CUSTOMER_MANAGEMENT_POOL ------*/ + public static final String CUSTOMER_MANAGEMENT_POOL_READ = "CUSTOMER_MANAGEMENT_POOL:READ"; + public static final String CUSTOMER_MANAGEMENT_POOL_UPDATE = "CUSTOMER_MANAGEMENT_POOL:UPDATE"; + public static final String CUSTOMER_MANAGEMENT_POOL_DELETE = "CUSTOMER_MANAGEMENT_POOL:DELETE"; + public static final String CUSTOMER_MANAGEMENT_POOL_PICK = "CUSTOMER_MANAGEMENT_POOL:PICK"; + public static final String CUSTOMER_MANAGEMENT_POOL_ASSIGN = "CUSTOMER_MANAGEMENT_POOL:ASSIGN"; + public static final String CUSTOMER_MANAGEMENT_POOL_EXPORT = "CUSTOMER_MANAGEMENT_POOL:EXPORT"; + /*------ end: CUSTOMER_MANAGEMENT_POOL ------*/ + + + /*------ start: CUSTOMER_MANAGEMENT_CONTACT ------*/ + public static final String CUSTOMER_MANAGEMENT_CONTACT_READ = "CUSTOMER_MANAGEMENT_CONTACT:READ"; + public static final String CUSTOMER_MANAGEMENT_CONTACT_ADD = "CUSTOMER_MANAGEMENT_CONTACT:ADD"; + public static final String CUSTOMER_MANAGEMENT_CONTACT_UPDATE = "CUSTOMER_MANAGEMENT_CONTACT:UPDATE"; + public static final String CUSTOMER_MANAGEMENT_CONTACT_DELETE = "CUSTOMER_MANAGEMENT_CONTACT:DELETE"; + public static final String CUSTOMER_MANAGEMENT_CONTACT_EXPORT = "CUSTOMER_MANAGEMENT_CONTACT:EXPORT"; + public static final String CUSTOMER_MANAGEMENT_CONTACT_IMPORT = "CUSTOMER_MANAGEMENT_CONTACT:IMPORT"; + /*------ end: CUSTOMER_MANAGEMENT_CONTACT ------*/ + + /*------ start: PRODUCT_MANAGEMENT ------*/ + public static final String PRODUCT_MANAGEMENT_READ = "PRODUCT_MANAGEMENT:READ"; + public static final String PRODUCT_MANAGEMENT_ADD = "PRODUCT_MANAGEMENT:ADD"; + public static final String PRODUCT_MANAGEMENT_UPDATE = "PRODUCT_MANAGEMENT:UPDATE"; + public static final String PRODUCT_MANAGEMENT_DELETE = "PRODUCT_MANAGEMENT:DELETE"; + public static final String PRODUCT_MANAGEMENT_IMPORT = "PRODUCT_MANAGEMENT:IMPORT"; + /*------ end: PRODUCT_MANAGEMENT ------*/ + + /*------ start: OPPORTUNITY_MANAGEMENT ------*/ + public static final String OPPORTUNITY_MANAGEMENT_READ = "OPPORTUNITY_MANAGEMENT:READ"; + public static final String OPPORTUNITY_MANAGEMENT_ADD = "OPPORTUNITY_MANAGEMENT:ADD"; + public static final String OPPORTUNITY_MANAGEMENT_UPDATE = "OPPORTUNITY_MANAGEMENT:UPDATE"; + public static final String OPPORTUNITY_MANAGEMENT_TRANSFER = "OPPORTUNITY_MANAGEMENT:TRANSFER"; + public static final String OPPORTUNITY_MANAGEMENT_DELETE = "OPPORTUNITY_MANAGEMENT:DELETE"; + public static final String OPPORTUNITY_MANAGEMENT_EXPORT = "OPPORTUNITY_MANAGEMENT:EXPORT"; + public static final String OPPORTUNITY_MANAGEMENT_RESIGN = "OPPORTUNITY_MANAGEMENT:RESIGN"; + public static final String OPPORTUNITY_MANAGEMENT_IMPORT = "OPPORTUNITY_MANAGEMENT:IMPORT"; + /*------ end: OPPORTUNITY_MANAGEMENT ------*/ + + + /** + * clue permission + */ + /*------ start: CLUE_MANAGEMENT ------*/ + public static final String CLUE_MANAGEMENT_READ = "CLUE_MANAGEMENT:READ"; + public static final String CLUE_MANAGEMENT_ADD = "CLUE_MANAGEMENT:ADD"; + public static final String CLUE_MANAGEMENT_UPDATE = "CLUE_MANAGEMENT:UPDATE"; + public static final String CLUE_MANAGEMENT_TRANSFER = "CLUE_MANAGEMENT:TRANSFER"; + public static final String CLUE_MANAGEMENT_RECYCLE = "CLUE_MANAGEMENT:RECYCLE"; + public static final String CLUE_MANAGEMENT_DELETE = "CLUE_MANAGEMENT:DELETE"; + public static final String CLUE_MANAGEMENT_EXPORT = "CLUE_MANAGEMENT:EXPORT"; + public static final String CLUE_MANAGEMENT_IMPORT = "CLUE_MANAGEMENT:IMPORT"; + /*------ end: CLUE_MANAGEMENT ------*/ + + + /*------ start: CLUE_MANAGEMENT_POOL ------*/ + public static final String CLUE_MANAGEMENT_POOL_READ = "CLUE_MANAGEMENT_POOL:READ"; + public static final String CLUE_MANAGEMENT_POOL_DELETE = "CLUE_MANAGEMENT_POOL:DELETE"; + public static final String CLUE_MANAGEMENT_POOL_PICK = "CLUE_MANAGEMENT_POOL:PICK"; + public static final String CLUE_MANAGEMENT_POOL_ASSIGN = "CLUE_MANAGEMENT_POOL:ASSIGN"; + public static final String CLUE_MANAGEMENT_POOL_UPDATE = "CLUE_MANAGEMENT_POOL:UPDATE"; + public static final String CLUE_MANAGEMENT_POOL_EXPORT = "CLUE_MANAGEMENT_POOL:EXPORT"; + /*------ end: CLUE_MANAGEMENT_POOL ------*/ + + /** + * dashboard permission + */ + public static final String DASHBOARD_READ = "DASHBOARD:READ"; + public static final String DASHBOARD_ADD = "DASHBOARD:ADD"; + public static final String DASHBOARD_EDIT = "DASHBOARD:UPDATE"; + public static final String DASHBOARD_DELETE = "DASHBOARD:DELETE"; + + /*------ start: LICENSE ------*/ + public static final String LICENSE_READ = "LICENSE:READ"; + public static final String LICENSE_EDIT = "LICENSE:EDIT"; + /*------ end: LICENSE ------*/ + + + /*------ start: PERSON INFO ------*/ + public static final String PERSONAL_API_KEY_READ = "PERSONAL_API_KEY:READ"; + public static final String PERSONAL_API_KEY_ADD = "PERSONAL_API_KEY:ADD"; + public static final String PERSONAL_API_KEY_UPDATE = "PERSONAL_API_KEY:UPDATE"; + public static final String PERSONAL_API_KEY_DELETE = "PERSONAL_API_KEY:DELETE"; + /*------ end: PERSON INFO ------*/ + + + /*------ start: AGENT ------*/ + public static final String AGENT_READ = "AGENT:READ"; + public static final String AGENT_ADD = "AGENT:ADD"; + public static final String AGENT_UPDATE = "AGENT:UPDATE"; + public static final String AGENT_DELETE = "AGENT:DELETE"; + /*------ end: AGENT ------*/ + + /** + * product price permission + */ + public static final String PRICE_READ = "PRICE:READ"; + public static final String PRICE_ADD = "PRICE:ADD"; + public static final String PRICE_UPDATE = "PRICE:UPDATE"; + public static final String PRICE_DELETE = "PRICE:DELETE"; + public static final String PRICE_IMPORT = "PRICE:IMPORT"; + public static final String PRICE_EXPORT = "PRICE:EXPORT"; + + + /*------ start: OPPORTUNITY_QUOTATION ------*/ + + public static final String OPPORTUNITY_QUOTATION_READ = "OPPORTUNITY_QUOTATION:READ"; + public static final String OPPORTUNITY_QUOTATION_ADD = "OPPORTUNITY_QUOTATION:ADD"; + public static final String OPPORTUNITY_QUOTATION_UPDATE = "OPPORTUNITY_QUOTATION:UPDATE"; + public static final String OPPORTUNITY_QUOTATION_DELETE = "OPPORTUNITY_QUOTATION:DELETE"; + public static final String OPPORTUNITY_QUOTATION_DOWNLOAD = "OPPORTUNITY_QUOTATION:DOWNLOAD"; + public static final String OPPORTUNITY_QUOTATION_VOIDED = "OPPORTUNITY_QUOTATION:VOIDED"; + public static final String OPPORTUNITY_QUOTATION_APPROVAL = "OPPORTUNITY_QUOTATION:APPROVAL"; + + /*------ end: OPPORTUNITY_QUOTATION ------*/ + + /*------ start: CONTRACT ------*/ + public static final String CONTRACT_READ = "CONTRACT:READ"; + public static final String CONTRACT_ADD = "CONTRACT:ADD"; + public static final String CONTRACT_UPDATE = "CONTRACT:UPDATE"; + public static final String CONTRACT_DELETE = "CONTRACT:DELETE"; + public static final String CONTRACT_EXPORT = "CONTRACT:EXPORT"; + public static final String CONTRACT_APPROVAL = "CONTRACT:APPROVAL"; + public static final String CONTRACT_STAGE = "CONTRACT:STAGE"; + public static final String CONTRACT_PAYMENT = "CONTRACT:PAYMENT"; + + /*------ end: CONTRACT ------*/ + + /*------ start: CONTRACT_CONTRACT_PAYMENT_PLAN_ROLE ------*/ + public static final String CONTRACT_PAYMENT_PLAN_READ = "CONTRACT_PAYMENT_PLAN:READ"; + public static final String CONTRACT_PAYMENT_PLAN_ADD = "CONTRACT_PAYMENT_PLAN:ADD"; + public static final String CONTRACT_PAYMENT_PLAN_UPDATE = "CONTRACT_PAYMENT_PLAN:UPDATE"; + public static final String CONTRACT_PAYMENT_PLAN_DELETE = "CONTRACT_PAYMENT_PLAN:DELETE"; + /*------ end: CONTRACT_CONTRACT_PAYMENT_PLAN_ROLE ------*/ + + + /*------ start: TENDER ------*/ + public static final String TENDER_READ = "TENDER:READ"; + /*------ end: TENDER ------*/ + + + /*------ start: CONTRACT_INVOICE_ROLE ------*/ + public static final String CONTRACT_INVOICE_READ = "CONTRACT_INVOICE:READ"; + public static final String CONTRACT_INVOICE_ADD = "CONTRACT_INVOICE:ADD"; + public static final String CONTRACT_INVOICE_UPDATE = "CONTRACT_INVOICE:UPDATE"; + public static final String CONTRACT_INVOICE_EXPORT = "CONTRACT_INVOICE:EXPORT"; + public static final String CONTRACT_INVOICE_APPROVAL = "CONTRACT_INVOICE:APPROVAL"; + public static final String CONTRACT_INVOICE_DELETE = "CONTRACT_INVOICE:DELETE"; + /*------ end: CONTRACT_INVOICE_ROLE ------*/ + + /*------ start: BUSINESS_TITLE ------*/ + public static final String CONTRACT_BUSINESS_TITLE_READ = "CONTRACT_BUSINESS_TITLE:READ"; + public static final String CONTRACT_BUSINESS_TITLE_ADD = "CONTRACT_BUSINESS_TITLE:ADD"; + public static final String CONTRACT_BUSINESS_TITLE_UPDATE = "CONTRACT_BUSINESS_TITLE:UPDATE"; + public static final String CONTRACT_BUSINESS_TITLE_DELETE = "CONTRACT_BUSINESS_TITLE:DELETE"; + public static final String CONTRACT_BUSINESS_TITLE_EXPORT = "CONTRACT_BUSINESS_TITLE:EXPORT"; + public static final String CONTRACT_BUSINESS_TITLE_APPROVAL = "CONTRACT_BUSINESS_TITLE:APPROVAL"; + public static final String CONTRACT_BUSINESS_TITLE_IMPORT = "CONTRACT_BUSINESS_TITLE:IMPORT"; + + /*------ end: BUSINESS_TITLE ------*/ + + /** + * Contract payment record permission + */ + public static final String CONTRACT_PAYMENT_RECORD_READ = "CONTRACT_PAYMENT_RECORD:READ"; + public static final String CONTRACT_PAYMENT_RECORD_ADD = "CONTRACT_PAYMENT_RECORD:ADD"; + public static final String CONTRACT_PAYMENT_RECORD_UPDATE = "CONTRACT_PAYMENT_RECORD:UPDATE"; + public static final String CONTRACT_PAYMENT_RECORD_DELETE = "CONTRACT_PAYMENT_RECORD:DELETE"; + public static final String CONTRACT_PAYMENT_RECORD_IMPORT = "CONTRACT_PAYMENT_RECORD:IMPORT"; + public static final String CONTRACT_PAYMENT_RECORD_EXPORT = "CONTRACT_PAYMENT_RECORD:EXPORT"; + + /*------ start: ORDER_ROLE ------*/ + public static final String ORDER_READ = "ORDER:READ"; + public static final String ORDER_ADD = "ORDER:ADD"; + public static final String ORDER_UPDATE = "ORDER:UPDATE"; + public static final String ORDER_DELETE = "ORDER:DELETE"; + public static final String ORDER_DOWNLOAD = "ORDER:DOWNLOAD"; + /*------ end: ORDER_ROLE ------*/ + + /*------ start: PROCESS_SETTING ------*/ + public static final String PROCESS_SETTING_READ = "PROCESS_SETTING:READ"; + public static final String PROCESS_SETTING_ADD = "PROCESS_SETTING:ADD"; + public static final String PROCESS_SETTING_UPDATE = "PROCESS_SETTING:UPDATE"; + public static final String PROCESS_SETTING_DELETE = "PROCESS_SETTING:DELETE"; + /*------ end: PROCESS_SETTING ------*/ + + /*------ start: CUSTOM_FORM ------*/ + public static final String CUSTOM_FORM_READ = "CUSTOM_FORM:READ"; + public static final String CUSTOM_FORM_ADD = "CUSTOM_FORM:ADD"; + /*------ end: CUSTOM_FORM ------*/ +} + diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/RoleDataScope.java b/backend/crm/src/main/java/cn/cordys/common/constants/RoleDataScope.java new file mode 100644 index 0000000..fc42062 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/RoleDataScope.java @@ -0,0 +1,26 @@ +package cn.cordys.common.constants; + +/** + * 角色的数据权限范围 + * + * @Author: jianxing + * @CreateTime: 2025-01-07 16:42 + */ +public enum RoleDataScope { + /** + * 全部数据权限 + */ + ALL, + /** + * 指定部门数据权限 + */ + DEPT_CUSTOM, + /** + * 部门及以下数据权限 + */ + DEPT_AND_CHILD, + /** + * 仅本人数据权限 + */ + SELF +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/RuleValidatorConstants.java b/backend/crm/src/main/java/cn/cordys/common/constants/RuleValidatorConstants.java new file mode 100644 index 0000000..6a6a037 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/RuleValidatorConstants.java @@ -0,0 +1,13 @@ +package cn.cordys.common.constants; + +public class RuleValidatorConstants { + + /** + * 必填 + */ + public static final String REQUIRED = "required"; + /** + * 唯一 + */ + public static final String UNIQUE = "unique"; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/ThirdConfigTypeConstants.java b/backend/crm/src/main/java/cn/cordys/common/constants/ThirdConfigTypeConstants.java new file mode 100644 index 0000000..fb88c80 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/ThirdConfigTypeConstants.java @@ -0,0 +1,53 @@ +package cn.cordys.common.constants; + +/** + * 部门来源类型 + */ +public enum ThirdConfigTypeConstants { + + /** + * 本地 + */ + INTERNAL, + /** + * 企业微信 + */ + WECOM, + /** + * 钉钉 + */ + DINGTALK, + /** + * 飞书 + */ + LARK, + /** + * DE + */ + DE, + /** + * SQLBOT + */ + SQLBOT, + /** + * maxKB + */ + MAXKB, + /** + * tender + */ + TENDER, + /** + * 企查查 + */ + QCC; + + public static ThirdConfigTypeConstants fromString(String type) { + try { + return ThirdConfigTypeConstants.valueOf(type.toUpperCase()); + } catch (Exception e) { + return null; + } + } + +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/ThirdDetailType.java b/backend/crm/src/main/java/cn/cordys/common/constants/ThirdDetailType.java new file mode 100644 index 0000000..2b3212f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/ThirdDetailType.java @@ -0,0 +1,5 @@ +package cn.cordys.common.constants; + +public enum ThirdDetailType { + WECOM_SYNC, DINGTALK_SYNC, LARK_SYNC, DE_BOARD, SQLBOT_CHAT, SQLBOT_BOARD, MAXKB, TENDER, QCC +} diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/TopicConstants.java b/backend/crm/src/main/java/cn/cordys/common/constants/TopicConstants.java new file mode 100644 index 0000000..0630535 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/TopicConstants.java @@ -0,0 +1,25 @@ +package cn.cordys.common.constants; + +import java.util.List; + +public class TopicConstants { + /** + * 下载任务的 Redis 主题名称 + */ + public static final String DOWNLOAD_TOPIC = "download-topic"; + + /** + * sse 消息通知的Redis 主题名称 + */ + public static final String SSE_TOPIC = "sse-topic"; + + /** + * 所有 Redis 主题的集合 + * 用于统一管理订阅和发布的主题 + */ + public static final List ALL_TOPICS = List.of(DOWNLOAD_TOPIC, SSE_TOPIC); + + private TopicConstants() { + // 私有构造函数,防止实例化 + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/constants/UserSource.java b/backend/crm/src/main/java/cn/cordys/common/constants/UserSource.java new file mode 100644 index 0000000..7fa0c1b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/constants/UserSource.java @@ -0,0 +1,55 @@ +package cn.cordys.common.constants; + +/** + * 用户来源类型枚举类,用于标识用户的来源。 + *

+ * 此枚举类定义了不同的用户来源类型,包括本地、LDAP、CAS、OIDC、OAuth2 和二维码。 + *

+ */ +public enum UserSource { + + /** + * 本地用户来源,表示用户通过本地系统注册和登录。 + */ + LOCAL, + + /** + * LDAP 用户来源,表示用户通过 LDAP(轻量目录访问协议)系统认证。 + */ + LDAP, + + /** + * CAS 用户来源,表示用户通过 CAS(中央认证服务)认证。 + */ + CAS, + + /** + * OIDC 用户来源,表示用户通过 OIDC(开放ID连接)认证。 + */ + OIDC, + + /** + * OAUTH2 用户来源,表示用户通过 企业微信OAUTH2 授权框架认证。 + */ + WECOM_OAUTH2, + + /** + * OAUTH2 用户来源,表示用户通过 GitHub OAUTH2 授权框架认证。 + */ + GITHUB_OAUTH2, + + /** + * 二维码用户来源,表示用户通过扫描二维码登录。 + */ + QR_CODE, + + /** + * OAUTH2 用户来源,表示用户通过 钉钉OAUTH2 授权框架认证。 + */ + DINGTALK_OAUTH2, + + /** + * OAUTH2 用户来源,表示用户通过 飞书OAUTH2 授权框架认证。 + */ + LARK_OAUTH2 +} diff --git a/backend/crm/src/main/java/cn/cordys/common/context/CustomFunction.java b/backend/crm/src/main/java/cn/cordys/common/context/CustomFunction.java new file mode 100644 index 0000000..79f6c6a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/context/CustomFunction.java @@ -0,0 +1,6 @@ +package cn.cordys.common.context; + +@FunctionalInterface +public interface CustomFunction { + R apply(T t) throws InterruptedException; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/common/context/ExportTaskFunction.java b/backend/crm/src/main/java/cn/cordys/common/context/ExportTaskFunction.java new file mode 100644 index 0000000..4c3340c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/context/ExportTaskFunction.java @@ -0,0 +1,6 @@ +package cn.cordys.common.context; + +@FunctionalInterface +public interface ExportTaskFunction { + void apply() throws Exception; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/context/OrganizationContextWebFilter.java b/backend/crm/src/main/java/cn/cordys/common/context/OrganizationContextWebFilter.java new file mode 100644 index 0000000..e10516b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/context/OrganizationContextWebFilter.java @@ -0,0 +1,42 @@ +package cn.cordys.common.context; + +import cn.cordys.context.OrganizationContext; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * 组织信息及请求来源的 Web 过滤器 + *

+ * 根据请求头自动设置组织上下文与请求来源,并在请求结束时清理资源。 + * + * @author jianxing + */ +public class OrganizationContextWebFilter extends OncePerRequestFilter { + + public static final String ORGANIZATION_ID_HEADER = "Organization-Id"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + // 提取所有头信息 + String organizationId = request.getHeader(ORGANIZATION_ID_HEADER); + + // 设置组织 ID + if (StringUtils.isNotBlank(organizationId)) { + OrganizationContext.setOrganizationId(organizationId); + } + + try { + chain.doFilter(request, response); + } finally { + // 保证上下文清理,避免内存泄漏 + OrganizationContext.clear(); + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/common/context/SourceDetailResolveContext.java b/backend/crm/src/main/java/cn/cordys/common/context/SourceDetailResolveContext.java new file mode 100644 index 0000000..56d5a48 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/context/SourceDetailResolveContext.java @@ -0,0 +1,68 @@ +package cn.cordys.common.context; + +import java.util.HashMap; +import java.util.Map; + +/** + * 数据源详情解析上下文 + * + * @author song-cc-rock + */ +public class SourceDetailResolveContext { + + private static final ThreadLocal>> CONTEXT = + ThreadLocal.withInitial(HashMap::new); + private static final ThreadLocal DEPTH = + ThreadLocal.withInitial(() -> 0); + + public static Map> getSourceMap() { + return CONTEXT.get(); + } + + public static boolean contains(String sourceId) { + return CONTEXT.get().containsKey(sourceId); + } + + public static void putPlaceholder(String sourceId) { + CONTEXT.get().putIfAbsent(sourceId, new HashMap<>(8)); + } + + public static void put(String sourceId, Map detail) { + CONTEXT.get().put(sourceId, detail); + } + + public static void start() { + DEPTH.set(DEPTH.get() + 1); + } + + /** + * 获取当前深度 + * + * @return 当前深度 + */ + public static int getDepth() { + return DEPTH.get(); + } + + public static void end() { + int depth = DEPTH.get() - 1; + if (depth <= 0) { + clear(); + DEPTH.remove(); + } else { + DEPTH.set(depth); + } + } + + public static void clear() { + CONTEXT.remove(); + } + + public static void remove(String sourceId) { + CONTEXT.get().remove(sourceId); + } + + private SourceDetailResolveContext() { + + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/domain/BaseModel.java b/backend/crm/src/main/java/cn/cordys/common/domain/BaseModel.java new file mode 100644 index 0000000..760ee61 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/domain/BaseModel.java @@ -0,0 +1,24 @@ +package cn.cordys.common.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +@Data +public class BaseModel implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + @Schema(description = "创建人") + private String createUser; + @Schema(description = "修改人") + private String updateUser; + @Schema(description = "创建时间") + private Long createTime; + @Schema(description = "更新时间") + private Long updateTime; + +} diff --git a/backend/crm/src/main/java/cn/cordys/common/domain/BaseModuleFieldValue.java b/backend/crm/src/main/java/cn/cordys/common/domain/BaseModuleFieldValue.java new file mode 100644 index 0000000..56f887f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/domain/BaseModuleFieldValue.java @@ -0,0 +1,42 @@ +package cn.cordys.common.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * @author jianxing + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class BaseModuleFieldValue implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "自定义属性id") + private String fieldId; + + /** + * 可能是数组 + */ + @Schema(description = "自定义属性值") + private Object fieldValue; + + public boolean valid() { + return switch (fieldValue) { + case null -> false; + case String fieldValueStr when StringUtils.isBlank(fieldValueStr) -> false; + case List fieldValueList when CollectionUtils.isEmpty(fieldValueList) -> false; + default -> true; + }; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/domain/BaseResourceField.java b/backend/crm/src/main/java/cn/cordys/common/domain/BaseResourceField.java new file mode 100644 index 0000000..9fd22b9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/domain/BaseResourceField.java @@ -0,0 +1,20 @@ +package cn.cordys.common.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @author jianxing + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class BaseResourceField extends BaseModuleFieldValue { + @Schema(description = "ID") + private String id; + + @Schema(description = "资源ID") + private String resourceId; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/domain/BaseResourceSubField.java b/backend/crm/src/main/java/cn/cordys/common/domain/BaseResourceSubField.java new file mode 100644 index 0000000..08752eb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/domain/BaseResourceSubField.java @@ -0,0 +1,24 @@ +package cn.cordys.common.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @author song-cc-rock + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class BaseResourceSubField extends BaseResourceField { + + @Schema(description = "关联子表格ID") + private String refSubId; + + @Schema(description = "行ID") + private String rowId; + + @Schema(description = "行唯一标识") + private String bizId; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/BasePageRequest.java b/backend/crm/src/main/java/cn/cordys/common/dto/BasePageRequest.java new file mode 100644 index 0000000..4db8d3b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/BasePageRequest.java @@ -0,0 +1,35 @@ +package cn.cordys.common.dto; + +import cn.cordys.common.dto.condition.BaseCondition; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.Data; + +/** + *

表示分页请求的 DTO 类,继承自 {@link BaseCondition} 类,包含了分页参数和排序字段。

+ *

用于分页查询时传递当前页码、每页条数和排序信息。

+ */ +@Data +public class BasePageRequest extends BaseCondition { + + /** + * 当前页码,最小值为 1 + */ + @Min(value = 1, message = "当前页码必须大于0") + @Schema(description = "当前页码") + private int current; + + /** + * 每页显示条数,范围为 1 到 500 + */ + @Min(value = 1, message = "每页显示条数必须不小于1") + @Max(value = 500, message = "每页显示条数不能大于500") + @Schema(description = "每页显示条数") + private int pageSize; + + @Valid + @Schema(description = "排序字段") + private SortRequest sort; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/BatchUpdateDbParam.java b/backend/crm/src/main/java/cn/cordys/common/dto/BatchUpdateDbParam.java new file mode 100644 index 0000000..9e73aa8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/BatchUpdateDbParam.java @@ -0,0 +1,26 @@ +package cn.cordys.common.dto; + +import cn.cordys.common.util.JSON; +import lombok.Data; + +import java.util.List; + +/** + * @Author: jianxing + * @CreateTime: 2025-09-25 11:37 + */ +@Data +public class BatchUpdateDbParam { + private List ids; + private String fieldName; + private Object fieldValue; + private String updateUser; + private Long updateTime; + + public Object getFieldValue() { + if (fieldValue != null && fieldValue instanceof List) { + return JSON.toJSONString(fieldValue); + } + return fieldValue; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/BusinessDataPermission.java b/backend/crm/src/main/java/cn/cordys/common/dto/BusinessDataPermission.java new file mode 100644 index 0000000..edb67cb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/BusinessDataPermission.java @@ -0,0 +1,11 @@ +package cn.cordys.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class BusinessDataPermission extends DeptDataPermissionDTO { + + @Schema(description = "数据来源表") + private String sourceTable; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/ChartAnalysisDbRequest.java b/backend/crm/src/main/java/cn/cordys/common/dto/ChartAnalysisDbRequest.java new file mode 100644 index 0000000..75c42ca --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/ChartAnalysisDbRequest.java @@ -0,0 +1,33 @@ +package cn.cordys.common.dto; + +import cn.cordys.common.dto.chart.ChartCategoryAxisDbParam; +import cn.cordys.common.dto.chart.ChartValueAxisDbParam; +import cn.cordys.common.dto.condition.CombineSearch; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-10-13 14:34 + */ +@Data +public class ChartAnalysisDbRequest extends ChartAnalysisRequest { + + @Schema(description = "过滤条件") + private CombineSearch viewFilterCondition; + + /** + * x轴查询参数 + */ + private ChartCategoryAxisDbParam categoryAxisParam; + + /** + * x轴子类别查询参数 + */ + private ChartCategoryAxisDbParam subCategoryAxisParam; + + /** + * y轴查询参数 + */ + private ChartValueAxisDbParam valueAxisParam; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/ChartAnalysisRequest.java b/backend/crm/src/main/java/cn/cordys/common/dto/ChartAnalysisRequest.java new file mode 100644 index 0000000..fc39844 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/ChartAnalysisRequest.java @@ -0,0 +1,28 @@ +package cn.cordys.common.dto; + +import cn.cordys.common.dto.chart.ChartConfig; +import cn.cordys.common.dto.condition.CombineSearch; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-10-13 14:34 + */ +@Data +public class ChartAnalysisRequest { + + @Schema(description = "视图ID") + private String viewId; + + @Schema(description = "过滤条件") + @Valid + private CombineSearch filterCondition; + + @Schema(description = "搜索条件,支持组合搜索") + @NotNull + @Valid + private ChartConfig chartConfig; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/DeptDataPermissionDTO.java b/backend/crm/src/main/java/cn/cordys/common/dto/DeptDataPermissionDTO.java new file mode 100644 index 0000000..b9e3623 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/DeptDataPermissionDTO.java @@ -0,0 +1,38 @@ +package cn.cordys.common.dto; + +import cn.cordys.common.constants.InternalUserView; +import lombok.Data; + +import java.util.HashSet; +import java.util.Set; + +/** + * 部门的数据权限 + * + * @author jianxing + */ +@Data +public class DeptDataPermissionDTO { + + /** + * 搜索类型(ALL/SELF/DEPARTMENT/VISIBLE) + * {@link InternalUserView} + */ + private String viewId; + /** + * 是否可查看全部数据 + */ + private Boolean all = false; + /** + * 是否可查看自己的数据 + */ + private Boolean self = false; + /** + * 是否被设置为可见 + */ + private Boolean visible = false; + /** + * 可查看的部门Id + */ + private Set deptIds = new HashSet<>(); +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/EnableFieldValue.java b/backend/crm/src/main/java/cn/cordys/common/dto/EnableFieldValue.java new file mode 100644 index 0000000..3ae2ed5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/EnableFieldValue.java @@ -0,0 +1,25 @@ +package cn.cordys.common.dto; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.apache.commons.lang3.BooleanUtils; + +/** + * @author jianxing + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class EnableFieldValue extends BaseModuleFieldValue { + + + @Schema(description = "是否启用") + private Boolean enable; + + public boolean valid() { + return super.valid() && BooleanUtils.isTrue(enable); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/ExportDTO.java b/backend/crm/src/main/java/cn/cordys/common/dto/ExportDTO.java new file mode 100644 index 0000000..f69456d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/ExportDTO.java @@ -0,0 +1,33 @@ +package cn.cordys.common.dto; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; +import java.util.Locale; + +@Data +@Builder +public class ExportDTO { + private String userId; + private String orgId; + /** + * {@link cn.cordys.crm.system.constants.ExportConstants.ExportType} + */ + private String exportType; + private String logModule; + private Locale locale; + private String fileName; + private List headList; + private DeptDataPermissionDTO deptDataPermission; + private BasePageRequest pageRequest; + private List selectIds; + private ExportSelectRequest selectRequest; + private String formKey; + /** + * 导出字段参数 (通用参数无需设置) + */ + private ExportFieldParam exportFieldParam; + private List mergeHeads; + private List exportMetas; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/ExportFieldParam.java b/backend/crm/src/main/java/cn/cordys/common/dto/ExportFieldParam.java new file mode 100644 index 0000000..b7e5893 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/ExportFieldParam.java @@ -0,0 +1,32 @@ +package cn.cordys.common.dto; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import lombok.Builder; +import lombok.Data; + +import java.util.Map; +import java.util.Set; + +/** + * @author song-cc-rock + */ +@Data +@Builder +public class ExportFieldParam { + + /** + * 子表格ID集合 + */ + private Set subIds; + + /** + * 字段配置 + */ + private Map fieldConfigMap; + + /** + * 表单配置 + */ + private ModuleFormConfigDTO formConfig; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/ExportHeadDTO.java b/backend/crm/src/main/java/cn/cordys/common/dto/ExportHeadDTO.java new file mode 100644 index 0000000..7889fd2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/ExportHeadDTO.java @@ -0,0 +1,19 @@ +package cn.cordys.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ExportHeadDTO { + + @Schema(description = "key") + private String key; + @Schema(description = "表头名称") + private String title; + @Schema(description = "字段类型") + private String columnType; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/ExportSelectRequest.java b/backend/crm/src/main/java/cn/cordys/common/dto/ExportSelectRequest.java new file mode 100644 index 0000000..8fe7fc7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/ExportSelectRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class ExportSelectRequest { + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "表头信息") + @NotEmpty(message = "{export_head_list_is_empty}") + private List headList; + + @Schema(description = "勾选的数据id集合") + @NotEmpty(message = "{export_select_ids_is_empty}") + private List ids; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/FieldExportMeta.java b/backend/crm/src/main/java/cn/cordys/common/dto/FieldExportMeta.java new file mode 100644 index 0000000..967e847 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/FieldExportMeta.java @@ -0,0 +1,27 @@ +package cn.cordys.common.dto; + +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.crm.system.dto.field.base.BaseField; +import lombok.Data; + +/** + * 导出字段元数据 (预处理) + * @author song-cc-rock + */ +@Data +public class FieldExportMeta { + + private String head; + + private BaseField field; + + private AbstractModuleFieldResolver resolver; + + private boolean noResource; + + private String fieldId; + + private String businessKey; + + private String prefixId; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/RedisMessage.java b/backend/crm/src/main/java/cn/cordys/common/dto/RedisMessage.java new file mode 100644 index 0000000..0b90ee9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/RedisMessage.java @@ -0,0 +1,11 @@ +package cn.cordys.common.dto; + +import lombok.Data; + +@Data +public class RedisMessage { + /** + * redis 发布订阅消息主体 + */ + private String message; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/ResourceTabEnableDTO.java b/backend/crm/src/main/java/cn/cordys/common/dto/ResourceTabEnableDTO.java new file mode 100644 index 0000000..782392d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/ResourceTabEnableDTO.java @@ -0,0 +1,33 @@ +package cn.cordys.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 客户、商机、线索是否显示所有数据和部门数据 tab + * + * @Author: jianxing + * @CreateTime: 2025-05-15 14:54 + */ +@Data +public class ResourceTabEnableDTO { + @Schema(description = "是否显示所有数据tab") + private Boolean all = false; + @Schema(description = "是否显示部门数据tab") + private Boolean dept = false; + + /** + * 合并权限 + * + * @param other 其余数据权限配置 + * + * @return 合并后的数据权限配置 + */ + public ResourceTabEnableDTO or(ResourceTabEnableDTO other) { + if (other != null) { + all |= other.all; + dept |= other.dept; + } + return this; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/SortRequest.java b/backend/crm/src/main/java/cn/cordys/common/dto/SortRequest.java new file mode 100644 index 0000000..876afa0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/SortRequest.java @@ -0,0 +1,66 @@ +package cn.cordys.common.dto; + +import cn.cordys.common.utils.SqlInjectionChecker; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +/** + * @author jianxing + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SortRequest { + + @Pattern(regexp = "^[A-Za-z0-9]+$") + @Schema(description = "排序字段") + private String name; + + @Schema(description = "排序类型(asc/desc)") + private String type; + + public static String camelToUnderline(String camelCase) { + if (camelCase == null || camelCase.isEmpty()) { + return camelCase; + } + + // 使用正则表达式将驼峰转换为下划线 + String underline = camelCase.replaceAll("([A-Z])", "_$1").toLowerCase(); + + // 如果开头有下划线,去掉 + if (underline.startsWith("_")) { + underline = underline.substring(1); + } + + return underline; + } + + public String getName() { + if (SqlInjectionChecker.containsSqlInjectionRisk(name)) { + return "1"; + } + return camelToUnderline(name); + } + + public String getType() { + if (Strings.CI.equals(type, "asc")) { + return "asc"; + } else { + return "desc"; + } + } + + /** + * mapper 中调用 + * + * @return + */ + public boolean valid() { + return StringUtils.isNotBlank(name) && !SqlInjectionChecker.containsSqlInjectionRisk(name); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/UserDeptDTO.java b/backend/crm/src/main/java/cn/cordys/common/dto/UserDeptDTO.java new file mode 100644 index 0000000..88c5844 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/UserDeptDTO.java @@ -0,0 +1,24 @@ +package cn.cordys.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @author jianxing + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UserDeptDTO { + + @Schema(description = "用户ID") + private String userId; + + @Schema(description = "部门ID") + private String deptId; + + @Schema(description = "部门名称") + private String deptName; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartCategoryAxisConfig.java b/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartCategoryAxisConfig.java new file mode 100644 index 0000000..acf72bf --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartCategoryAxisConfig.java @@ -0,0 +1,16 @@ +package cn.cordys.common.dto.chart; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-10-13 14:54 + */ +@Data +public class ChartCategoryAxisConfig { + @NotBlank + @Schema(description = "字段ID") + private String fieldId; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartCategoryAxisDbParam.java b/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartCategoryAxisDbParam.java new file mode 100644 index 0000000..239b2f7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartCategoryAxisDbParam.java @@ -0,0 +1,23 @@ +package cn.cordys.common.dto.chart; + +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-10-13 14:54 + */ +@Data +public class ChartCategoryAxisDbParam extends ChartCategoryAxisConfig { + /** + * 是否要查blob表 + */ + private Boolean blob = false; + /** + * 是否是业务字段 + */ + private Boolean businessField = false; + /** + * 业务字段名称 + */ + private String businessFieldName; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartConfig.java b/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartConfig.java new file mode 100644 index 0000000..ceb1fd5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartConfig.java @@ -0,0 +1,31 @@ +package cn.cordys.common.dto.chart; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-10-13 14:34 + */ +@Data +public class ChartConfig { + + @Schema(description = "图表类型") + private String chatType; + + @Schema(description = "类别轴配置") + @NotNull + @Valid + private ChartCategoryAxisConfig categoryAxis; + + @Schema(description = "子类别轴配置") + @Valid + private ChartCategoryAxisConfig subCategoryAxis; + + @Schema(description = "值轴配置") + @NotNull + @Valid + private ChartValueAxisConfig valueAxis; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartResult.java b/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartResult.java new file mode 100644 index 0000000..0ac6651 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartResult.java @@ -0,0 +1,16 @@ +package cn.cordys.common.dto.chart; + +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-10-15 11:40 + */ +@Data +public class ChartResult { + private String categoryAxis; + private String categoryAxisName; + private String subCategoryAxis; + private String subCategoryAxisName; + private Object valueAxis; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartValueAxisConfig.java b/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartValueAxisConfig.java new file mode 100644 index 0000000..1698a64 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartValueAxisConfig.java @@ -0,0 +1,29 @@ +package cn.cordys.common.dto.chart; + +import cn.cordys.common.constants.ChartAggregateMethod; +import cn.cordys.common.constants.EnumValue; +import cn.cordys.common.uid.utils.EnumUtils; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-10-13 14:54 + */ +@Data +public class ChartValueAxisConfig { + @Schema(description = "字段ID") + private String fieldId; + + @EnumValue(enumClass = ChartAggregateMethod.class) + @Schema(description = "聚合方式") + private String aggregateMethod; + + public String getAggregateMethod() { + if (this.aggregateMethod == null) { + return ChartAggregateMethod.COUNT.name(); + } + // 避免mapper中sql注入 + return EnumUtils.valueOf(ChartAggregateMethod.class, this.aggregateMethod).name(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartValueAxisDbParam.java b/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartValueAxisDbParam.java new file mode 100644 index 0000000..ed440ab --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/chart/ChartValueAxisDbParam.java @@ -0,0 +1,24 @@ +package cn.cordys.common.dto.chart; + +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-10-13 14:54 + */ +@Data +public class ChartValueAxisDbParam extends ChartValueAxisConfig { + /** + * 是否要查blob表 + */ + private Boolean blob = false; + /** + * 是否是业务字段 + */ + private Boolean businessField = false; + + /** + * 业务字段名称 + */ + private String businessFieldName; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/condition/BaseCondition.java b/backend/crm/src/main/java/cn/cordys/common/dto/condition/BaseCondition.java new file mode 100644 index 0000000..b3871e0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/condition/BaseCondition.java @@ -0,0 +1,70 @@ +package cn.cordys.common.dto.condition; + +import cn.cordys.common.utils.ConditionFilterUtils; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import lombok.Data; +import org.apache.commons.lang3.Strings; + +import java.util.List; + +/** + * 表示 CRM 系统中的基础条件类,用于支持过滤和搜索操作。 + */ +@Data +public class BaseCondition { + + @Schema(description = "视图ID") + private String viewId; + + @Schema(description = "关键字,用于搜索匹配") + private String keyword; + + @Schema(description = "筛选条件列表,用于定义多个搜索条件") + @Valid + private List filters; + + @Schema(description = "高级搜索条件,支持组合搜索") + @Valid + private CombineSearch combineSearch; + + private CombineSearch viewCombineSearch; + + /** + * 转义关键字中的特殊字符。 + * + * @param keyword 输入的关键字 + * + * @return 转义后的关键字 + */ + public static String transferKeyword(String keyword) { + if (Strings.CS.contains(keyword, "\\") && !Strings.CS.contains(keyword, "\\\\")) { + keyword = Strings.CS.replace(keyword, "\\", "\\\\"); + } + // 判断是否已经转义过,未转义才进行转义。 + if (Strings.CS.contains(keyword, "%") && !Strings.CS.contains(keyword, "\\%")) { + keyword = Strings.CS.replace(keyword, "%", "\\%"); + } + if (Strings.CS.contains(keyword, "_") && !Strings.CS.contains(keyword, "\\_")) { + keyword = Strings.CS.replace(keyword, "_", "\\_"); + } + return keyword; + } + + public CombineSearch getCombineSearch() { + return combineSearch == null ? new CombineSearch() : combineSearch; + } + + public List getFilters() { + return ConditionFilterUtils.getValidConditions(filters); + } + + /** + * 初始化关键字,直接设置字段值。 + * + * @param keyword 初始化的关键字 + */ + public void initKeyword(String keyword) { + this.keyword = keyword; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/condition/CombineSearch.java b/backend/crm/src/main/java/cn/cordys/common/dto/condition/CombineSearch.java new file mode 100644 index 0000000..9594d90 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/condition/CombineSearch.java @@ -0,0 +1,109 @@ +package cn.cordys.common.dto.condition; + +import cn.cordys.common.constants.EnumValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import lombok.Data; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 表示组合搜索条件,用于支持复杂的搜索逻辑。 + * 包含匹配模式(所有/任一)和筛选条件列表。 + */ +@Data +public class CombineSearch { + + @Schema(description = "匹配模式,支持“所有”或“任一”", allowableValues = {"AND", "OR"}) + @EnumValue(enumClass = SearchMode.class) + private String searchMode = SearchMode.AND.name(); + + @Schema(description = "筛选条件列表,用于定义多个搜索条件") + @Valid + private List conditions; + + public List getConditions() { + if (CollectionUtils.isEmpty(conditions)) { + return new ArrayList<>(); + } + return conditions.stream() + .filter(FilterCondition::valid) + .collect(Collectors.toList()); + } + + /** + * 获取当前的匹配模式。如果未设置,则默认返回 "AND"。 + * + * @return 当前的匹配模式 + */ + public String getSearchMode() { + return StringUtils.isBlank(searchMode) ? SearchMode.AND.name() : searchMode; + } + + public CombineSearch convert() { + if (CollectionUtils.isEmpty(conditions)) { + return this; + } + + Iterator iterator = conditions.iterator(); + while (iterator.hasNext()) { + FilterCondition condition = iterator.next(); + if (!condition.valid()) { + iterator.remove(); + continue; + } + + Object value = condition.getCombineValue(); + boolean isBetween = Strings.CS.equals(condition.getCombineOperator(), FilterCondition.CombineConditionOperator.BETWEEN.name()); + + if (value instanceof List valueList) { + if (CollectionUtils.isEmpty(valueList)) { + /* + * 兜底处理, 防止前端[EMPTY, NOT_EMPTY]条件产生脏数据导致报错 + */ + iterator.remove(); + continue; + } + // 多值处理 + if (!condition.expectMulti()) { + condition.setValue(valueList.getFirst()); + } + if (isBetween) { + Object first = valueList.getFirst(); + condition.setValue(List.of(first, first)); + } + } else { + // 单值处理 + if (condition.expectMulti()) { + if (isBetween) { + condition.setValue(List.of(value, value)); + } else { + condition.setValue(List.of(value)); + } + } + } + } + return this; + } + + /** + * 枚举:搜索模式,定义了“所有”与“任一”两种匹配模式。 + */ + public enum SearchMode { + /** + * 所有条件都匹配(“与”操作) + */ + AND, + + /** + * 任一条件匹配(“或”操作) + */ + OR + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/condition/FilterCondition.java b/backend/crm/src/main/java/cn/cordys/common/dto/condition/FilterCondition.java new file mode 100644 index 0000000..2364839 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/condition/FilterCondition.java @@ -0,0 +1,472 @@ +package cn.cordys.common.dto.condition; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.utils.SqlInjectionChecker; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.time.*; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.List; + +/** + * 表示组合条件,用于支持复杂的过滤和查询逻辑。 + * 包含字段名、操作符和期望值等信息。 + */ +@Data +public class FilterCondition { + + /** + * 系统字段为字段名 + * 模块字段为字段ID + */ + @Schema(description = "条件的参数名称") + @NotNull + private String name; + + @Schema(description = "期望值,若操作符为 BETWEEN, IN, NOT_IN 时为数组,其他操作符为单个值") + private Object value; + + @Schema(description = "是否是多选值") + @NotNull + private Boolean multipleValue = false; + + @Schema(description = "操作符", + allowableValues = {"IN", "NOT_IN", "BETWEEN", "GT", "LT", "GE", "LE", "COUNT_GT", "COUNT_LT", "EQUALS", "NOT_EQUALS", "CONTAINS", "NOT_CONTAINS", "EMPTY", "NOT_EMPTY"}) + @EnumValue(enumClass = CombineConditionOperator.class) + private String operator; + + @Schema(description = "类型") + private String type; + + @Schema(description = "包含新增子部门集合") + private List containChildIds; + + public String getName() { + if (SqlInjectionChecker.containsSqlInjectionRisk(name)) { + throw new GenericException("condition name illegal"); + } + return name; + } + + /** + * 校验条件是否合法,检查字段名称、操作符和值的有效性。 + * + * @return 如果条件合法则返回 true,否则返回 false + */ + public boolean valid() { + if (StringUtils.isBlank(name) || StringUtils.isBlank(operator) || SqlInjectionChecker.containsSqlInjectionRisk(name)) { + return false; + } + + // 针对空值判断操作符 + if (Strings.CS.equalsAny(operator, CombineConditionOperator.EMPTY.name(), CombineConditionOperator.NOT_EMPTY.name(), CombineConditionOperator.NOT_EQUAL_ORIGINAL.name())) { + return true; + } + + if (value == null) { + return false; + } + + // 针对值为集合类型的校验 + if (value instanceof List valueList && CollectionUtils.isEmpty(valueList)) { + return false; + } + + // 针对值为字符串的校验 + return !(value instanceof String valueStr) || !StringUtils.isBlank(valueStr); + } + + public boolean expectMulti() { + return Strings.CS.equalsAny(operator, CombineConditionOperator.IN.name(), CombineConditionOperator.NOT_IN.name(), CombineConditionOperator.BETWEEN.name(), CombineConditionOperator.DYNAMICS.name()); + } + + + public Object getCombineValue() { + if (Strings.CI.equals(operator, CombineConditionOperator.DYNAMICS.name())) { + // value 转为string 类型 + String strValue = (String) value; + String[] split = strValue.split(","); + if (split.length == 1) { + String dateValue = split[0]; + switch (dateValue) { + case "TODAY" -> { + List todayList = new ArrayList<>(); + // 获取今天的日期 + LocalDate today = LocalDate.now(); + long timestamp = getTimestamp(today.atStartOfDay()); + todayList.add(timestamp); + long timestampEnd = getTimestamp(today.atTime(23, 59, 59, 999_000_000)); + todayList.add(timestampEnd); + return todayList; + } + case "YESTERDAY" -> { + List yesterdayList = new ArrayList<>(); + LocalDate yesterday = LocalDate.now().minusDays(1); + long timestamp = getTimestamp(yesterday.atStartOfDay()); + yesterdayList.add(timestamp); + long timestampEnd = getTimestamp(yesterday.atTime(23, 59, 59, 999_000_000)); + yesterdayList.add(timestampEnd); + return yesterdayList; + } + case "TOMORROW" -> { + List tomorrowList = new ArrayList<>(); + LocalDate tomorrow = LocalDate.now().plusDays(1); + long timestamp = getTimestamp(tomorrow.atStartOfDay()); + tomorrowList.add(timestamp); + long timestampEnd = getTimestamp(tomorrow.atTime(23, 59, 59, 999_000_000)); + tomorrowList.add(timestampEnd); + return tomorrowList; + } + case "WEEK" -> { + List weeks = new ArrayList<>(); + LocalDate startOfWeek = LocalDate.now().with(java.time.DayOfWeek.MONDAY); + long timestamp = getTimestamp(startOfWeek.atStartOfDay()); + weeks.add(timestamp); + LocalDate now = LocalDate.now().with(DayOfWeek.SUNDAY); + long timestampEnd = getTimestamp(now.atTime(23, 59, 59, 999_000_000)); + weeks.add(timestampEnd); + return weeks; + } + case "LAST_WEEK" -> { + List lastWeeks = new ArrayList<>(); + LocalDate startOfLastWeek = LocalDate.now().minusWeeks(1).with(java.time.DayOfWeek.MONDAY); + long timestamp = getTimestamp(startOfLastWeek.atStartOfDay()); + lastWeeks.add(timestamp); + LocalDate startOfLastWeekEnd = LocalDate.now().minusWeeks(1).with(DayOfWeek.SUNDAY); + long timestampEnd = getTimestamp(startOfLastWeekEnd.atTime(23, 59, 59, 999_000_000)); + lastWeeks.add(timestampEnd); + return lastWeeks; + } + case "NEXT_WEEK" -> { + List nextWeeks = new ArrayList<>(); + LocalDate startOfNextWeek = LocalDate.now().plusWeeks(1).with(java.time.DayOfWeek.MONDAY); + long timestamp = getTimestamp(startOfNextWeek.atStartOfDay()); + nextWeeks.add(timestamp); + LocalDate startOfNextWeekEnd = LocalDate.now().plusWeeks(1).with(DayOfWeek.SUNDAY); + long timestampEnd = getTimestamp(startOfNextWeekEnd.atTime(23, 59, 59, 999_000_000)); + nextWeeks.add(timestampEnd); + return nextWeeks; + } + case "MONTH" -> { + List months = new ArrayList<>(); + LocalDate startOfMonth = LocalDate.now().withDayOfMonth(1); + long timestamp = getTimestamp(startOfMonth.atStartOfDay()); + months.add(timestamp); + LocalDate now = LocalDate.now().with(TemporalAdjusters.lastDayOfMonth()); + long timestampEnd = getTimestamp(now.atTime(23, 59, 59, 999_000_000)); + months.add(timestampEnd); + return months; + } + case "LAST_MONTH" -> { + List lastMonths = new ArrayList<>(); + LocalDate startOfLastMonth = LocalDate.now().minusMonths(1).withDayOfMonth(1); + long timestamp = getTimestamp(startOfLastMonth.atStartOfDay()); + lastMonths.add(timestamp); + LocalDate startOfLastMonthEnd = LocalDate.now().minusMonths(1).with(TemporalAdjusters.lastDayOfMonth()); + long timestampEnd = getTimestamp(startOfLastMonthEnd.atTime(23, 59, 59, 999_000_000)); + lastMonths.add(timestampEnd); + return lastMonths; + } + case "NEXT_MONTH" -> { + List nextMonths = new ArrayList<>(); + LocalDate startOfNextMonth = LocalDate.now().plusMonths(1).withDayOfMonth(1); + long timestamp = getTimestamp(startOfNextMonth.atStartOfDay()); + nextMonths.add(timestamp); + LocalDate startOfNextMonthEnd = LocalDate.now().plusMonths(1).with(TemporalAdjusters.lastDayOfMonth()); + long timestampEnd = getTimestamp(startOfNextMonthEnd.atTime(23, 59, 59, 999_000_000)); + nextMonths.add(timestampEnd); + return nextMonths; + } + case "LAST_SEVEN" -> { + List lastSevens = new ArrayList<>(); + LocalDate startOfLastSevenDays = LocalDate.now().minusDays(7); + long timestamp = getTimestamp(startOfLastSevenDays.atStartOfDay()); + lastSevens.add(timestamp); + long timestampEnd = getTimestamp(LocalDate.now().atStartOfDay()); + lastSevens.add(timestampEnd); + return lastSevens; + } + case "SEVEN" -> { + List sevens = new ArrayList<>(); + LocalDate startOfNextSevenDays = LocalDate.now().plusDays(6); + long timestamp = getTimestamp(LocalDate.now().atStartOfDay()); + sevens.add(timestamp); + long timestampEnd = getTimestamp(startOfNextSevenDays.atTime(23, 59, 59, 999_000_000)); + sevens.add(timestampEnd); + return sevens; + } + case "THIRTY" -> { + List thirty = new ArrayList<>(); + LocalDate startOfNextThirtyDays = LocalDate.now().plusDays(29); + long timestamp = getTimestamp(LocalDate.now().atStartOfDay()); + thirty.add(timestamp); + long timestampEnd = getTimestamp(startOfNextThirtyDays.atTime(23, 59, 59, 999_000_000)); + thirty.add(timestampEnd); + return thirty; + } + case "LAST_THIRTY" -> { + List lastThirty = new ArrayList<>(); + LocalDate startOfLastThirtyDays = LocalDate.now().minusDays(30); + long timestamp = getTimestamp(startOfLastThirtyDays.atStartOfDay()); + lastThirty.add(timestamp); + long timestampEnd = getTimestamp(LocalDate.now().atStartOfDay()); + lastThirty.add(timestampEnd); + return lastThirty; + } + case "SIXTY" -> { + List sixty = new ArrayList<>(); + LocalDate startOfNextSixtyDays = LocalDate.now().plusDays(59); + long timestamp = getTimestamp(LocalDate.now().atStartOfDay()); + sixty.add(timestamp); + long timestampEnd = getTimestamp(startOfNextSixtyDays.atTime(23, 59, 59, 999_000_000)); + sixty.add(timestampEnd); + return sixty; + } + case "LAST_SIXTY" -> { + List lastSixty = new ArrayList<>(); + LocalDate startOfLastSixtyDays = LocalDate.now().minusDays(60); + long timestamp = getTimestamp(startOfLastSixtyDays.atStartOfDay()); + lastSixty.add(timestamp); + long timestampEnd = getTimestamp(LocalDate.now().atStartOfDay()); + lastSixty.add(timestampEnd); + return lastSixty; + } + //本季度 + case "QUARTER" -> { + List quarters = new ArrayList<>(); + LocalDate now = LocalDate.now(); + int currentMonth = now.getMonthValue(); + int startMonth = (currentMonth - 1) / 3 * 3 + 1; + LocalDate startOfQuarter = LocalDate.of(now.getYear(), startMonth, 1); + long timestamp = getTimestamp(startOfQuarter.atStartOfDay()); + quarters.add(timestamp); + LocalDate endOfQuarter = startOfQuarter.plusMonths(2).with(TemporalAdjusters.lastDayOfMonth()); + long timestampEnd = getTimestamp(endOfQuarter.atTime(23, 59, 59, 999_000_000)); + quarters.add(timestampEnd); + return quarters; + } + //上季度 + case "LAST_QUARTER" -> { + List lastQuarters = new ArrayList<>(); + LocalDate now = LocalDate.now(); + int currentMonth = now.getMonthValue(); + int startMonth = (currentMonth - 1) / 3 * 3 + 1; + LocalDate startOfLastQuarter = LocalDate.of(now.getYear(), startMonth, 1).minusMonths(3); + long timestamp = getTimestamp(startOfLastQuarter.atStartOfDay()); + lastQuarters.add(timestamp); + LocalDate endOfLastQuarter = startOfLastQuarter.plusMonths(2).with(TemporalAdjusters.lastDayOfMonth()); + long timestampEnd = getTimestamp(endOfLastQuarter.atTime(23, 59, 59, 999_000_000)); + lastQuarters.add(timestampEnd); + return lastQuarters; + } + //下季度 + case "NEXT_QUARTER" -> { + List nextQuarters = new ArrayList<>(); + LocalDate now = LocalDate.now(); + int currentMonth = now.getMonthValue(); + int startMonth = (currentMonth - 1) / 3 * 3 + 1; + LocalDate startOfNextQuarter = LocalDate.of(now.getYear(), startMonth, 1).plusMonths(3); + long timestamp = getTimestamp(startOfNextQuarter.atStartOfDay()); + nextQuarters.add(timestamp); + LocalDate endOfNextQuarter = startOfNextQuarter.plusMonths(2).with(TemporalAdjusters.lastDayOfMonth()); + long timestampEnd = getTimestamp(endOfNextQuarter.atTime(23, 59, 59, 999_000_000)); + nextQuarters.add(timestampEnd); + return nextQuarters; + } + //本年度 + case "YEAR" -> { + List years = new ArrayList<>(); + LocalDate startOfYear = LocalDate.now().withDayOfYear(1); + long timestamp = getTimestamp(startOfYear.atStartOfDay()); + years.add(timestamp); + LocalDate now = LocalDate.now().with(TemporalAdjusters.lastDayOfYear()); + long timestampEnd = getTimestamp(now.atTime(23, 59, 59, 999_000_000)); + years.add(timestampEnd); + return years; + } + //上年度 + case "LAST_YEAR" -> { + List lastYears = new ArrayList<>(); + LocalDate startOfLastYear = LocalDate.now().minusYears(1).withDayOfYear(1); + long timestamp = getTimestamp(startOfLastYear.atStartOfDay()); + lastYears.add(timestamp); + LocalDate startOfLastYearEnd = LocalDate.now().minusYears(1).with(TemporalAdjusters.lastDayOfYear()); + long timestampEnd = getTimestamp(startOfLastYearEnd.atTime(23, 59, 59, 999_000_000)); + lastYears.add(timestampEnd); + return lastYears; + } + //下年度 + case "NEXT_YEAR" -> { + List nextYears = new ArrayList<>(); + LocalDate startOfNextYear = LocalDate.now().plusYears(1).withDayOfYear(1); + long timestamp = getTimestamp(startOfNextYear.atStartOfDay()); + nextYears.add(timestamp); + LocalDate startOfNextYearEnd = LocalDate.now().plusYears(1).with(TemporalAdjusters.lastDayOfYear()); + long timestampEnd = getTimestamp(startOfNextYearEnd.atTime(23, 59, 59, 999_000_000)); + nextYears.add(timestampEnd); + return nextYears; + } + + } + + } else { + String dateValue = split[1]; + String dateUnit = split[2]; + int dateNumber = Integer.parseInt(dateValue); + switch (dateUnit) { + case "BEFORE_DAY" -> { + LocalDateTime startOfLastDays = LocalDateTime.now().minusDays(dateNumber); + return getTimestamp(startOfLastDays); + } + case "AFTER_DAY" -> { + LocalDateTime startOfNextDays = LocalDateTime.now().plusDays(dateNumber); + return getTimestamp(startOfNextDays); + } + case "BEFORE_WEEK" -> { + LocalDateTime startOfLastWeeks = LocalDateTime.now().minusDays(dateNumber * 7L); + return getTimestamp(startOfLastWeeks); + } + case "AFTER_WEEK" -> { + LocalDateTime startOfNextWeeks = LocalDateTime.now().plusDays(dateNumber * 7L); + return getTimestamp(startOfNextWeeks); + } + case "BEFORE_MONTH" -> { + LocalDateTime startOfLastMonths = LocalDateTime.now().minusMonths(dateNumber); + return getTimestamp(startOfLastMonths); + } + case "AFTER_MONTH" -> { + LocalDateTime startOfNextMonths = LocalDateTime.now().plusMonths(dateNumber); + return getTimestamp(startOfNextMonths); + } + } + } + } + return value; + } + + public String getCombineOperator() { + if (Strings.CI.equals(operator, CombineConditionOperator.DYNAMICS.name())) { + String strValue = (String) value; + String[] split = strValue.split(","); + if (split.length == 1) { + return CombineConditionOperator.BETWEEN.name(); + } else { + String dateUnit = split[2]; + switch (dateUnit) { + case "BEFORE_DAY", "BEFORE_WEEK", "BEFORE_MONTH" -> { + return CombineConditionOperator.LT.name(); + } + case "AFTER_DAY", "AFTER_WEEK", "AFTER_MONTH" -> { + return CombineConditionOperator.GT.name(); + } + } + } + + } + return operator; + } + + + private long getTimestamp(LocalDateTime today) { + // 使用系统默认时区 + ZonedDateTime zonedEndOfDay = today.atZone(ZoneId.systemDefault()); + // 转为时间戳(毫秒) + return zonedEndOfDay.toInstant().toEpochMilli(); + } + + /** + * 枚举:组合条件操作符,定义了各种可能的查询操作符。 + */ + public enum CombineConditionOperator { + /** + * 动态 + */ + DYNAMICS, + /** + * 属于某个集合 + */ + IN, + + /** + * 不属于某个集合 + */ + NOT_IN, + + /** + * 区间操作 + */ + BETWEEN, + + /** + * 大于 + */ + GT, + + /** + * 小于 + */ + LT, + + /** + * 大于等于 + */ + GE, + + /** + * 小于等于 + */ + LE, + + /** + * 数量大于 + */ + COUNT_GT, + + /** + * 数量小于 + */ + COUNT_LT, + + /** + * 等于 + */ + EQUALS, + + /** + * 不等于 + */ + NOT_EQUALS, + + /** + * 包含 + */ + CONTAINS, + + /** + * 不包含 + */ + NOT_CONTAINS, + + /** + * 为空 + */ + EMPTY, + + /** + * 不为空 + */ + NOT_EMPTY, + /** + * 不等于原值(用户审批时的条件判断) + */ + NOT_EQUAL_ORIGINAL + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/condition/FilterDBCondition.java b/backend/crm/src/main/java/cn/cordys/common/dto/condition/FilterDBCondition.java new file mode 100644 index 0000000..8442eb0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/condition/FilterDBCondition.java @@ -0,0 +1,30 @@ +package cn.cordys.common.dto.condition; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 表示组合条件,用于支持复杂的过滤和查询逻辑。 + * 包含字段名、操作符和期望值等信息。 + */ +@Data +public class FilterDBCondition extends FilterCondition { + + @Schema(description = "是否是自定义字段") + private Boolean customField = false; + + @Schema(description = "是否是大字段") + private Boolean blob = false; + + @Schema(description = "是否是显示字段") + private Boolean refFiled = false; + + @Schema(description = "显示字段的主字段是否是自定义字段") + private Boolean refMainCustomField = false; + + @Schema(description = "显示字段的主字段名称或ID") + private String refMainFieldName; + + @Schema(description = "显示字段的主表名") + private String refMainTableName; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/stage/CirculationFieldValue.java b/backend/crm/src/main/java/cn/cordys/common/dto/stage/CirculationFieldValue.java new file mode 100644 index 0000000..3f0ac21 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/stage/CirculationFieldValue.java @@ -0,0 +1,15 @@ +package cn.cordys.common.dto.stage; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class CirculationFieldValue extends BaseModuleFieldValue { + + @Schema(description = "是否必填") + private Boolean required; + + @Schema(description = "默认值类型") + private String valueType; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/stage/CirculationSetting.java b/backend/crm/src/main/java/cn/cordys/common/dto/stage/CirculationSetting.java new file mode 100644 index 0000000..67b5eaa --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/stage/CirculationSetting.java @@ -0,0 +1,19 @@ +package cn.cordys.common.dto.stage; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class CirculationSetting { + + @Schema(description = "源id") + private String originId; + + @Schema(description = "源id对应的行目标ids") + private List targets; + + @Schema(description = "模块类型(order-订单/contract-合同)") + private String moduleType; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageAddRequest.java b/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageAddRequest.java new file mode 100644 index 0000000..a310df1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageAddRequest.java @@ -0,0 +1,20 @@ +package cn.cordys.common.dto.stage; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class StageAddRequest { + + @Schema(description = "值") + private String name; + + @Schema(description = "类型") + private String type; + + @Schema(description = "添加的位置(取值:-1,,1。 -1:源节点之前,1:源节点之后)", requiredMode = Schema.RequiredMode.REQUIRED) + private int dropPosition; + + @Schema(description = "源节点") + private String targetId; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageAdvancedConfigRequest.java b/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageAdvancedConfigRequest.java new file mode 100644 index 0000000..b291a61 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageAdvancedConfigRequest.java @@ -0,0 +1,16 @@ +package cn.cordys.common.dto.stage; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class StageAdvancedConfigRequest { + + @Schema(description = "流转配置类型") + private String circulationType; + + @Schema(description = "高级流转设置") + private List circulationSettings; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageConfigResponse.java b/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageConfigResponse.java new file mode 100644 index 0000000..f6f9392 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageConfigResponse.java @@ -0,0 +1,32 @@ +package cn.cordys.common.dto.stage; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class StageConfigResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "状态") + private String name; + + @Schema(description = "类型") + private String type; + + @Schema(description = "进行中回退设置") + private Boolean afootRollBack; + + @Schema(description = "完结回退设置") + private Boolean endRollBack; + + @Schema(description = "顺序") + private Long pos; + + @Schema(description = "当前阶段是否存在数据") + private Boolean stageHasData = false; + + @Schema(description = "流转类型") + private String circulationType; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageConfigsResponse.java b/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageConfigsResponse.java new file mode 100644 index 0000000..e3dc9c4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageConfigsResponse.java @@ -0,0 +1,25 @@ +package cn.cordys.common.dto.stage; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class StageConfigsResponse { + + @Schema(description = "订单状态流配置列表") + List stageConfigList; + + @Schema(description = "进行中回退设置") + private Boolean afootRollBack = true; + + @Schema(description = "完结回退设置") + private Boolean endRollBack = false; + + @Schema(description = "流转配置类型") + private String circulationType; + + @Schema(description = "高级流转设置") + private List advancedConfigs; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageRollBackRequest.java b/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageRollBackRequest.java new file mode 100644 index 0000000..cdc1572 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageRollBackRequest.java @@ -0,0 +1,14 @@ +package cn.cordys.common.dto.stage; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class StageRollBackRequest { + + @Schema(description = "进行中回退设置") + private Boolean afootRollBack; + + @Schema(description = "完结回退设置") + private Boolean endRollBack; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageSortRequest.java b/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageSortRequest.java new file mode 100644 index 0000000..d1f737d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageSortRequest.java @@ -0,0 +1,21 @@ +package cn.cordys.common.dto.stage; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.system.dto.request.NodeMoveRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.List; + +@Data +public class StageSortRequest extends NodeMoveRequest { + + @NotBlank + @Schema(description = "阶段", requiredMode = Schema.RequiredMode.REQUIRED) + private String stage; + + @Schema(description = "更新字段") + private List fields; + +} diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageUpdateRequest.java new file mode 100644 index 0000000..972f998 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/stage/StageUpdateRequest.java @@ -0,0 +1,16 @@ +package cn.cordys.common.dto.stage; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class StageUpdateRequest { + + @Schema(description = "id") + private String id; + + @Schema(description = "状态名称") + private String name; + +} + diff --git a/backend/crm/src/main/java/cn/cordys/common/dto/stage/Target.java b/backend/crm/src/main/java/cn/cordys/common/dto/stage/Target.java new file mode 100644 index 0000000..c58e575 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/dto/stage/Target.java @@ -0,0 +1,19 @@ +package cn.cordys.common.dto.stage; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class Target { + + @Schema(description = "目标id") + private String targetId; + + @Schema(description = "是否允许流转") + private Boolean enable; + + @Schema(description = "字段配置") + private List circulationFieldValues; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/handler/BaseTypeHandler.java b/backend/crm/src/main/java/cn/cordys/common/handler/BaseTypeHandler.java new file mode 100644 index 0000000..9634503 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/handler/BaseTypeHandler.java @@ -0,0 +1,45 @@ +package cn.cordys.common.handler; + +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.TypeHandler; +import org.apache.ibatis.type.TypeReference; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +public abstract class BaseTypeHandler extends TypeReference implements TypeHandler { + + @Override + public void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException { + if (parameter == null) { + ps.setNull(i, jdbcType.TYPE_CODE); + } else { + setNonNullParameter(ps, i, parameter, jdbcType); + } + } + + @Override + public T getResult(ResultSet rs, String columnName) throws SQLException { + return getNullableResult(rs, columnName); + } + + @Override + public T getResult(ResultSet rs, int columnIndex) throws SQLException { + return getNullableResult(rs, columnIndex); + } + + @Override + public T getResult(CallableStatement cs, int columnIndex) throws SQLException { + return getNullableResult(cs, columnIndex); + } + + public abstract void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException; + + public abstract T getNullableResult(ResultSet rs, String columnName) throws SQLException; + + public abstract T getNullableResult(ResultSet rs, int columnIndex) throws SQLException; + + public abstract T getNullableResult(CallableStatement cs, int columnIndex) throws SQLException; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/handler/ListTypeHandler.java b/backend/crm/src/main/java/cn/cordys/common/handler/ListTypeHandler.java new file mode 100644 index 0000000..c147a8c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/handler/ListTypeHandler.java @@ -0,0 +1,98 @@ +package cn.cordys.common.handler; + + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.core.json.JsonReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.type.CollectionType; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.micrometer.common.util.StringUtils; +import org.apache.ibatis.type.JdbcType; + +import java.io.IOException; +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +public class ListTypeHandler extends BaseTypeHandler> { + public static final int DEFAULT_MAX_STRING_LEN = Integer.MAX_VALUE; + private static final ObjectMapper objectMapper = JsonMapper.builder() + .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS) + .build(); + private static final TypeFactory typeFactory = objectMapper.getTypeFactory(); + + static { + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + // 支持json字符中带注释符 + objectMapper.configure(JsonParser.Feature.ALLOW_COMMENTS, true); + // 自动检测所有类的全部属性 + objectMapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); + // 如果一个对象中没有任何的属性,那么在序列化的时候就会报错 + objectMapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); + objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); + // 设置JSON处理字符长度限制 + objectMapper.getFactory() + .setStreamReadConstraints(StreamReadConstraints.builder().maxStringLength(DEFAULT_MAX_STRING_LEN).build()); + // 处理时间格式 + objectMapper.registerModule(new JavaTimeModule()); + + } + + public static String toJSONString(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static List parseArray(String content, Class valueType) { + CollectionType javaType = typeFactory.constructCollectionType(List.class, valueType); + try { + return objectMapper.readValue(content, javaType); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void setNonNullParameter(PreparedStatement ps, int i, List parameter, JdbcType jdbcType) throws SQLException { + String d = toJSONString(parameter); + ps.setString(i, d); + } + + @Override + public List getNullableResult(ResultSet rs, String columnName) throws SQLException { + String values = rs.getString(columnName); + return getResults(values); + } + + @Override + public List getNullableResult(ResultSet rs, int columnIndex) throws SQLException { + String values = rs.getString(columnIndex); + return getResults(values); + } + + @Override + public List getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { + String values = cs.getString(columnIndex); + return getResults(values); + } + + private List getResults(String values) { + if (StringUtils.isNotBlank(values)) { + return parseArray(values, String.class); + } + return new ArrayList<>(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/interceptor/SystemInterceptor.java b/backend/crm/src/main/java/cn/cordys/common/interceptor/SystemInterceptor.java new file mode 100644 index 0000000..702695b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/interceptor/SystemInterceptor.java @@ -0,0 +1,51 @@ +package cn.cordys.common.interceptor; + +import cn.cordys.common.util.CompressUtils; +import cn.cordys.config.MybatisInterceptorConfig; +import cn.cordys.crm.contract.domain.ContractInvoiceSnapshot; +import cn.cordys.crm.contract.domain.ContractSnapshot; +import cn.cordys.crm.opportunity.domain.OpportunityQuotationSnapshot; +import cn.cordys.crm.system.domain.*; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.ArrayList; +import java.util.List; + +/** + * 系统拦截器配置类 + *

+ * 该类用于配置 MyBatis 的拦截器,特别是字段压缩等功能的配置。 + *

+ */ +@Configuration +public class SystemInterceptor { + + /** + * 配置系统拦截器列表 + * + * @return 返回 MyBatis 拦截器配置列表,目前支持字段压缩等功能。 + */ + @Bean + public List systemCompressConfigs() { + List configList = new ArrayList<>(); + + configList.add(new MybatisInterceptorConfig(MessageTask.class, "template", CompressUtils.class, "zip", "unzip")); + configList.add(new MybatisInterceptorConfig(Announcement.class, "content", CompressUtils.class, "zip", "unzip")); + configList.add(new MybatisInterceptorConfig(Announcement.class, "receiver", CompressUtils.class, "zip", "unzip")); + configList.add(new MybatisInterceptorConfig(Announcement.class, "receiveType", CompressUtils.class, "zip", "unzip")); + configList.add(new MybatisInterceptorConfig(OrganizationConfigDetail.class, "content", CompressUtils.class, "zip", "unzip")); + configList.add(new MybatisInterceptorConfig(Notification.class, "content", CompressUtils.class, "zip", "unzip")); + + configList.add(new MybatisInterceptorConfig(OperationLogBlob.class, "originalValue", CompressUtils.class, "zip", "unzip")); + + configList.add(new MybatisInterceptorConfig(OpportunityQuotationSnapshot.class, "quotationProp", CompressUtils.class, "zipString", "unzipString")); + configList.add(new MybatisInterceptorConfig(ContractSnapshot.class, "contractProp", CompressUtils.class, "zipString", "unzipString")); + configList.add(new MybatisInterceptorConfig(ContractInvoiceSnapshot.class, "invoiceProp", CompressUtils.class, "zipString", "unzipString")); + + + // 添加自定义拦截器配置,例如压缩和解压缩功能 + // configList.add(new MybatisInterceptorConfig(TestResourcePoolBlob.class, "configuration", CompressUtils.class, "zip", "unzip")); + return configList; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/interceptor/UserDesensitizationInterceptor.java b/backend/crm/src/main/java/cn/cordys/common/interceptor/UserDesensitizationInterceptor.java new file mode 100644 index 0000000..cca4f7b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/interceptor/UserDesensitizationInterceptor.java @@ -0,0 +1,87 @@ +package cn.cordys.common.interceptor; + +import cn.cordys.crm.system.domain.User; +import org.apache.ibatis.cache.CacheKey; +import org.apache.ibatis.executor.Executor; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.plugin.*; +import org.apache.ibatis.session.ResultHandler; +import org.apache.ibatis.session.RowBounds; + +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +/** + * 用户密码字段脱敏的拦截器。 + * 该拦截器会在查询结果中去除 User 对象的密码字段,以避免敏感信息泄露。 + */ +@Intercepts({ + @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), + @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}), +}) +public class UserDesensitizationInterceptor implements Interceptor { + + /** + * 拦截 query 方法,脱敏 User 对象中的密码字段。 + * + * @param invocation 方法调用对象 + * + * @return 脱敏后的结果 + * + * @throws Throwable 如果执行方法时发生错误 + */ + @Override + public Object intercept(Invocation invocation) throws Throwable { + // 执行原始方法 + Object returnValue = invocation.proceed(); + + // 如果返回值是 List 类型,处理其中的每个元素 + if (returnValue instanceof List) { + List list = new ArrayList<>(); + boolean isDecrypted = false; + + for (Object val : (List) returnValue) { + if (val instanceof User) { + isDecrypted = true; + // 将密码字段置为 null,进行脱敏处理 + ((User) val).setPassword(null); + } + list.add(val); + } + + // 如果有任何脱敏操作,则返回修改后的列表,否则返回原始结果 + return isDecrypted ? list : returnValue; + } + + // 如果返回值是单个 User 对象,脱敏其密码字段 + if (returnValue instanceof User) { + ((User) returnValue).setPassword(null); + } + + return returnValue; + } + + /** + * 将目标对象包装成拦截器对象。 + * + * @param target 需要包装的目标对象 + * + * @return 包装后的目标对象 + */ + @Override + public Object plugin(Object target) { + return Plugin.wrap(target, this); + } + + /** + * 设置拦截器的属性。 + * + * @param properties 拦截器的属性 + */ + @Override + public void setProperties(Properties properties) { + // 本拦截器不需要设置属性 + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/mapper/CommonMapper.java b/backend/crm/src/main/java/cn/cordys/common/mapper/CommonMapper.java new file mode 100644 index 0000000..b21c0d7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/mapper/CommonMapper.java @@ -0,0 +1,61 @@ +package cn.cordys.common.mapper; + +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface CommonMapper { + boolean checkAddExist(@Param("tableName") String tableName, + @Param("fieldName") String fieldName, + @Param("fieldValue") String fieldValue, + @Param("orgId") String orgId); + + boolean checkUpdateExist(@Param("tableName") String tableName, + @Param("fieldName") String fieldName, + @Param("fieldValue") String fieldValue, + @Param("orgId") String orgId, + @Param("excludeIds") List excludeIds); + + /** + * 获取表属性值集合 + * + * @param tableName 表 + * @param fieldName 值 + * @param orgId 组织ID + * + * @return 值集合 + */ + List getCheckValList(@Param("tableName") String tableName, + @Param("fieldName") String fieldName, @Param("orgId") String orgId); + + /** + * 校验字段值是否重复 + * + * @param dataTable 数据表 + * @param fieldTable 字段表 + * @param fieldId 字段ID + * @param fieldValue 字段值 + * @param orgId 组织ID + * + * @return 是否重复 + */ + String checkFieldRepeatName(@Param("dataTable") String dataTable, @Param("fieldTable") String fieldTable, + @Param("fieldId") String fieldId, @Param("fieldValue") String fieldValue, @Param("orgId") String orgId); + + List getCheckFieldValList(@Param("dataTable") String dataTable, @Param("fieldTable") String fieldTable, + @Param("fieldId") String fieldId, @Param("orgId") String orgId); + + /** + * 校验业务字段是否重复 + * + * @param dataTable 数据表 + * @param businessName 业务字段名 + * @param value 值 + * @param orgId 组织ID + * + * @return 是否重复 + */ + String checkInternalRepeatName(@Param("dataTable") String dataTable, @Param("businessName") String businessName, + @Param("value") String value, @Param("orgId") String orgId); +} + diff --git a/backend/crm/src/main/java/cn/cordys/common/mapper/CommonMapper.xml b/backend/crm/src/main/java/cn/cordys/common/mapper/CommonMapper.xml new file mode 100644 index 0000000..d8407a0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/mapper/CommonMapper.xml @@ -0,0 +1,598 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${column} like CONCAT('%', #{condition.value},'%') + + + JSON_OVERLAPS(${column}, JSON_ARRAY( + + #{valueItem} + )) + + + + + + ${column} not like CONCAT('%', #{condition.value},'%') + or ${column} is null + + + ${column} is null or ${column} = '[]' or + NOT JSON_OVERLAPS(${column}, JSON_ARRAY( + + #{valueItem} + )) + + + + + JSON_OVERLAPS(${column}, JSON_ARRAY( + + #{tag} + )) + + + + ${column} is null or ${column} = '[]' or + NOT JSON_OVERLAPS(${column}, JSON_ARRAY( + + #{tag} + )) + + + + + JSON_LENGTH(${column}) > #{condition.value} + + + + + + false + + + (JSON_LENGTH(${column}) < #{condition.value} OR ${column} is null OR ${column} = '[]') + + + + + + + (${column} IS NULL OR ${column} = '[]') + + + + (${column} IS NOT NULL AND ${column} != '[]') + + + + ${column} = #{condition.value} + + + ${column} != #{condition.value} + or ${column} is null + + + + + + + + + + + + CONCAT('CO.NO.', LPAD(${column}, 8, '0')) + LIKE CONCAT('%', #{condition.value}, '%') + + + CONCAT('CO.NO.', LPAD(${column}, 8, '0')) + NOT LIKE CONCAT('%', #{condition.value}, '%') + or combine_0.company_number is null + + + ${column} IS NULL OR ${column} = '' + + + ${column} IS NOT NULL AND ${column} != '' + + + CONCAT('CO.NO.', LPAD(${column}, 8, '0')) = #{condition.value} + + + CONCAT('CO.NO.', LPAD(${column}, 8, '0')) != #{condition.value} + or ${column} is null + + + + + + + ${column} like CONCAT('%', #{item},'%') + + + + + ${column} not like CONCAT('%', #{item},'%') + or ${column} is null + + + + + + + + ${column} REGEXP '^[0-9]{6}(-.*)?$' or ${column} = 'CHN-' + + + + `value` + + + ${column} + + LIKE CONCAT(#{v}, '%') + + + + + + `value` + + + ${column} + + in + + #{v} + + + + + + + + (!( + + + ${column} REGEXP '^[0-9]{6}(-.*)?$' + + + + `value` + + + ${column} + + LIKE CONCAT(#{v}, '%') + + + ) + + + AND ${column} != 'CHN-' + + + ) + or ${column} is null + + + !( + + `value` + + + ${column} + + in + + #{v} + + ) + or ${column} is null + + + + + ${column} between #{condition.value[0]} and #{condition.value[1]} + + + ${column} is not null and ${column} > #{condition.value} + + + ${column} is not null and ${column} < #{condition.value} + + + ${column} is not null and ${column} >= #{condition.value} + + + ${column} is not null and ${column} <= #{condition.value} + + + ${column} is null or ${column} = '' + + + ${column} is not null and ${column} != '' + + + ${column} = #{condition.value} + + + ${column} != #{condition.value} + or ${column} is null + + + + + + + + + + + + + + + + ${column} in ( + + #{valueItem} + ) + + + + ${column} NOT IN ( + + #{valueItem} + ) + OR ${column} IS NULL + + + + + (${column} IS NULL OR ${column} = '') + + + + (${column} IS NOT NULL AND ${column} != '') + + + + + + + + + + AND + + + OR + + + + + + + #{value} + + + + + JSON_OVERLAPS(${column}, + + + JSON_ARRAY( + + CAST(#{value} AS SIGNED) + ) + + + JSON_ARRAY(CAST(#{values[0]} AS SIGNED)) + + ) + + + + + + + + order by + + + ${prefix}.${sortName} ${sortType} + + + ${sortName} ${sortType} + + + + + + order by + + + ${prefix}.${defaultSort} + + + ${defaultSort} + + + + + + + + + + + + + + + + + + + + + categoryAxis_multiple.id + + + + categoryAxis.department_id + + + ${mainTable}.${request.categoryAxisParam.businessFieldName} + + + + + + categoryAxis_multiple.id + + + categoryAxis.field_value + + + as categoryAxis, + + + + + subCategoryAxis_multiple.id + + + + subCategoryAxis.department_id + + + ${mainTable}.${request.subCategoryAxisParam.businessFieldName} + + + + + + subCategoryAxis_multiple.id + + + subCategoryAxis.field_value + + + as subCategoryAxis, + + + ${request.valueAxisParam.aggregateMethod}( + + 1 + + + + ${mainTable}.${request.valueAxisParam.businessFieldName} + + + valueAxis.field_value + + + ) as valueAxis + + + + group by + + + categoryAxis_multiple.id + + + + categoryAxis.department_id + + + ${mainTable}.${request.categoryAxisParam.businessFieldName} + + + + + + categoryAxis_multiple.id + + + categoryAxis.field_value + + + + , + + + subCategoryAxis_multiple.id + + + + subCategoryAxis.department_id + + + ${mainTable}.${request.subCategoryAxisParam.businessFieldName} + + + + + + + subCategoryAxis_multiple.id + + + subCategoryAxis.field_value + + + + limit 500 + + + + + left join JSON_TABLE( + ${mainTablePrefix}.products, + '$[*]' COLUMNS ( + id VARCHAR(50) PATH '$' + ) + ) AS ${tablePrefix}_multiple on true + + + + left join ${mainTable}_field_blob ${tablePrefix} + on ${mainTablePrefix}.id = ${tablePrefix}.resource_id and ${tablePrefix}.field_id = #{${axisParam}.fieldId} + left join JSON_TABLE( + ${tablePrefix}.field_value, + '$[*]' COLUMNS ( + id VARCHAR(50) PATH '$' + ) + ) AS ${tablePrefix}_multiple on true + + + left join ${mainTable}_field ${tablePrefix} + on ${mainTablePrefix}.id = ${tablePrefix}.resource_id and ${tablePrefix}.field_id = #{${axisParam}.fieldId} + + + + left join sys_organization_user ${tablePrefix} + on ${tablePrefix}.user_id = ${mainTablePrefix}.owner + + + + + + + left join ${condition.refMainTableName} ${fieldTable} + on ${mainTableAlias}.${condition.refMainFieldName} = ${fieldTable}.id + + + left join ${condition.refMainTableName} ${fieldTable}_main + on ${mainTableAlias}.${condition.refMainFieldName} = ${fieldTable}_main.id + left join + + ${condition.refMainTableName}_field_blob + + + ${condition.refMainTableName}_field + + ${fieldTable} + on ${fieldTable}_main.id = ${fieldTable}.resource_id and ${fieldTable}.field_id = #{condition.name} + + + + left join ${mainTable}_field ${fieldTable}_custom + on ${mainTableAlias}.id = ${fieldTable}_custom.resource_id and ${fieldTable}_custom.field_id = #{condition.refMainFieldName} + + left join ${condition.refMainTableName} ${fieldTable} + on ${fieldTable}_custom.field_value = ${fieldTable}.id + + + left join ${condition.refMainTableName} ${fieldTable}_main + on ${fieldTable}_custom.field_value = ${fieldTable}_main.id + left join + + ${condition.refMainTableName}_field_blob + + + ${condition.refMainTableName}_field + + ${fieldTable} + on ${fieldTable}_main.id = ${fieldTable}.resource_id and ${fieldTable}.field_id = #{condition.name} + + + + diff --git a/backend/crm/src/main/java/cn/cordys/common/permission/CsBatchPermission.java b/backend/crm/src/main/java/cn/cordys/common/permission/CsBatchPermission.java new file mode 100644 index 0000000..a81a942 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/permission/CsBatchPermission.java @@ -0,0 +1,33 @@ +package cn.cordys.common.permission; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 批量操作权限校验注解 + *

+ * 仅校验角色权限位和数据权限,不校验待办和审批状态权限 + * 状态权限业务代理里有校验,待办没有批量操作 + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface CsBatchPermission { + + /** + * 权限码,如 "ORDER:UPDATE" + */ + String value(); + + /** + * 资源ID的SpEL表达式,支持解析为单个ID或List + * 为空时仅校验角色权限位 + */ + String resourceId() default ""; + + /** + * 表单类型,如 "order",用于数据权限校验 + */ + String formType() default ""; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/permission/CsPermission.java b/backend/crm/src/main/java/cn/cordys/common/permission/CsPermission.java new file mode 100644 index 0000000..a176b0b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/permission/CsPermission.java @@ -0,0 +1,38 @@ +package cn.cordys.common.permission; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 自定义权限校验注解 + * 该注解可以替换 @RequiresPermissions,支持更复杂的权限校验逻辑,包括资源ID和审批状态权限。 + * 注:该注解不支持校验多个权限码,多个请使用 @RequiresPermissions 注解。 + *

+ * 四个判断依据:当前资源是你待办的资源 or (角色的权限位 && 角色的数据权限 && 审批流的状态权限) + *

+ * 当 resourceId 为空时,仅校验角色权限位; + * 指定 resourceId 时,若 approvalTaskId 不为空,则校验审批状态权限;否则校order验(角色的权限位 && 角色的数据权限 && 审批流的状态权限)。 + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface CsPermission { + + /** + * 权限码,如 "ORDER:READ" + */ + String value(); + + /** + * 资源ID的SpEL表达式,如 "{#id}" 或 "{#request.id}" + * 为空时仅校验角色权限位 + */ + String resourceId() default ""; + + /** + * 表单类型,如 "order",用于审批状态权限校验 + * 为空时跳过审批状态权限校验 + */ + String formType() default ""; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/permission/CsPermissionAspect.java b/backend/crm/src/main/java/cn/cordys/common/permission/CsPermissionAspect.java new file mode 100644 index 0000000..87927c2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/permission/CsPermissionAspect.java @@ -0,0 +1,159 @@ +package cn.cordys.common.permission; + +import cn.cordys.context.OrganizationContext; +import cn.cordys.security.SessionUtils; +import jakarta.annotation.Resource; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.StandardReflectionParameterNameDiscoverer; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Objects; + +/** + * @CsPermission 注解切面 + *

+ * 拦截标注了 @CsPermission 的方法,执行权限校验 + */ +@Aspect +@Component +public class CsPermissionAspect { + + private final ExpressionParser parser = new SpelExpressionParser(); + private final StandardReflectionParameterNameDiscoverer discoverer = new StandardReflectionParameterNameDiscoverer(); + + @Resource + private ResourcePermissionService resourcePermissionService; + + + @Before("@annotation(cn.cordys.common.permission.CsPermission)") + public void checkPermission(JoinPoint joinPoint) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + CsPermission annotation = method.getAnnotation(CsPermission.class); + if (annotation == null) { + return; + } + + String permission = annotation.value(); + String resourceIdExpr = annotation.resourceId(); + + if (resourceIdExpr.isEmpty()) { + // 仅校验角色权限位 + resourcePermissionService.checkPermission(permission); + } else { + String formType = annotation.formType(); + String userId = SessionUtils.getUserId(); + String orgId = OrganizationContext.getOrganizationId(); + // 从方法参数中解析资源ID + String resourceId = resolveResourceId(method, joinPoint.getArgs(), resourceIdExpr); + // 校验 (1 && 2 && 3) + resourcePermissionService.checkResourcePermission(permission, resourceId, formType, userId, orgId); + } + } + + /** + * 从方法参数中解析资源ID(支持SpEL表达式) + */ + private String resolveResourceId(Method method, Object[] args, String expression) { + String[] params = discoverer.getParameterNames(method); + if (params == null || args == null) { + return null; + } + + EvaluationContext context = new StandardEvaluationContext(); + for (int i = 0; i < params.length; i++) { + context.setVariable(params[i], args[i]); + } + + try { + var exp = parser.parseExpression(expression); + Object value = exp.getValue(context, Object.class); + if (value == null) { + return null; + } + if (value instanceof List list) { + return list.isEmpty() ? null : list.getFirst().toString(); + } + return value.toString(); + } catch (Exception e) { + return null; + } + } + + /** + * 批量操作权限校验:仅校验角色权限位 + 数据权限,不校验待办和审批状态权限 + */ + @Before("@annotation(cn.cordys.common.permission.CsBatchPermission)") + public void checkBatchPermission(JoinPoint joinPoint) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + CsBatchPermission annotation = method.getAnnotation(CsBatchPermission.class); + if (annotation == null) { + return; + } + + String permission = annotation.value(); + String resourceIdExpr = annotation.resourceId(); + String formType = annotation.formType(); + String userId = SessionUtils.getUserId(); + String orgId = OrganizationContext.getOrganizationId(); + + if (resourceIdExpr.isEmpty()) { + resourcePermissionService.checkPermission(permission); + } else { + List resourceIds = resolveResourceIds(method, joinPoint.getArgs(), resourceIdExpr); + resourcePermissionService.checkBatchResourcePermission(permission, resourceIds, formType, userId, orgId); + } + } + + /** + * 从方法参数中解析资源ID列表(支持SpEL表达式,结果为List) + *

+ * SpEL {expr} 会将结果包一层 list,如 {#request.ids} 中 ids 为 List 时, + * 结果为 List>,需要展平内层 + */ + private List resolveResourceIds(Method method, Object[] args, String expression) { + String[] params = discoverer.getParameterNames(method); + if (params == null || args == null) { + return List.of(); + } + + EvaluationContext context = new StandardEvaluationContext(); + for (int i = 0; i < params.length; i++) { + context.setVariable(params[i], args[i]); + } + + try { + var exp = parser.parseExpression(expression); + Object value = exp.getValue(context, Object.class); + if (value == null) { + return List.of(); + } + if (value instanceof List list) { + // SpEL {expr} 包了一层 list,如果唯一元素也是 list,则展平 + if (list.size() == 1 && list.getFirst() instanceof List inner) { + return inner.stream() + .filter(Objects::nonNull) + .map(Object::toString) + .toList(); + } + return list.stream() + .filter(Objects::nonNull) + .map(Object::toString) + .toList(); + } + return List.of(value.toString()); + } catch (Exception e) { + return List.of(); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/permission/Permission.java b/backend/crm/src/main/java/cn/cordys/common/permission/Permission.java new file mode 100644 index 0000000..cccd53f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/permission/Permission.java @@ -0,0 +1,29 @@ +package cn.cordys.common.permission; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 权限信息 + * + * @author jianxing + */ +@Data +@Schema(description = "权限信息") +public class Permission implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "权限ID") + private String id; + @Schema(description = "权限名称") + private String name; + @Schema(description = "是否启用该权限") + private Boolean enable = false; + @Schema(description = "是否是企业权限") + private Boolean license = false; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/permission/PermissionCache.java b/backend/crm/src/main/java/cn/cordys/common/permission/PermissionCache.java new file mode 100644 index 0000000..f36b81b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/permission/PermissionCache.java @@ -0,0 +1,72 @@ +package cn.cordys.common.permission; + +import cn.cordys.common.dto.RoleDataScopeDTO; +import cn.cordys.common.dto.RolePermissionDTO; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.crm.system.domain.RolePermission; +import cn.cordys.crm.system.service.RoleService; +import org.apache.commons.collections.CollectionUtils; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 权限缓存 + * + * @author jianxing + */ +@Component +public class PermissionCache { + + @Cacheable(value = "permission_cache", key = "#userId + ':' + #orgId") + public List getRolePermissions(String userId, String orgId) { + RoleService roleService = CommonBeanFactory.getBean(RoleService.class); + // 获取角色 + List roleOptions = Objects.requireNonNull(roleService).getRoleOptions(userId, orgId); + List roleIds = roleOptions.stream() + .map(RoleDataScopeDTO::getId).collect(Collectors.toList()); + + if (CollectionUtils.isEmpty(roleIds)) { + return new ArrayList<>(0); + } + + // 获取角色权限 + List permissions = roleService.getPermissions(roleIds); + Map> rolePermissionMap = permissions.stream() + .collect(Collectors.groupingBy(RolePermission::getRoleId)); + + // 缓存角色和权限 + return roleOptions.stream() + .map(roleDataScopeDTO -> { + RolePermissionDTO rolePermission = BeanUtils.copyBean(new RolePermissionDTO(), roleDataScopeDTO); + List rolePermissions = rolePermissionMap.get(roleDataScopeDTO.getId()); + if (CollectionUtils.isEmpty(rolePermissions)) { + rolePermission.setPermissions(Set.of()); + return rolePermission; + } + rolePermission.setPermissions( + rolePermissions + .stream() + .map(RolePermission::getPermissionId) + .collect(Collectors.toSet()) + ); + return rolePermission; + }).collect(Collectors.toList()); + } + + public Set getPermissionIds(String userId, String orgId) { + List rolePermissions = Objects.requireNonNull(CommonBeanFactory.getBean(PermissionCache.class)).getRolePermissions(userId, orgId); + return rolePermissions.stream() + .flatMap(rolePermissionDTO -> rolePermissionDTO.getPermissions().stream()) + .collect(Collectors.toSet()); + } + + @CacheEvict(value = "permission_cache", key = "#userId + ':' + #orgId", beforeInvocation = true) + public void clearCache(String userId, String orgId) { + // do nothing + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/permission/PermissionDefinitionItem.java b/backend/crm/src/main/java/cn/cordys/common/permission/PermissionDefinitionItem.java new file mode 100644 index 0000000..0e32d00 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/permission/PermissionDefinitionItem.java @@ -0,0 +1,26 @@ +package cn.cordys.common.permission; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * @author jianxing + */ +@Data +@Schema(description = "权限设置菜单项") +public class PermissionDefinitionItem { + @Schema(description = "菜单项ID") + private String id; + @Schema(description = "菜单项名称") + private String name; + @Schema(description = "是否是企业版菜单") + private Boolean license = false; + @Schema(description = "菜单是否全选") + private Boolean enable = false; + @Schema(description = "菜单下的权限列表") + private List permissions; + @Schema(description = "子菜单") + private List children; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/permission/PermissionUtils.java b/backend/crm/src/main/java/cn/cordys/common/permission/PermissionUtils.java new file mode 100644 index 0000000..ab810d7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/permission/PermissionUtils.java @@ -0,0 +1,58 @@ +package cn.cordys.common.permission; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.RoleDataScope; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.common.dto.RolePermissionDTO; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.context.OrganizationContext; +import cn.cordys.security.SessionUser; +import cn.cordys.security.SessionUtils; +import org.apache.commons.lang3.Strings; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * @author jianxing + */ +public class PermissionUtils { + public static boolean hasPermission(String permission) { + PermissionCache permissionCache = CommonBeanFactory.getBean(PermissionCache.class); + String userId = SessionUtils.getUserId(); + String organizationId = OrganizationContext.getOrganizationId(); + Set permissionIds = Objects.requireNonNull(permissionCache).getPermissionIds(userId, organizationId); + SessionUser user = Objects.requireNonNull(SessionUtils.getUser()); + if (Strings.CS.equals(InternalUser.ADMIN.getValue(), user.getId())) { + // admin 用户拥有所有权限 + return true; + } + // 判断是否拥有权限 + return permissionIds.contains(permission); + } + + public static ResourceTabEnableDTO getTabEnableConfig(String userId, String permission, List rolePermissions) { + ResourceTabEnableDTO resourceTabEnableDTO = new ResourceTabEnableDTO(); + if (Strings.CS.equals(userId, InternalUser.ADMIN.getValue())) { + resourceTabEnableDTO.setAll(true); + resourceTabEnableDTO.setDept(true); + } + for (RolePermissionDTO rolePermission : rolePermissions) { + if (!rolePermission.getPermissions().contains(permission)) { + // 判断权限 + continue; + } + if (Strings.CS.equalsAny(rolePermission.getDataScope(), RoleDataScope.ALL.name(), RoleDataScope.DEPT_CUSTOM.name())) { + // 数据权限为全部或指定部门,显示所有tab + resourceTabEnableDTO.setAll(true); + } + if (Strings.CS.equalsAny(rolePermission.getDataScope(), RoleDataScope.ALL.name(), RoleDataScope.DEPT_CUSTOM.name(), + RoleDataScope.DEPT_AND_CHILD.name())) { + // 数据权限为全部或部门,显示部门tab + resourceTabEnableDTO.setDept(true); + } + } + return resourceTabEnableDTO; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/permission/ResourceAccessContext.java b/backend/crm/src/main/java/cn/cordys/common/permission/ResourceAccessContext.java new file mode 100644 index 0000000..09bc5b6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/permission/ResourceAccessContext.java @@ -0,0 +1,20 @@ +package cn.cordys.common.permission; + +import lombok.Data; + +/** + * 资源访问上下文,包含权限校验所需的资源信息 + */ +@Data +public class ResourceAccessContext { + + /** + * 资源负责人ID + */ + private String ownerId; + + /** + * 审批状态 + */ + private String approvalStatus; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/permission/ResourceAccessContextProvider.java b/backend/crm/src/main/java/cn/cordys/common/permission/ResourceAccessContextProvider.java new file mode 100644 index 0000000..9ab66c5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/permission/ResourceAccessContextProvider.java @@ -0,0 +1,37 @@ +package cn.cordys.common.permission; + +import java.util.List; +import java.util.Map; + +/** + * 资源访问上下文提供者 + *

+ * 各业务模块实现此接口,提供资源负责人和审批状态信息 + */ +public interface ResourceAccessContextProvider { + + /** + * 返回此提供者处理的表单类型,如 "order"、"contract" + */ + String getFormType(); + + /** + * 获取资源访问上下文 + * + * @param resourceId 资源ID + * @param orgId 组织ID + * @return 资源访问上下文,资源不存在时返回 null + */ + ResourceAccessContext getAccessContext(String resourceId, String orgId); + + /** + * 批量获取资源负责人ID + *

+ * 默认逐条调用 getAccessContext,子类可重写以优化为批量查询 + * + * @param resourceIds 资源ID列表 + * @param orgId 组织ID + * @return 资源ID -> 负责人ID 的映射,不包含负责人为空的资源 + */ + Map batchGetOwnerIds(List resourceIds, String orgId); +} diff --git a/backend/crm/src/main/java/cn/cordys/common/permission/ResourcePermissionService.java b/backend/crm/src/main/java/cn/cordys/common/permission/ResourcePermissionService.java new file mode 100644 index 0000000..d670a5b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/permission/ResourcePermissionService.java @@ -0,0 +1,211 @@ +package cn.cordys.common.permission; + +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.response.result.CrmHttpResultCode; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.crm.approval.constants.ApprovalStatus; +import cn.cordys.crm.approval.domain.ApprovalInstance; +import cn.cordys.crm.approval.domain.ApprovalTask; +import cn.cordys.crm.approval.service.ApprovalFlowService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.List; +import java.util.Map; + +/** + * 资源权限校验服务 + *

+ * 校验逻辑:(角色权限位 && 角色数据权限 && 审批状态权限) + */ +@Service +public class ResourcePermissionService { + + @Resource + private DataScopeService dataScopeService; + @Resource + private ApprovalFlowService approvalFlowService; + @Resource + private BaseMapper approvalTaskMapper; + @Resource + private BaseMapper approvalInstanceMapper; + + /** + * 仅校验角色权限位 + */ + public void checkPermission(String permission) { + if (!PermissionUtils.hasPermission(permission)) { + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + } + + /** + * 完整权限校验:(角色权限位 && 角色数据权限 && 审批状态权限) + * + * @param permission 权限码 + * @param resourceId 资源ID + * @param formType 表单类型 + * @param userId 当前用户ID + * @param orgId 组织ID + */ + public void checkResourcePermission(String permission, String resourceId, String formType, String userId, String orgId) { + if (StringUtils.isBlank(resourceId)) { + checkPermission(permission); + return; + } + + // 从HTTP请求中获取待办任务ID + String approvalTaskId = resolveApprovalTaskId(); + // 如果传了待办任务ID,只校验当前用户是否是该待办的所有人 + if (StringUtils.isNotBlank(approvalTaskId)) { + if (!isTaskOwner(approvalTaskId, resourceId, userId)) { + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + return; + } + + ResourceAccessContextProvider provider = getProvider(formType); + ResourceAccessContext context = provider != null ? provider.getAccessContext(resourceId, orgId) : null; + boolean permitted = checkRoleAndDataAndStatusPermission(permission, formType, userId, orgId, context); + + if (!permitted) { + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + } + + /** + * 校验当前用户是否是指定待办任务的所有人,且该任务关联的资源与请求资源一致 + */ + private boolean isTaskOwner(String approvalTaskId, String resourceId, String userId) { + if (StringUtils.isBlank(approvalTaskId) || StringUtils.isBlank(userId)) { + return false; + } + + ApprovalTask task = approvalTaskMapper.selectByPrimaryKey(approvalTaskId); + if (task == null || !userId.equals(task.getApproverId())) { + return false; + } + + // 校验任务关联的审批实例资源是否与当前请求的资源一致 + ApprovalInstance instance = approvalInstanceMapper.selectByPrimaryKey(task.getInstanceId()); + return instance != null && resourceId.equals(instance.getResourceId()); + } + + /** + * 从HTTP请求中提取待办任务ID + * 优先从请求头 X-Pending-Task-Id 获取,其次从查询参数 pendingTaskId 获取 + */ + private String resolveApprovalTaskId() { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return null; + } + HttpServletRequest request = attributes.getRequest(); + String pendingTaskId = request.getHeader("Approval-Task-Id"); + if (pendingTaskId == null || pendingTaskId.isBlank()) { + pendingTaskId = request.getParameter("approvalTaskId"); + } + return (pendingTaskId != null && !pendingTaskId.isBlank()) ? pendingTaskId.trim() : null; + } + + /** + * 批量权限校验:仅校验角色权限位 + 数据权限(跳过审批状态权限) + */ + public void checkBatchResourcePermission(String permission, List resourceIds, String formType, String userId, String orgId) { + if (!PermissionUtils.hasPermission(permission)) { + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + + if (StringUtils.isNotBlank(formType) && CollectionUtils.isNotEmpty(resourceIds)) { + ResourceAccessContextProvider provider = getProvider(formType); + if (provider == null) { + return; + } + Map ownerMap = provider.batchGetOwnerIds(resourceIds, orgId); + List ownerIds = ownerMap.values().stream() + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + if (!ownerIds.isEmpty()) { + dataScopeService.checkDataPermission(userId, orgId, ownerIds, permission); + } + } + } + + /** + * 校验角色权限位 + 数据权限 + 审批状态权限:(1 && 2 && 3) + */ + private boolean checkRoleAndDataAndStatusPermission(String permission, String formType, + String userId, String orgId, ResourceAccessContext context) { + + String ownerId = context != null ? context.getOwnerId() : null; + String approvalStatus = context != null ? context.getApprovalStatus() : null; + + // Check 1: 角色权限位 + if (!PermissionUtils.hasPermission(permission)) { + return false; + } + + // Check 2: 角色的数据权限 + if (ownerId != null && !dataScopeService.hasDataPermission(userId, orgId, ownerId, permission)) { + return false; + } + + // Check 3: 审批流的状态权限 + if (StringUtils.isNotBlank(formType) && !checkStatusPermission(permission, formType, approvalStatus, orgId)) { + return false; + } + + return true; + } + + /** + * 校验审批状态权限 + * 如果审批状态为 NONE 或无审批流配置,默认允许 + */ + private boolean checkStatusPermission(String permission, String formType, String approvalStatus, String orgId) { + if (StringUtils.isBlank(approvalStatus) || ApprovalStatus.NONE.name().equals(approvalStatus)) { + return true; + } + + // 查询状态权限配置 + var setting = approvalFlowService.getStatusPermissionsByFormType(formType, orgId); + if (setting == null || CollectionUtils.isEmpty(setting.getStatusPermissions())) { + return true; + } + + // 查找当前审批状态对应的权限配置 + for (var sp : setting.getStatusPermissions()) { + if (permission.equals(sp.getPermission()) && approvalStatus.equals(sp.getApprovalStatus())) { + return Boolean.TRUE.equals(sp.getEnabled()); + } + } + + // 未找到对应配置,默认允许 + return true; + } + + /** + * 根据 formType 获取对应的 ResourceAccessContextProvider + */ + private ResourceAccessContextProvider getProvider(String formType) { + if (StringUtils.isBlank(formType)) { + return null; + } + Map providers = cn.cordys.common.util.CommonBeanFactory + .getBeansOfType(ResourceAccessContextProvider.class); + if (providers == null) { + return null; + } + return providers.values().stream() + .filter(p -> formType.equals(p.getFormType())) + .findFirst() + .orElse(null); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/redis/MessagePublisher.java b/backend/crm/src/main/java/cn/cordys/common/redis/MessagePublisher.java new file mode 100644 index 0000000..205ddf4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/redis/MessagePublisher.java @@ -0,0 +1,42 @@ +package cn.cordys.common.redis; + +import cn.cordys.common.constants.TopicConstants; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class MessagePublisher { + + private final StringRedisTemplate redisTemplate; + + public MessagePublisher(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + /** + * 发布消息到默认主题 + * + * @param message 要发布的消息内容 + */ + public void publish(String message) { + publish(TopicConstants.DOWNLOAD_TOPIC, message); + } + + /** + * 发布消息到指定主题 + * + * @param topicName 主题名称 + * @param message 要发布的消息内容 + */ + public void publish(String topicName, String message) { + try { + ChannelTopic topic = new ChannelTopic(topicName); + redisTemplate.convertAndSend(topic.getTopic(), message); + } catch (Exception e) { + log.error("发布消息到主题失败", e); + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/common/redis/MessageSubscriber.java b/backend/crm/src/main/java/cn/cordys/common/redis/MessageSubscriber.java new file mode 100644 index 0000000..ec40cae --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/redis/MessageSubscriber.java @@ -0,0 +1,65 @@ +package cn.cordys.common.redis; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Redis消息订阅处理器 + * 负责接收并处理来自Redis频道的消息 + */ +@Service +@Slf4j +public class MessageSubscriber implements MessageListener { + + /** + * 所有Topic的消费者集合 + */ + static Map consumerMap = new HashMap<>(); + + + public MessageSubscriber(List consumers) { + consumers.forEach(consumer -> consumerMap.put(consumer.getChannel(), consumer)); + } + + /** + * 处理从Redis接收的消息 + * + * @param message Redis消息对象 + * @param pattern 订阅的模式 + */ + @Override + public void onMessage(Message message, byte[] pattern) { + try { + String channel = new String(pattern); + String messageBody = new String(message.getBody()); + // 处理消息 + processMessage(messageBody, channel); + } catch (Exception e) { + log.error("处理订阅消息时发生异常", e); + } + } + + /** + * 处理解析后的消息 + * + * @param message 消息内容 + * @param channel 消息来源频道 + */ + private void processMessage(String message, String channel) { + // 根据频道区分不同的业务逻辑处理 + TopicConsumer consumer = consumerMap.get(channel); + if (consumer == null) { + log.error("未找到对应的消费者处理频道: {}", channel); + return; + } + consumer.consume(message); + } + + +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/common/redis/TopicConsumer.java b/backend/crm/src/main/java/cn/cordys/common/redis/TopicConsumer.java new file mode 100644 index 0000000..2946cc9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/redis/TopicConsumer.java @@ -0,0 +1,11 @@ +package cn.cordys.common.redis; + +/** + * redis 各种topic消费者的公共接口 + */ +public interface TopicConsumer { + + String getChannel(); + + void consume(String message); +} diff --git a/backend/crm/src/main/java/cn/cordys/common/request/LoginRequest.java b/backend/crm/src/main/java/cn/cordys/common/request/LoginRequest.java new file mode 100644 index 0000000..e17ef29 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/request/LoginRequest.java @@ -0,0 +1,78 @@ +package cn.cordys.common.request; + +import cn.cordys.common.util.rsa.RsaKey; +import cn.cordys.common.util.rsa.RsaUtils; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +/** + *

登录请求的 DTO 类。

+ *

包含用户名、密码以及认证信息。用户名和密码经过 RSA 解密后使用。

+ */ +@Getter +@Setter +public class LoginRequest { + + /** + * 用户名,不能为空,最大长度为 256。 + */ + @NotBlank(message = "{user_name_is_null}") + @Size(max = 256, message = "{user_name_length_too_long}") + private String username; + + /** + * 密码,不能为空,最大长度为 256。 + */ + @NotBlank(message = "{password_is_null}") + @Size(max = 256, message = "{password_length_too_long}") + private String password; + + /** + * 认证信息,可选字段。 + */ + private String authenticate; + + /** + * 登录地,可选字段。 + */ + private String loginAddress; + + /** + * 平台 + */ + @NotBlank(message = "{platform_is_null}") + private String platform; + + /** + * 获取解密后的用户名。 + *

如果解密失败,将返回原始的用户名。

+ * + * @return 解密后的用户名 + */ + public String getUsername() { + try { + RsaKey rsaKey = RsaUtils.getRsaKey(); + return RsaUtils.privateDecrypt(username, rsaKey.getPrivateKey()); + } catch (Exception e) { + // 解密失败,返回原始用户名 + throw new RuntimeException("解密用户名失败", e); + } + } + + /** + * 获取解密后的密码。 + *

如果解密失败,将返回原始的密码。

+ * + * @return 解密后的密码 + */ + public String getPassword() { + try { + RsaKey rsaKey = RsaUtils.getRsaKey(); + return RsaUtils.privateDecrypt(password, rsaKey.getPrivateKey()); + } catch (Exception e) { + throw new RuntimeException("解密密码失败", e); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/AbstractModuleFieldResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/AbstractModuleFieldResolver.java new file mode 100644 index 0000000..eb025c4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/AbstractModuleFieldResolver.java @@ -0,0 +1,142 @@ +package cn.cordys.common.resolver.field; + + +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import org.apache.commons.lang3.StringUtils; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static cn.cordys.common.constants.CommonResultCode.FIELD_OPTION_VALUE_ERROR; +import static cn.cordys.common.constants.CommonResultCode.FIELD_VALIDATE_ERROR; + + +/** + * @author jainxing + */ +public abstract class AbstractModuleFieldResolver { + + /** + * 校验参数是否合法 + * + * @param customField + * @param value + */ + abstract public void validate(T customField, Object value); + + /** + * 将数据库的字符串值转换为对应的参数值 + * + * @param value + * @return + */ + public Object convertToValue(T selectField, String value) { + return value; + } + + /** + * 将数据库的字符串值转换为对应的参数值 + * + * @param value + * @return + */ + public Object transformToValue(T selectField, String value) { + return value; + } + + /** + * 字段文本 => 值 + * + * @param field 字段 + * @param text 文本 + * @return 字段值 + */ + public Object textToValue(T field, String text) { + return text; + } + + + /** + * 将对应的参数值转换成字符串 + * + * @param value + * @return + */ + public String convertToString(T selectField, Object value) { + return value == null ? null : value.toString(); + } + + protected void throwValidateException(String name) { + throw new GenericException(FIELD_VALIDATE_ERROR, Translator.getWithArgs(FIELD_VALIDATE_ERROR.getMessage(), name)); + } + + protected void throwOptionException(String name) { + throw new GenericException(FIELD_OPTION_VALUE_ERROR, Translator.getWithArgs(FIELD_OPTION_VALUE_ERROR.getMessage(), name)); + } + + protected void validateRequired(T customField, Object value) { + // 移动端,不一定需要校验必填,暂时不校验 + } + + protected void validateArray(String name, Object value) { + if (value == null) { + return; + } + if (value instanceof List list) { + list.forEach(v -> validateString(name, v)); + } else { + throwValidateException(name); + } + } + + protected void validateString(String name, Object v) { + if (v != null && !(v instanceof String)) { + throwValidateException(name); + } + } + + // 校验选项值是否合法,空值,"",不校验 + protected void validateOptions(String name, Object value, List options) { + if (value == null || StringUtils.isBlank(value.toString())) { + return; + } + if (options == null) { + options = List.of(); + } + Set values = options.stream() + .map(OptionProp::getValue) + .collect(Collectors.toSet()); + if (!values.contains(value)) { + throwOptionException(name); + } + } + + protected String getStringValue(Object value) { + return value == null ? null : value.toString(); + } + + protected Object parse2Array(String value) { + return value == null ? null : JSON.parseArray(value); + } + + protected Object parse2Long(String value) { + return value == null ? null : Long.valueOf(value); + } + + protected String getJsonString(Object value) { + return value == null ? null : JSON.toJSONString(value); + } + + protected List parseFakeJsonArray(String content) { + return Arrays.stream(content.replaceAll("^\\[|]$", "").replaceAll("['\"]", "").split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/AttachmentFieldResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/AttachmentFieldResolver.java new file mode 100644 index 0000000..8b95b15 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/AttachmentFieldResolver.java @@ -0,0 +1,54 @@ +package cn.cordys.common.resolver.field; + +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.dto.field.AttachmentField; +import cn.cordys.crm.system.mapper.ExtAttachmentMapper; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.List; +import java.util.Objects; + +public class AttachmentFieldResolver extends AbstractModuleFieldResolver { + + + private static final ExtAttachmentMapper extAttachmentMapper; + + static { + extAttachmentMapper = CommonBeanFactory.getBean(ExtAttachmentMapper.class); + } + + @Override + public void validate(AttachmentField customField, Object value) { + + } + + @Override + public String convertToString(AttachmentField attachmentField, Object value) { + return getJsonString(value); + } + + @Override + public Object convertToValue(AttachmentField attachmentField, String value) { + return parse2Array(value); + } + + @Override + public Object transformToValue(AttachmentField attachmentField, String value) { + if (StringUtils.isBlank(value) || Strings.CS.equals(value, "[]")) { + return StringUtils.EMPTY; + } + List ids = JSON.parseArray(value, String.class); + + List names = Objects.requireNonNull(extAttachmentMapper).selectNameByIds(ids); + + if (CollectionUtils.isNotEmpty(names)) { + return String.join(",", JSON.parseArray(JSON.toJSONString(names), String.class)); + } + + return StringUtils.EMPTY; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/CheckBoxResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/CheckBoxResolver.java new file mode 100644 index 0000000..1c9d67b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/CheckBoxResolver.java @@ -0,0 +1,77 @@ +package cn.cordys.common.resolver.field; + + +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.dto.field.CheckBoxField; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * @author jianxing + */ +public class CheckBoxResolver extends AbstractModuleFieldResolver { + + @Override + public void validate(CheckBoxField checkBoxField, Object value) { + // 校验必填 + validateRequired(checkBoxField, value); + + // 校验值类型 + validateArray(checkBoxField.getName(), value); + } + + @Override + public String convertToString(CheckBoxField checkBoxField, Object value) { + return JSON.toJSONString(value); + } + + @Override + public Object convertToValue(CheckBoxField checkBoxField, String value) { + return parse2Array(value); + } + + @Override + public Object transformToValue(CheckBoxField checkBoxField, String value) { + if (StringUtils.isBlank(value) || Strings.CS.equals(value, "[]")) { + return StringUtils.EMPTY; + } + List list = JSON.parseArray(value, String.class); + List result = new ArrayList<>(); + Map optionValueMap = checkBoxField.getOptions().stream().collect(Collectors.toMap(OptionProp::getValue, OptionProp::getLabel)); + list.forEach(item -> { + if (optionValueMap.containsKey(item)) { + result.add(optionValueMap.get(item)); + } + }); + return String.join(",", JSON.parseArray(JSON.toJSONString(result))); + } + + @Override + public Object textToValue(CheckBoxField field, String text) { + if (StringUtils.isBlank(text) || Strings.CS.equals(text, "[]")) { + return StringUtils.EMPTY; + } + try { + List texts = parseFakeJsonArray(text); + if (CollectionUtils.isEmpty(texts)) { + return StringUtils.EMPTY; + } + Map optionMap = field.getOptions().stream() + .collect(Collectors.toMap(OptionProp::getLabel, OptionProp::getValue, (v1, v2) -> v1)); + List values = texts.stream() + .filter(item -> item != null && optionMap.containsKey(item)) + .map(optionMap::get) + .collect(Collectors.toList()); + return CollectionUtils.isEmpty(values) ? texts : values; + } catch (Exception e) { + return StringUtils.EMPTY; + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/DatasourceMultipleResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/DatasourceMultipleResolver.java new file mode 100644 index 0000000..9bd5f7a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/DatasourceMultipleResolver.java @@ -0,0 +1,225 @@ +package cn.cordys.common.resolver.field; + +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.clue.domain.Clue; +import cn.cordys.crm.clue.service.ClueService; +import cn.cordys.crm.contract.domain.BusinessTitle; +import cn.cordys.crm.contract.domain.Contract; +import cn.cordys.crm.contract.domain.ContractPaymentPlan; +import cn.cordys.crm.contract.domain.ContractPaymentRecord; +import cn.cordys.crm.contract.service.BusinessTitleService; +import cn.cordys.crm.contract.service.ContractPaymentPlanService; +import cn.cordys.crm.contract.service.ContractPaymentRecordService; +import cn.cordys.crm.contract.service.ContractService; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.domain.CustomerContact; +import cn.cordys.crm.customer.service.CustomerContactService; +import cn.cordys.crm.customer.service.CustomerService; +import cn.cordys.crm.form.domain.CustomFormData; +import cn.cordys.crm.form.service.CustomFormDataService; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.opportunity.domain.OpportunityQuotation; +import cn.cordys.crm.opportunity.service.OpportunityQuotationService; +import cn.cordys.crm.opportunity.service.OpportunityService; +import cn.cordys.crm.order.domain.Order; +import cn.cordys.crm.order.service.OrderService; +import cn.cordys.crm.product.domain.Product; +import cn.cordys.crm.product.domain.ProductPrice; +import cn.cordys.crm.product.service.ProductPriceService; +import cn.cordys.crm.product.service.ProductService; +import cn.cordys.crm.system.constants.FieldSourceType; +import cn.cordys.crm.system.dto.field.DatasourceMultipleField; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.List; +import java.util.Objects; + +public class DatasourceMultipleResolver extends AbstractModuleFieldResolver { + + private static final CustomerService customerService; + private static final OpportunityService opportunityService; + private static final ClueService clueService; + private static final CustomerContactService contactService; + private static final ProductService productService; + private static final ProductPriceService productPriceService; + private static final OpportunityQuotationService opportunityQuotationService; + private static final ContractPaymentPlanService contractPaymentPlanService; + private static final ContractPaymentRecordService contractPaymentRecordService; + private static final BusinessTitleService businessTitleService; + private static final OrderService orderService; + private static final ContractService contractService; + private static final CustomFormDataService customFormDataService; + + public static final String EMPTY_ARRAY_STRING = "[]"; + + static { + customerService = CommonBeanFactory.getBean(CustomerService.class); + opportunityService = CommonBeanFactory.getBean(OpportunityService.class); + clueService = CommonBeanFactory.getBean(ClueService.class); + contactService = CommonBeanFactory.getBean(CustomerContactService.class); + productService = CommonBeanFactory.getBean(ProductService.class); + productPriceService = CommonBeanFactory.getBean(ProductPriceService.class); + opportunityQuotationService = CommonBeanFactory.getBean(OpportunityQuotationService.class); + contractPaymentRecordService = CommonBeanFactory.getBean(ContractPaymentRecordService.class); + contractPaymentPlanService = CommonBeanFactory.getBean(ContractPaymentPlanService.class); + businessTitleService = CommonBeanFactory.getBean(BusinessTitleService.class); + orderService = CommonBeanFactory.getBean(OrderService.class); + contractService = CommonBeanFactory.getBean(ContractService.class); + customFormDataService = CommonBeanFactory.getBean(CustomFormDataService.class); + } + + @Override + public void validate(DatasourceMultipleField customField, Object value) { + + } + + @Override + public Object convertToValue(DatasourceMultipleField customField, String value) { + return parse2Array(value); + } + + @Override + public String convertToString(DatasourceMultipleField customField, Object value) { + return getJsonString(value); + } + + + @Override + public Object transformToValue(DatasourceMultipleField datasourceMultipleField, String value) { + if (StringUtils.isBlank(value) || Strings.CS.equals(value, EMPTY_ARRAY_STRING)) { + return StringUtils.EMPTY; + } + var list = JSON.parseArray(value, String.class); + + if (Strings.CI.equals(datasourceMultipleField.getDataSourceType(), FieldSourceType.CUSTOMER.name())) { + return Objects.requireNonNull(customerService).getCustomerNameByIds(list); + } + + if (Strings.CI.equals(datasourceMultipleField.getDataSourceType(), FieldSourceType.CONTACT.name())) { + return Objects.requireNonNull(contactService).getContactNameByIds(list); + } + + if (Strings.CI.equals(datasourceMultipleField.getDataSourceType(), FieldSourceType.OPPORTUNITY.name())) { + return Objects.requireNonNull(opportunityService).getOpportunityNameByIds(list); + } + + if (Strings.CI.equals(datasourceMultipleField.getDataSourceType(), FieldSourceType.CLUE.name())) { + return Objects.requireNonNull(clueService).getClueNameByIds(list); + } + + if (Strings.CI.equals(datasourceMultipleField.getDataSourceType(), FieldSourceType.PRODUCT.name())) { + return Objects.requireNonNull(productService).getProductNameByIds(list); + } + + if (Strings.CI.equals(datasourceMultipleField.getDataSourceType(), FieldSourceType.PRICE.name())) { + return Objects.requireNonNull(productPriceService).getProductPriceNameByIds(list); + } + + if (Strings.CI.equals(datasourceMultipleField.getDataSourceType(), FieldSourceType.QUOTATION.name())) { + return Objects.requireNonNull(opportunityQuotationService).getQuotationNameByIds(list); + } + + if (Strings.CI.equals(datasourceMultipleField.getDataSourceType(), FieldSourceType.CONTRACT_PAYMENT_RECORD.name())) { + return Objects.requireNonNull(contractPaymentRecordService).getRecordNameByIds(list); + } + + if (Strings.CI.equals(datasourceMultipleField.getDataSourceType(), FieldSourceType.PAYMENT_PLAN.name())) { + return Objects.requireNonNull(contractPaymentPlanService).getPlanNameByIds(list); + } + + if (Strings.CI.equals(datasourceMultipleField.getDataSourceType(), FieldSourceType.BUSINESS_TITLE.name())) { + return Objects.requireNonNull(businessTitleService).getTitleNameByIds(list); + } + if (Strings.CI.equals(datasourceMultipleField.getDataSourceType(), FieldSourceType.ORDER.name())) { + return Objects.requireNonNull(orderService).getOrderNameByIds(list); + } + if (Strings.CI.equals(datasourceMultipleField.getDataSourceType(), FieldSourceType.CONTRACT.name())) { + return Objects.requireNonNull(contractService).getContractNameByIds(list); + } + + return Objects.requireNonNull(customFormDataService).getNameStrByIds(list); + } + + @Override + public Object textToValue(DatasourceMultipleField field, String text) { + if (StringUtils.isBlank(text) || Strings.CS.equals(text, EMPTY_ARRAY_STRING)) { + return StringUtils.EMPTY; + } + List names = parseFakeJsonArray(text); + + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.CUSTOMER.name())) { + List customerList = Objects.requireNonNull(customerService).getCustomerListByNames(names); + List ids = customerList.stream().map(Customer::getId).toList(); + return CollectionUtils.isEmpty(ids) ? names : ids; + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.OPPORTUNITY.name())) { + List opportunityList = Objects.requireNonNull(opportunityService).getOpportunityListByNames(names); + List ids = opportunityList.stream().map(Opportunity::getId).toList(); + return CollectionUtils.isEmpty(ids) ? names : ids; + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.CLUE.name())) { + List clueList = Objects.requireNonNull(clueService).getClueListByNames(names); + List ids = clueList.stream().map(Clue::getId).toList(); + return CollectionUtils.isEmpty(ids) ? names : ids; + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.CONTACT.name())) { + List contactList = Objects.requireNonNull(contactService).getContactListByNames(names); + List ids = contactList.stream().map(CustomerContact::getId).toList(); + return CollectionUtils.isEmpty(ids) ? names : ids; + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.PRODUCT.name())) { + List productList = Objects.requireNonNull(productService).getProductListByNames(names); + List ids = productList.stream().map(Product::getId).toList(); + return CollectionUtils.isEmpty(ids) ? names : ids; + } + + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.PRICE.name())) { + List prices = Objects.requireNonNull(productPriceService).getProductPriceListByNames(names); + List ids = prices.stream().map(ProductPrice::getId).toList(); + return CollectionUtils.isEmpty(ids) ? names : ids; + } + + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.QUOTATION.name())) { + List quotations = Objects.requireNonNull(opportunityQuotationService).getQuotationListByNames(names); + List ids = quotations.stream().map(OpportunityQuotation::getId).toList(); + return CollectionUtils.isEmpty(ids) ? names : ids; + } + + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.CONTRACT_PAYMENT_RECORD.name())) { + List records = Objects.requireNonNull(contractPaymentRecordService).getRecordListByNames(names); + List ids = records.stream().map(ContractPaymentRecord::getId).toList(); + return CollectionUtils.isEmpty(ids) ? names : ids; + } + + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.PAYMENT_PLAN.name())) { + List plans = Objects.requireNonNull(contractPaymentPlanService).getPlanListByNames(names); + List ids = plans.stream().map(ContractPaymentPlan::getId).toList(); + return CollectionUtils.isEmpty(ids) ? names : ids; + } + + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.BUSINESS_TITLE.name())) { + List titles = Objects.requireNonNull(businessTitleService).getBusinessTitleListByNames(names); + List ids = titles.stream().map(BusinessTitle::getId).toList(); + return CollectionUtils.isEmpty(ids) ? names : ids; + } + + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.ORDER.name())) { + List orders = Objects.requireNonNull(orderService).getOrderListByNames(names); + List ids = orders.stream().map(Order::getId).toList(); + return CollectionUtils.isEmpty(ids) ? names : ids; + } + + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.CONTRACT.name())) { + List contracts = Objects.requireNonNull(contractService).getContractListByNames(names); + List ids = contracts.stream().map(Contract::getId).toList(); + return CollectionUtils.isEmpty(ids) ? names : ids; + } + + List contracts = Objects.requireNonNull(customFormDataService).selectByNames(names); + List ids = contracts.stream().map(CustomFormData::getId).toList(); + return CollectionUtils.isEmpty(ids) ? names : ids; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/DatasourceResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/DatasourceResolver.java new file mode 100644 index 0000000..0fa79b9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/DatasourceResolver.java @@ -0,0 +1,193 @@ +package cn.cordys.common.resolver.field; + +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.crm.clue.domain.Clue; +import cn.cordys.crm.clue.service.ClueService; +import cn.cordys.crm.contract.domain.BusinessTitle; +import cn.cordys.crm.contract.domain.Contract; +import cn.cordys.crm.contract.domain.ContractPaymentPlan; +import cn.cordys.crm.contract.domain.ContractPaymentRecord; +import cn.cordys.crm.contract.service.BusinessTitleService; +import cn.cordys.crm.contract.service.ContractPaymentPlanService; +import cn.cordys.crm.contract.service.ContractPaymentRecordService; +import cn.cordys.crm.contract.service.ContractService; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.domain.CustomerContact; +import cn.cordys.crm.customer.service.CustomerContactService; +import cn.cordys.crm.customer.service.CustomerService; +import cn.cordys.crm.form.domain.CustomFormData; +import cn.cordys.crm.form.service.CustomFormDataService; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.opportunity.domain.OpportunityQuotation; +import cn.cordys.crm.opportunity.service.OpportunityQuotationService; +import cn.cordys.crm.opportunity.service.OpportunityService; +import cn.cordys.crm.order.domain.Order; +import cn.cordys.crm.order.service.OrderService; +import cn.cordys.crm.product.domain.Product; +import cn.cordys.crm.product.domain.ProductPrice; +import cn.cordys.crm.product.service.ProductPriceService; +import cn.cordys.crm.product.service.ProductService; +import cn.cordys.crm.system.constants.FieldSourceType; +import cn.cordys.crm.system.dto.field.DatasourceField; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.List; +import java.util.Objects; + +public class DatasourceResolver extends AbstractModuleFieldResolver { + + private static final CustomerService customerService; + private static final OpportunityService opportunityService; + private static final ClueService clueService; + private static final CustomerContactService contactService; + private static final ProductService productService; + private static final ProductPriceService productPriceService; + private static final OpportunityQuotationService opportunityQuotationService; + private static final ContractService contractService; + private static final ContractPaymentPlanService contractPaymentPlanService; + private static final ContractPaymentRecordService contractPaymentRecordService; + private static final BusinessTitleService businessTitleService; + private static final OrderService orderService; + private static final CustomFormDataService customFormDataService; + + static { + customerService = CommonBeanFactory.getBean(CustomerService.class); + opportunityService = CommonBeanFactory.getBean(OpportunityService.class); + clueService = CommonBeanFactory.getBean(ClueService.class); + contactService = CommonBeanFactory.getBean(CustomerContactService.class); + productService = CommonBeanFactory.getBean(ProductService.class); + productPriceService = CommonBeanFactory.getBean(ProductPriceService.class); + opportunityQuotationService = CommonBeanFactory.getBean(OpportunityQuotationService.class); + contractService = CommonBeanFactory.getBean(ContractService.class); + contractPaymentRecordService = CommonBeanFactory.getBean(ContractPaymentRecordService.class); + contractPaymentPlanService = CommonBeanFactory.getBean(ContractPaymentPlanService.class); + businessTitleService = CommonBeanFactory.getBean(BusinessTitleService.class); + orderService = CommonBeanFactory.getBean(OrderService.class); + customFormDataService = CommonBeanFactory.getBean(CustomFormDataService.class); + } + + @Override + public void validate(DatasourceField customField, Object value) { + + } + + + @Override + public Object transformToValue(DatasourceField datasourceField, String value) { + if (StringUtils.isBlank(value)) { + return StringUtils.EMPTY; + } + + if (Strings.CI.equals(datasourceField.getDataSourceType(), FieldSourceType.CUSTOMER.name())) { + return Objects.requireNonNull(customerService).getCustomerName(value); + } + + if (Strings.CI.equals(datasourceField.getDataSourceType(), FieldSourceType.CONTACT.name())) { + return Objects.requireNonNull(contactService).getContactName(value); + } + + if (Strings.CI.equals(datasourceField.getDataSourceType(), FieldSourceType.OPPORTUNITY.name())) { + return Objects.requireNonNull(opportunityService).getOpportunityName(value); + } + + if (Strings.CI.equals(datasourceField.getDataSourceType(), FieldSourceType.CLUE.name())) { + return Objects.requireNonNull(clueService).getClueName(value); + } + + if (Strings.CI.equals(datasourceField.getDataSourceType(), FieldSourceType.PRODUCT.name())) { + return Objects.requireNonNull(productService).getProductName(value); + } + + if (Strings.CI.equals(datasourceField.getDataSourceType(), FieldSourceType.PRICE.name())) { + return Objects.requireNonNull(productPriceService).getProductPriceName(value); + } + + if (Strings.CI.equals(datasourceField.getDataSourceType(), FieldSourceType.QUOTATION.name())) { + return Objects.requireNonNull(opportunityQuotationService).getQuotationName(value); + } + + if (Strings.CI.equals(datasourceField.getDataSourceType(), FieldSourceType.PAYMENT_PLAN.name())) { + return Objects.requireNonNull(contractPaymentPlanService).getPlanName(value); + } + + if (Strings.CI.equals(datasourceField.getDataSourceType(), FieldSourceType.CONTRACT_PAYMENT_RECORD.name())) { + return Objects.requireNonNull(contractPaymentRecordService).getRecordNameById(value); + } + + if (Strings.CI.equals(datasourceField.getDataSourceType(), FieldSourceType.BUSINESS_TITLE.name())) { + return Objects.requireNonNull(businessTitleService).getBusinessTitleName(value); + } + if (Strings.CI.equals(datasourceField.getDataSourceType(), FieldSourceType.ORDER.name())) { + return Objects.requireNonNull(orderService).getOrderName(value); + } + if (Strings.CI.equals(datasourceField.getDataSourceType(), FieldSourceType.CONTRACT.name())) { + return Objects.requireNonNull(contractService).getContractName(value); + } + + return Objects.requireNonNull(customFormDataService).getNameById(value); + } + + @Override + public Object textToValue(DatasourceField field, String text) { + if (StringUtils.isBlank(text)) { + return StringUtils.EMPTY; + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.CUSTOMER.name())) { + List customerList = Objects.requireNonNull(customerService).getCustomerListByNames(List.of(text)); + return CollectionUtils.isEmpty(customerList) ? StringUtils.EMPTY : customerList.getFirst().getId(); + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.OPPORTUNITY.name())) { + List opportunityList = Objects.requireNonNull(opportunityService).getOpportunityListByNames(List.of(text)); + return CollectionUtils.isEmpty(opportunityList) ? StringUtils.EMPTY : opportunityList.getFirst().getId(); + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.CLUE.name())) { + List clueList = Objects.requireNonNull(clueService).getClueListByNames(List.of(text)); + return CollectionUtils.isEmpty(clueList) ? StringUtils.EMPTY : clueList.getFirst().getId(); + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.CONTACT.name())) { + List contactList = Objects.requireNonNull(contactService).getContactListByNames(List.of(text)); + return CollectionUtils.isEmpty(contactList) ? StringUtils.EMPTY : contactList.getFirst().getId(); + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.PRODUCT.name())) { + List productList = Objects.requireNonNull(productService).getProductListByNames(List.of(text)); + return CollectionUtils.isEmpty(productList) ? StringUtils.EMPTY : productList.getFirst().getId(); + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.PRICE.name())) { + List productPrices = Objects.requireNonNull(productPriceService).getProductPriceListByNames(List.of(text)); + return CollectionUtils.isEmpty(productPrices) ? StringUtils.EMPTY : productPrices.getFirst().getId(); + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.QUOTATION.name())) { + List quotations = Objects.requireNonNull(opportunityQuotationService).getQuotationListByNames(List.of(text)); + return CollectionUtils.isEmpty(quotations) ? StringUtils.EMPTY : quotations.getFirst().getId(); + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.CONTRACT.name())) { + List contracts = Objects.requireNonNull(contractService).getContractListByNames(List.of(text)); + return CollectionUtils.isEmpty(contracts) ? StringUtils.EMPTY : contracts.getFirst().getId(); + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.PAYMENT_PLAN.name())) { + List plans = Objects.requireNonNull(contractPaymentPlanService).getPlanListByNames(List.of(text)); + return CollectionUtils.isEmpty(plans) ? StringUtils.EMPTY : plans.getFirst().getId(); + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.CONTRACT_PAYMENT_RECORD.name())) { + List records = Objects.requireNonNull(contractPaymentRecordService).getRecordListByNames(List.of(text)); + return CollectionUtils.isEmpty(records) ? StringUtils.EMPTY : records.getFirst().getId(); + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.BUSINESS_TITLE.name())) { + List businessTitles = Objects.requireNonNull(businessTitleService).getBusinessTitleListByNames(List.of(text)); + return CollectionUtils.isEmpty(businessTitles) ? StringUtils.EMPTY : businessTitles.getFirst().getId(); + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.ORDER.name())) { + List orders = Objects.requireNonNull(orderService).getOrderListByNames(List.of(text)); + return CollectionUtils.isEmpty(orders) ? StringUtils.EMPTY : orders.getFirst().getId(); + } + if (Strings.CI.equals(field.getDataSourceType(), FieldSourceType.CONTRACT.name())) { + List contracts = Objects.requireNonNull(contractService).getContractListByNames(List.of(text)); + return CollectionUtils.isEmpty(contracts) ? StringUtils.EMPTY : contracts.getFirst().getId(); + } + + List customFormDataList = Objects.requireNonNull(customFormDataService).selectByNames(List.of(text)); + return CollectionUtils.isEmpty(customFormDataList) ? StringUtils.EMPTY : customFormDataList.getFirst().getId(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/DateTimeResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/DateTimeResolver.java new file mode 100644 index 0000000..9237179 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/DateTimeResolver.java @@ -0,0 +1,81 @@ +package cn.cordys.common.resolver.field; + +import cn.cordys.common.util.TimeUtils; +import cn.cordys.crm.system.dto.field.DateTimeField; +import org.apache.commons.lang3.Strings; + +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.DateTimeParseException; +import java.time.temporal.TemporalAccessor; + +/** + * @author jianxing + */ +public class DateTimeResolver extends AbstractModuleFieldResolver { + + public static final String DATE = "date"; + public static final String DATETIME = "datetime"; + public static final String MONTH = "month"; + + @Override + public void validate(DateTimeField dateTimeField, Object value) { + validateRequired(dateTimeField, value); + + validateLong(dateTimeField.getName(), value); + } + + protected void validateLong(String name, Object value) { + if (value != null && !(value instanceof Long)) { + throwValidateException(name); + } + } + + @Override + public Object convertToValue(DateTimeField dateTimeField, String value) { + return parse2Long(value); + } + + @Override + public Object transformToValue(DateTimeField dateTimeField, String value) { + if (Strings.CI.equals(dateTimeField.getDateType(), DATE)) { + return TimeUtils.getDateStr(Long.valueOf(value)); + } + if (Strings.CI.equals(dateTimeField.getDateType(), DATETIME)) { + return TimeUtils.getDateTimeStr(Long.valueOf(value)); + } + if (Strings.CI.equals(dateTimeField.getDateType(), MONTH)) { + return TimeUtils.getMonthStr(Long.valueOf(value)); + } + return value; + } + + @Override + public Object textToValue(DateTimeField field, String text) { + DateTimeFormatter formatter = new DateTimeFormatterBuilder() + .appendOptional(DateTimeFormatter.ofPattern("yyyy-M-d H:m:s")) + .appendOptional(DateTimeFormatter.ofPattern("yyyy/M/d H:m:s")) + .appendOptional(DateTimeFormatter.ofPattern("yyyy-M-d H:m")) + .appendOptional(DateTimeFormatter.ofPattern("yyyy/M/d H:m")) + .appendOptional(DateTimeFormatter.ofPattern("yyyy-M-d")) + .appendOptional(DateTimeFormatter.ofPattern("yyyy/M/d")) + .appendOptional(DateTimeFormatter.ofPattern("yyyy-M")) + .appendOptional(DateTimeFormatter.ofPattern("yyyy/M")) + .toFormatter(); + + TemporalAccessor parsed = formatter.parseBest(text, + LocalDateTime::from, + LocalDate::from, + YearMonth::from); + + Instant instant = switch (parsed) { + case LocalDateTime localDateTime -> localDateTime.atZone(ZoneId.systemDefault()).toInstant(); + case LocalDate localDate -> localDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + case YearMonth yearMonth -> yearMonth.atDay(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + default -> throw new DateTimeParseException("无法解析日期时间: " + text, text, 0); + }; + + return instant.toEpochMilli(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/DefaultModuleFieldResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/DefaultModuleFieldResolver.java new file mode 100644 index 0000000..173638a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/DefaultModuleFieldResolver.java @@ -0,0 +1,25 @@ +package cn.cordys.common.resolver.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; + +import java.util.List; + +/** + * @Author: jianxing + * @CreateTime: 2025-03-06 16:07 + */ +public class DefaultModuleFieldResolver extends AbstractModuleFieldResolver { + @Override + public void validate(BaseField customField, Object value) { + // 校验必填 + validateRequired(customField, value); + } + + @Override + public String convertToString(BaseField selectField, Object value) { + if (value instanceof List) { + return getJsonString(value); + } + return getStringValue(value); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/DepartmentMultipleResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/DepartmentMultipleResolver.java new file mode 100644 index 0000000..a635171 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/DepartmentMultipleResolver.java @@ -0,0 +1,66 @@ +package cn.cordys.common.resolver.field; + +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.dto.field.DepartmentMultipleField; +import cn.cordys.crm.system.mapper.ExtDepartmentMapper; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.List; +import java.util.Objects; + +public class DepartmentMultipleResolver extends AbstractModuleFieldResolver { + + private static final ExtDepartmentMapper extDepartmentMapper; + + static { + extDepartmentMapper = CommonBeanFactory.getBean(ExtDepartmentMapper.class); + } + + @Override + public void validate(DepartmentMultipleField departmentField, Object value) { + validateRequired(departmentField, value); + validateArray(departmentField.getName(), value); + } + + @Override + public String convertToString(DepartmentMultipleField departmentField, Object value) { + return getJsonString(value); + } + + @Override + public Object convertToValue(DepartmentMultipleField departmentField, String value) { + return parse2Array(value); + } + + @Override + public Object transformToValue(DepartmentMultipleField departmentMultipleField, String value) { + if (StringUtils.isBlank(value) || Strings.CS.equals(value, "[]")) { + return StringUtils.EMPTY; + } + List ids = JSON.parseArray(value, String.class); + + List names = Objects.requireNonNull(extDepartmentMapper).getNameByIds(ids); + + if (CollectionUtils.isNotEmpty(names)) { + return String.join(",", JSON.parseArray(JSON.toJSONString(names), String.class)); + } + + return StringUtils.EMPTY; + } + + @Override + public Object textToValue(DepartmentMultipleField field, String text) { + if (StringUtils.isBlank(text) || Strings.CS.equals(text, "[]")) { + return StringUtils.EMPTY; + } + List names = parseFakeJsonArray(text); + List ids = Objects.requireNonNull(extDepartmentMapper).getIdsByNames(names); + if (CollectionUtils.isNotEmpty(ids)) { + return ids; + } + return names; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/DepartmentResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/DepartmentResolver.java new file mode 100644 index 0000000..52e84cc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/DepartmentResolver.java @@ -0,0 +1,63 @@ +package cn.cordys.common.resolver.field; + +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.dto.field.DepartmentField; +import cn.cordys.crm.system.mapper.ExtDepartmentMapper; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import java.util.List; +import java.util.Objects; + +public class DepartmentResolver extends AbstractModuleFieldResolver { + + private static final ExtDepartmentMapper extDepartmentMapper; + + static { + extDepartmentMapper = CommonBeanFactory.getBean(ExtDepartmentMapper.class); + } + + @Override + public void validate(DepartmentField departmentField, Object value) { + validateRequired(departmentField, value); + validateString(departmentField.getName(), value); + } + + @Override + public String convertToString(DepartmentField selectField, Object value) { + return getStringValue(value); + } + + @Override + public Object convertToValue(DepartmentField selectField, String value) { + return getStringValue(value); + } + + + @Override + public Object transformToValue(DepartmentField departmentField, String value) { + if (StringUtils.isBlank(value)) { + return StringUtils.EMPTY; + } + + List names = Objects.requireNonNull(extDepartmentMapper).getNameByIds(List.of(value)); + if (CollectionUtils.isNotEmpty(names)) { + return String.join(",", JSON.parseArray(JSON.toJSONString(names), String.class)); + } + + return StringUtils.EMPTY; + } + + @Override + public Object textToValue(DepartmentField field, String text) { + if (StringUtils.isBlank(text)) { + return StringUtils.EMPTY; + } + List ids = Objects.requireNonNull(extDepartmentMapper).getIdsByNames(List.of(text)); + if (CollectionUtils.isNotEmpty(ids)) { + return ids.getFirst(); + } + return text; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/FormulaResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/FormulaResolver.java new file mode 100644 index 0000000..bc698c8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/FormulaResolver.java @@ -0,0 +1,47 @@ +package cn.cordys.common.resolver.field; + +import cn.cordys.crm.system.dto.field.FormulaField; +import org.apache.poi.ss.formula.FormulaParseException; + +import java.math.BigDecimal; + +/** + * @author jianxing + */ +public class FormulaResolver extends AbstractModuleFieldResolver { + + @Override + public void validate(FormulaField numberField, Object value) { + validateRequired(numberField, value); + + if (value != null && !(value instanceof Number) && !(value instanceof String)) { + throwValidateException(numberField.getName()); + } + } + + @Override + public Object convertToValue(FormulaField numberField, String value) { + return transformToValue(numberField, value); + } + + @Override + public Object transformToValue(FormulaField numberField, String value) { + if (value == null) { + return null; + } + try { + return new BigDecimal(value); + } catch (Exception e) { + return value; + } + } + + @Override + public Object textToValue(FormulaField field, String text) { + try { + return new BigDecimal(text); + } catch (NumberFormatException e) { + throw new FormulaParseException("无法解析数值类型: " + text); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/IndustryResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/IndustryResolver.java new file mode 100644 index 0000000..e5c14c1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/IndustryResolver.java @@ -0,0 +1,25 @@ +package cn.cordys.common.resolver.field; + +import cn.cordys.common.utils.IndustryUtils; +import cn.cordys.crm.system.dto.field.IndustryField; + +/** + * @author song-cc-rock + */ +public class IndustryResolver extends AbstractModuleFieldResolver { + + @Override + public void validate(IndustryField customField, Object value) { + + } + + @Override + public Object transformToValue(IndustryField field, String value) { + return IndustryUtils.codeToParentLabel(value); + } + + @Override + public Object textToValue(IndustryField field, String text) { + return IndustryUtils.nameToCodeOnlyLeaf(text); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/LocationResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/LocationResolver.java new file mode 100644 index 0000000..4f06a73 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/LocationResolver.java @@ -0,0 +1,33 @@ +package cn.cordys.common.resolver.field; + +import cn.cordys.common.utils.RegionUtils; +import cn.cordys.crm.system.dto.field.LocationField; + +/** + * @author song-cc-rock + */ +public class LocationResolver extends AbstractModuleFieldResolver { + + public static final String PCD_D = "detail"; + public static final String PCD = "PCD"; + public static final String PC = "PC"; + public static final String P = "P"; + public static final String C = "C"; + public static final String CN = "CN"; + + @Override + public void validate(LocationField customField, Object value) { + + } + + + @Override + public Object transformToValue(LocationField locationField, String value) { + return RegionUtils.codeToName(value, locationField); + } + + @Override + public Object textToValue(LocationField field, String text) { + return RegionUtils.mapping(text, true); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/MemberMultipleResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/MemberMultipleResolver.java new file mode 100644 index 0000000..7eb97ad --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/MemberMultipleResolver.java @@ -0,0 +1,72 @@ +package cn.cordys.common.resolver.field; + + +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.dto.field.MemberMultipleField; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.List; +import java.util.Objects; + +/** + * @author jianxing + */ +public class MemberMultipleResolver extends AbstractModuleFieldResolver { + + private static final ExtUserMapper extUserMapper; + + static { + // 从 CommonBeanFactory 获取 ExtUserMapper 实例 + extUserMapper = CommonBeanFactory.getBean(ExtUserMapper.class); + } + + @Override + public void validate(MemberMultipleField memberField, Object value) { + validateRequired(memberField, value); + validateArray(memberField.getName(), value); + } + + @Override + public String convertToString(MemberMultipleField memberField, Object value) { + return getJsonString(value); + } + + @Override + public Object convertToValue(MemberMultipleField memberField, String value) { + return parse2Array(value); + } + + + @Override + public Object transformToValue(MemberMultipleField memberMultipleField, String value) { + if (StringUtils.isBlank(value) || Strings.CS.equals(value, "[]")) { + return StringUtils.EMPTY; + } + List ids = JSON.parseArray(value, String.class); + + List names = Objects.requireNonNull(extUserMapper).selectUserNameByIds(ids); + + if (CollectionUtils.isNotEmpty(names)) { + return String.join(",", JSON.parseArray(JSON.toJSONString(names), String.class)); + } + + return StringUtils.EMPTY; + } + + @Override + public Object textToValue(MemberMultipleField field, String text) { + if (StringUtils.isBlank(text) || Strings.CS.equals(text, "[]")) { + return StringUtils.EMPTY; + } + List names = parseFakeJsonArray(text); + List ids = Objects.requireNonNull(extUserMapper).selectUserIdsByNames(names); + if (CollectionUtils.isNotEmpty(ids)) { + return ids; + } + return names; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/MemberResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/MemberResolver.java new file mode 100644 index 0000000..a24407b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/MemberResolver.java @@ -0,0 +1,68 @@ +package cn.cordys.common.resolver.field; + + +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.dto.field.MemberField; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import java.util.List; +import java.util.Objects; + +/** + * @author jianxing + */ +public class MemberResolver extends AbstractModuleFieldResolver { + + private static final ExtUserMapper extUserMapper; + + static { + // 从 CommonBeanFactory 获取 ExtUserMapper 实例 + extUserMapper = CommonBeanFactory.getBean(ExtUserMapper.class); + } + + @Override + public void validate(MemberField memberField, Object value) { + validateRequired(memberField, value); + validateString(memberField.getName(), value); + } + + @Override + public String convertToString(MemberField memberField, Object value) { + return getStringValue(value); + } + + @Override + public Object convertToValue(MemberField memberField, String value) { + return getStringValue(value); + } + + @Override + public Object transformToValue(MemberField memberField, String value) { + if (StringUtils.isBlank(value)) { + return StringUtils.EMPTY; + } + + List names = Objects.requireNonNull(extUserMapper).selectUserNameByIds(List.of(value)); + + if (CollectionUtils.isNotEmpty(names)) { + return String.join(",", JSON.parseArray(JSON.toJSONString(names), String.class)); + } + + return StringUtils.EMPTY; + } + + @Override + public Object textToValue(MemberField field, String text) { + if (StringUtils.isBlank(text)) { + return StringUtils.EMPTY; + } + List ids = Objects.requireNonNull(extUserMapper).selectUserIdsByNames(List.of(text)); + if (CollectionUtils.isNotEmpty(ids)) { + return ids.getFirst(); + } + return text; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/ModuleFieldResolverFactory.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/ModuleFieldResolverFactory.java new file mode 100644 index 0000000..be72534 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/ModuleFieldResolverFactory.java @@ -0,0 +1,54 @@ +package cn.cordys.common.resolver.field; + +import cn.cordys.crm.system.constants.FieldType; + +import java.util.HashMap; + +/** + * @author jainxing + */ +public class ModuleFieldResolverFactory { + + private static final HashMap> resolverMap = new HashMap<>(); + + private static final DefaultModuleFieldResolver defaultModuleFieldResolver = new DefaultModuleFieldResolver(); + + static { + resolverMap.put(FieldType.RADIO.name(), new RadioResolver()); + + resolverMap.put(FieldType.CHECKBOX.name(), new CheckBoxResolver()); + + resolverMap.put(FieldType.INPUT.name(), new TextResolver()); + resolverMap.put(FieldType.TEXTAREA.name(), new TextResolver()); + + resolverMap.put(FieldType.INPUT_MULTIPLE.name(), new TextMultipleResolver()); + + resolverMap.put(FieldType.DATE_TIME.name(), new DateTimeResolver()); + + resolverMap.put(FieldType.INPUT_NUMBER.name(), new NumberResolver()); + resolverMap.put(FieldType.PICTURE.name(), new PictureResolver()); + + resolverMap.put(FieldType.SELECT.name(), new SelectResolver()); + resolverMap.put(FieldType.SELECT_MULTIPLE.name(), new SelectMultipleResolver()); + + resolverMap.put(FieldType.MEMBER.name(), new MemberResolver()); + resolverMap.put(FieldType.MEMBER_MULTIPLE.name(), new MemberMultipleResolver()); + + resolverMap.put(FieldType.DATA_SOURCE.name(), new DatasourceResolver()); + resolverMap.put(FieldType.DATA_SOURCE_MULTIPLE.name(), new DatasourceMultipleResolver()); + + resolverMap.put(FieldType.DEPARTMENT.name(), new DepartmentResolver()); + resolverMap.put(FieldType.DEPARTMENT_MULTIPLE.name(), new DepartmentMultipleResolver()); + + resolverMap.put(FieldType.LOCATION.name(), new LocationResolver()); + resolverMap.put(FieldType.INDUSTRY.name(), new IndustryResolver()); + resolverMap.put(FieldType.PHONE.name(), new PhoneResolver()); + resolverMap.put(FieldType.ATTACHMENT.name(), new AttachmentFieldResolver()); + resolverMap.put(FieldType.FORMULA.name(), new FormulaResolver()); + } + + public static AbstractModuleFieldResolver getResolver(String type) { + var moduleFieldResolver = resolverMap.get(type); + return moduleFieldResolver == null ? defaultModuleFieldResolver : moduleFieldResolver; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/NumberResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/NumberResolver.java new file mode 100644 index 0000000..c5961b0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/NumberResolver.java @@ -0,0 +1,86 @@ +package cn.cordys.common.resolver.field; + +import cn.cordys.crm.system.dto.field.InputNumberField; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.poi.ss.formula.FormulaParseException; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * @author jianxing + */ +public class NumberResolver extends AbstractModuleFieldResolver { + + public static final String PERCENT_FORMAT = "percent"; + public static final String PERCENT_SUFFIX = "%"; + + @Override + public void validate(InputNumberField numberField, Object value) { + validateRequired(numberField, value); + + if (value != null && !(value instanceof Number)) { + throwValidateException(numberField.getName()); + } + } + + @Override + public Object convertToValue(InputNumberField numberField, String value) { + return value == null ? null : new BigDecimal(value); + } + + @Override + public Object transformToValue(InputNumberField numberField, String value) { + BigDecimal actualDecimal = new BigDecimal(value).stripTrailingZeros(); + if (BooleanUtils.isTrue(numberField.getDecimalPlaces())) { + actualDecimal = actualDecimal.setScale(numberField.getPrecision(), RoundingMode.HALF_UP); + } + String formatActualVal; + if (BooleanUtils.isTrue(numberField.getShowThousandsSeparator())) { + formatActualVal = InputNumberField.formatThousands(actualDecimal); + } else { + formatActualVal = actualDecimal.toPlainString(); + } + return formatActualVal + (Strings.CI.equals(numberField.getNumberFormat(), "percent") ? "%" : ""); + } + + @Override + public Object textToValue(InputNumberField field, String text) { + if (Strings.CS.equals(field.getNumberFormat(), PERCENT_FORMAT)) { + text = text.replace(PERCENT_SUFFIX, StringUtils.EMPTY); + } + if (BooleanUtils.isTrue(field.getShowThousandsSeparator())) { + text = text.replace(",", StringUtils.EMPTY).replace(",", StringUtils.EMPTY); + } + try { + BigDecimal bd = new BigDecimal(text); + boolean illegal = checkIllegalDecimal(bd); + if (illegal) { + throw new FormulaParseException("数值超出范围, 整数不超过9位, 小数不超过4位;"); + } + return bd; + } catch (NumberFormatException e) { + throw new FormulaParseException("无法解析数值类型: " + text); + } + } + + /** + * 是否非法的数值范围 (整数不超过9位, 小数不超过4位) + * @param value 数值 + * @return 是否非法 + */ + private boolean checkIllegalDecimal(BigDecimal value) { + if (value == null) { + return false; + } + // 小数位 + int scale = value.scale(); + int precision = value.precision(); + // 整数位 + int integerDigits = precision - scale; + + return integerDigits > 9 || scale > 4; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/PhoneResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/PhoneResolver.java new file mode 100644 index 0000000..c9fd585 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/PhoneResolver.java @@ -0,0 +1,21 @@ +package cn.cordys.common.resolver.field; + +import cn.cordys.crm.system.dto.field.PhoneField; +import org.apache.commons.lang3.StringUtils; + +public class PhoneResolver extends AbstractModuleFieldResolver { + + @Override + public void validate(PhoneField field, Object value) { + validateRequired(field, value); + validateString(field.getName(), value); + } + + @Override + public Object textToValue(PhoneField field, String text) { + if (StringUtils.isEmpty(text)) { + return text; + } + return text.replaceAll("(", "(").replaceAll(")", ")").replaceAll("\\s+", ""); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/PictureResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/PictureResolver.java new file mode 100644 index 0000000..ddfac9f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/PictureResolver.java @@ -0,0 +1,20 @@ +package cn.cordys.common.resolver.field; + +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.dto.field.PictureField; + +/** + * @author song-cc-rock + */ +public class PictureResolver extends AbstractModuleFieldResolver { + + @Override + public void validate(PictureField customField, Object value) { + + } + + @Override + public Object convertToValue(PictureField selectField, String value) { + return JSON.parseArray(value, String.class); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/RadioResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/RadioResolver.java new file mode 100644 index 0000000..1b9a3ab --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/RadioResolver.java @@ -0,0 +1,54 @@ +package cn.cordys.common.resolver.field; + + +import cn.cordys.crm.system.dto.field.RadioField; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +/** + * @author jianxing + */ +public class RadioResolver extends AbstractModuleFieldResolver { + + @Override + public void validate(RadioField radioField, Object value) { + + // 校验必填 + validateRequired(radioField, value); + + // 校验值类型 + validateString(radioField.getName(), value); + + // 校验选项正确 + validateOptions(radioField.getName(), value, radioField.getOptions()); + } + + @Override + public String convertToString(RadioField radioField, Object value) { + return getStringValue(value); + } + + @Override + public Object convertToValue(RadioField selectField, String value) { + return super.convertToValue(selectField, value); + } + + @Override + public Object transformToValue(RadioField radioField, String value) { + return radioField.getOptions().stream() + .filter(option -> Strings.CI.equals(option.getValue(), value)) + .map(OptionProp::getLabel) + .findFirst() + .orElse(StringUtils.EMPTY); + } + + @Override + public Object textToValue(RadioField field, String text) { + return field.getOptions().stream() + .filter(option -> Strings.CI.equals(option.getLabel(), text)) + .map(OptionProp::getValue) + .findFirst() + .orElse(text); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/SelectMultipleResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/SelectMultipleResolver.java new file mode 100644 index 0000000..52fc8f1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/SelectMultipleResolver.java @@ -0,0 +1,89 @@ +package cn.cordys.common.resolver.field; + + +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.dto.field.SelectMultipleField; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * @author jianxing + */ +public class SelectMultipleResolver extends AbstractModuleFieldResolver { + + @Override + public void validate(SelectMultipleField selectField, Object value) { + // 校验必填 + validateRequired(selectField, value); + + // 校验值类型 + validateArray(selectField.getName(), value); + } + + @Override + public String convertToString(SelectMultipleField selectField, Object value) { + return getJsonString(value); + } + + @Override + public Object convertToValue(SelectMultipleField selectField, String value) { + return parse2Array(value); + } + + @Override + public Object transformToValue(SelectMultipleField selectMultipleField, String value) { + if (StringUtils.isBlank(value) || Strings.CS.equals(value, "[]")) { + return StringUtils.EMPTY; + } + + try { + List list = JSON.parseArray(value, String.class); + if (list == null || list.isEmpty()) { + return StringUtils.EMPTY; + } + + Map optionValueMap = selectMultipleField.getOptions().stream() + .collect(Collectors.toMap(OptionProp::getValue, OptionProp::getLabel, (a, b) -> a)); + + List result = list.stream() + .filter(item -> item != null && optionValueMap.containsKey(item)) + .map(optionValueMap::get) + .collect(Collectors.toList()); + + return String.join(",", result); + } catch (Exception e) { + return StringUtils.EMPTY; + } + } + + @Override + public Object textToValue(SelectMultipleField field, String text) { + if (StringUtils.isBlank(text) || Strings.CS.equals(text, "[]")) { + return StringUtils.EMPTY; + } + + try { + List texts = parseFakeJsonArray(text); + if (CollectionUtils.isEmpty(texts)) { + return StringUtils.EMPTY; + } + + Map optionMap = field.getOptions().stream() + .collect(Collectors.toMap(OptionProp::getLabel, OptionProp::getValue, (v1, v2) -> v1)); + List values = texts.stream() + .filter(item -> item != null && optionMap.containsKey(item)) + .map(optionMap::get) + .collect(Collectors.toList()); + + return CollectionUtils.isEmpty(values) ? texts : values; + } catch (Exception e) { + return StringUtils.EMPTY; + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/SelectResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/SelectResolver.java new file mode 100644 index 0000000..9506db9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/SelectResolver.java @@ -0,0 +1,54 @@ +package cn.cordys.common.resolver.field; + + +import cn.cordys.crm.system.dto.field.SelectField; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +/** + * @author jianxing + */ +public class SelectResolver extends AbstractModuleFieldResolver { + + @Override + public void validate(SelectField selectField, Object value) { + + // 校验必填 + validateRequired(selectField, value); + + // 校验值类型 + validateString(selectField.getName(), value); + + // 校验选项正确 + validateOptions(selectField.getName(), value, selectField.getOptions()); + } + + @Override + public String convertToString(SelectField selectField, Object value) { + return getStringValue(value); + } + + @Override + public Object convertToValue(SelectField selectField, String value) { + return super.convertToValue(selectField, value); + } + + @Override + public Object transformToValue(SelectField selectField, String value) { + return selectField.getOptions().stream() + .filter(option -> Strings.CI.equals(option.getValue(), value)) + .findFirst() + .map(OptionProp::getLabel) + .orElse(StringUtils.EMPTY); + } + + @Override + public Object textToValue(SelectField field, String text) { + return field.getOptions().stream() + .filter(option -> Strings.CI.equals(option.getLabel(), text)) + .findFirst() + .map(OptionProp::getValue) + .orElse(text); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/TextMultipleResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/TextMultipleResolver.java new file mode 100644 index 0000000..5c52949 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/TextMultipleResolver.java @@ -0,0 +1,65 @@ +package cn.cordys.common.resolver.field; + + +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.dto.field.InputMultipleField; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.List; + +public class TextMultipleResolver extends AbstractModuleFieldResolver { + + public static final String ARR_SQUARE = "[]"; + + @Override + public void validate(InputMultipleField inputMultipleField, Object value) { + validateRequired(inputMultipleField, value); + validateArray(inputMultipleField.getName(), value); + } + + @Override + public String convertToString(InputMultipleField inputMultipleField, Object value) { + return getJsonString(value); + } + + @Override + public Object convertToValue(InputMultipleField inputMultipleField, String value) { + return parse2Array(value); + } + + @Override + @SuppressWarnings("unchecked") + public Object transformToValue(InputMultipleField inputMultipleField, String value) { + if (StringUtils.isEmpty(value)) { + return StringUtils.EMPTY; + } + return String.join(",", JSON.parseArray(value)); + } + + @Override + public Object textToValue(InputMultipleField field, String text) { + if (StringUtils.isBlank(text) || Strings.CS.equals(text, ARR_SQUARE)) { + return null; + } + List textList = parseFakeJsonArray(text); + List correctTags = getCorrectFormatInput(textList); + return CollectionUtils.isEmpty(correctTags) ? null : correctTags; + } + + /** + * 要求标签最多10个, 且每个标签长度不超过64个字符 + * @param textList 标签列表 + * @return 符合要求的标签集合 + */ + public List getCorrectFormatInput(List textList) { + return textList.stream() + .map(String::trim) + .filter(StringUtils::isNotBlank) + .filter(text -> text.length() <= 64) + .distinct() + .limit(10) + .toList(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/resolver/field/TextResolver.java b/backend/crm/src/main/java/cn/cordys/common/resolver/field/TextResolver.java new file mode 100644 index 0000000..3435514 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/resolver/field/TextResolver.java @@ -0,0 +1,31 @@ +package cn.cordys.common.resolver.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import org.apache.commons.lang3.StringUtils; + +/** + * @author jainxing + */ +public class TextResolver extends AbstractModuleFieldResolver { + + @Override + public void validate(BaseField baseField, Object value) { + validateRequired(baseField, value); + + if (value != null && !(value instanceof Number) && !(value instanceof String)) { + throwValidateException(baseField.getName()); + } + } + + /** + * 要求文本长度不超过255个字符 + * @param text 文本 + * @return 符合要求的文本 + */ + public String getCorrectInputString(String text) { + if (StringUtils.isEmpty(text)) { + return null; + } + return text.length() > 255 ? text.substring(0, 255) : text; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/schedule/BaseScheduleJob.java b/backend/crm/src/main/java/cn/cordys/common/schedule/BaseScheduleJob.java new file mode 100644 index 0000000..d5661f4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/schedule/BaseScheduleJob.java @@ -0,0 +1,64 @@ +package cn.cordys.common.schedule; + + +import lombok.extern.slf4j.Slf4j; +import org.quartz.Job; +import org.quartz.JobDataMap; +import org.quartz.JobExecutionContext; +import org.quartz.JobKey; + +/** + * 基础调度任务类,所有调度任务都应该继承此类,并实现具体的业务逻辑。 + *

+ * 本类提供了调度任务执行所需的资源信息,并通过抽象方法 {@link #businessExecute(JobExecutionContext)} + * 让子类实现具体的业务逻辑。 + *

+ * + * @since 1.0 + */ +@Slf4j +public abstract class BaseScheduleJob implements Job { + + /** + * 资源 ID,表示该任务所关联的资源。 + */ + protected String resourceId; + + /** + * 用户 ID,表示该任务执行的用户。 + */ + protected String userId; + + /** + * 调度表达式,用于任务的调度规则。 + */ + protected String expression; + + /** + * 执行调度任务时调用,提取任务所需的信息并调用子类的业务执行方法。 + * + * @param context 任务执行的上下文对象 + */ + @Override + public void execute(JobExecutionContext context) { + // 从 JobDataMap 中获取任务所需的资源信息 + JobKey jobKey = context.getTrigger().getJobKey(); + JobDataMap jobDataMap = context.getJobDetail().getJobDataMap(); + this.resourceId = jobDataMap.getString("resourceId"); + this.userId = jobDataMap.getString("userId"); + this.expression = jobDataMap.getString("expression"); + + // 记录日志,显示当前任务的执行情况 + log.info(jobKey.getGroup() + " Running: " + resourceId); + + // 调用子类实现的业务逻辑 + businessExecute(context); + } + + /** + * 子类需要实现该方法,定义任务执行的具体业务逻辑。 + * + * @param context 任务执行的上下文对象 + */ + protected abstract void businessExecute(JobExecutionContext context); +} diff --git a/backend/crm/src/main/java/cn/cordys/common/schedule/ScheduleManager.java b/backend/crm/src/main/java/cn/cordys/common/schedule/ScheduleManager.java new file mode 100644 index 0000000..9104e7a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/schedule/ScheduleManager.java @@ -0,0 +1,230 @@ +package cn.cordys.common.schedule; + +import cn.cordys.common.exception.GenericException; +import cn.cordys.crm.system.domain.Schedule; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.quartz.*; + +/** + * 定时任务管理器,用于管理调度任务的添加、修改、删除等操作。 + * 提供了对简单任务和 Cron 表达式任务的支持。 + *

+ * 主要功能包括: + *

    + *
  • 添加和删除定时任务
  • + *
  • 修改 Cron 表达式
  • + *
  • 启动和关闭调度器
  • + *
+ *

+ * + * @since 1.0 + */ +@Slf4j +public class ScheduleManager { + + @Resource + private Scheduler scheduler; + + /** + * 启动调度器。 + * + * @param schedule 调度器 + */ + public static void startJobs(Scheduler schedule) { + try { + schedule.start(); + } catch (Exception e) { + log.error("启动调度器失败", e); + throw new RuntimeException("启动调度器失败", e); + } + } + + /** + * 添加一个简单的定时任务。 + * + * @param jobKey 任务标识 + * @param triggerKey 触发器标识 + * @param cls 任务类 + * @param repeatIntervalTime 任务重复间隔时间(单位:小时) + * @param jobDataMap 任务数据 + * + * @throws SchedulerException 如果调度失败 + */ + public void addSimpleJob(JobKey jobKey, TriggerKey triggerKey, Class cls, int repeatIntervalTime, JobDataMap jobDataMap) + throws SchedulerException { + + JobBuilder jobBuilder = JobBuilder.newJob(cls).withIdentity(jobKey); + if (jobDataMap != null) { + jobBuilder.usingJobData(jobDataMap); + } + + SimpleTrigger trigger = TriggerBuilder.newTrigger().withIdentity(triggerKey) + .withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInHours(repeatIntervalTime).repeatForever()) + .startNow().build(); + + scheduler.scheduleJob(jobBuilder.build(), trigger); + } + + /** + * 添加一个 Cron 表达式定时任务。 + * + * @param jobKey 任务标识 + * @param triggerKey 触发器标识 + * @param jobClass 任务类 + * @param cron Cron 表达式 + * @param jobDataMap 任务数据 + */ + public void addCronJob(JobKey jobKey, TriggerKey triggerKey, Class jobClass, String cron, JobDataMap jobDataMap) { + try { + log.info("addCronJob: {},{}", triggerKey.getName(), triggerKey.getGroup()); + JobBuilder jobBuilder = JobBuilder.newJob(jobClass).withIdentity(jobKey); + if (jobDataMap != null) { + jobBuilder.usingJobData(jobDataMap); + } + + TriggerBuilder triggerBuilder = TriggerBuilder.newTrigger(); + triggerBuilder.withIdentity(triggerKey); + triggerBuilder.startNow(); + triggerBuilder.withSchedule(CronScheduleBuilder.cronSchedule(cron)); + CronTrigger trigger = (CronTrigger) triggerBuilder.build(); + scheduler.scheduleJob(jobBuilder.build(), trigger); + + } catch (Exception e) { + log.error(e.getMessage(), e); + throw new GenericException("定时任务配置异常: " + e.getMessage(), e); + } + } + + /** + * 添加一个 Cron 表达式定时任务(不带 JobDataMap)。 + * + * @param jobKey 任务标识 + * @param triggerKey 触发器标识 + * @param jobClass 任务类 + * @param cron Cron 表达式 + */ + public void addCronJob(JobKey jobKey, TriggerKey triggerKey, Class jobClass, String cron) { + addCronJob(jobKey, triggerKey, jobClass, cron, null); + } + + /** + * 修改现有的 Cron 触发器的 Cron 表达式。 + * + * @param triggerKey 触发器标识 + * @param cron 新的 Cron 表达式 + */ + public void modifyCronJobTime(TriggerKey triggerKey, String cron) { + + log.info("modifyCronJobTime: {}", triggerKey.getName() + "," + triggerKey.getGroup()); + try { + CronTrigger trigger = (CronTrigger) scheduler.getTrigger(triggerKey); + if (trigger == null) { + return; + } + + String oldTime = trigger.getCronExpression(); + if (!oldTime.equalsIgnoreCase(cron)) { + // 修改触发器的 Cron 表达式 + TriggerBuilder triggerBuilder = TriggerBuilder.newTrigger(); + triggerBuilder.withIdentity(triggerKey); + triggerBuilder.startNow(); + triggerBuilder.withSchedule(CronScheduleBuilder.cronSchedule(cron)); + trigger = (CronTrigger) triggerBuilder.build(); + scheduler.rescheduleJob(triggerKey, trigger); + } + } catch (Exception e) { + throw new RuntimeException("修改 Cron 表达式失败", e); + } + } + + /** + * 删除指定的任务和触发器。 + * + * @param jobKey 任务标识 + * @param triggerKey 触发器标识 + */ + public void removeJob(JobKey jobKey, TriggerKey triggerKey) { + try { + log.info("RemoveJob: {},{}", jobKey.getName(), jobKey.getGroup()); + scheduler.pauseTrigger(triggerKey); + scheduler.unscheduleJob(triggerKey); + scheduler.deleteJob(jobKey); + } catch (Exception e) { + log.error("删除任务失败", e); + throw new RuntimeException("删除任务失败", e); + } + } + + /** + * 关闭调度器。 + * + * @param schedule 调度器 + */ + public void shutdownJobs(Scheduler schedule) { + try { + if (!schedule.isShutdown()) { + schedule.shutdown(); + } + } catch (Exception e) { + log.error("关闭调度器失败", e); + throw new RuntimeException("关闭调度器失败", e); + } + } + + /** + * 添加或更新 Cron 定时任务。 + * 如果触发器已存在,则修改其 Cron 表达式;否则,添加新的 Cron 定时任务。 + * + * @param jobKey 任务标识 + * @param triggerKey 触发器标识 + * @param jobClass 任务类 + * @param cron Cron 表达式 + * @param jobDataMap 任务数据 + * + * @throws SchedulerException 如果添加或更新任务失败 + */ + public void addOrUpdateCronJob(JobKey jobKey, TriggerKey triggerKey, Class jobClass, String cron, JobDataMap jobDataMap) + throws SchedulerException { + log.info("AddOrUpdateCronJob: {}", jobKey.getName() + "," + triggerKey.getGroup()); + + if (scheduler.checkExists(triggerKey)) { + modifyCronJobTime(triggerKey, cron); + } else { + addCronJob(jobKey, triggerKey, jobClass, cron, jobDataMap); + } + } + + /** + * 添加或更新 Cron 定时任务(不带 JobDataMap)。 + * + * @param jobKey 任务标识 + * @param triggerKey 触发器标识 + * @param jobClass 任务类 + * @param cron Cron 表达式 + * + * @throws SchedulerException 如果添加或更新任务失败 + */ + public void addOrUpdateCronJob(JobKey jobKey, TriggerKey triggerKey, Class jobClass, String cron) throws SchedulerException { + addOrUpdateCronJob(jobKey, triggerKey, jobClass, cron, null); + } + + /** + * 获取默认的 JobDataMap,包含定时任务所需的基本信息。 + * + * @param schedule 定时任务调度对象 + * @param expression Cron 或时间表达式 + * @param userId 执行任务的用户 ID + * + * @return JobDataMap 对象 + */ + public JobDataMap getDefaultJobDataMap(Schedule schedule, String expression, String userId) { + JobDataMap jobDataMap = new JobDataMap(); + jobDataMap.put("resourceId", schedule.getResourceId()); + jobDataMap.put("expression", expression); + jobDataMap.put("userId", userId); + jobDataMap.put("config", schedule.getConfig()); + jobDataMap.put("organizationId", schedule.getOrganizationId()); + return jobDataMap; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/schedule/ScheduleService.java b/backend/crm/src/main/java/cn/cordys/common/schedule/ScheduleService.java new file mode 100644 index 0000000..a0f191b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/schedule/ScheduleService.java @@ -0,0 +1,118 @@ +package cn.cordys.common.schedule; + +import cn.cordys.common.constants.ApplicationNumScope; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.uid.NumGenerator; +import cn.cordys.crm.system.domain.Schedule; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.quartz.JobKey; +import org.quartz.SchedulerException; +import org.quartz.TriggerKey; +import org.springframework.transaction.annotation.Transactional; + +/** + * 定时任务服务类,负责调度任务的创建、编辑、删除及相关操作。 + * 主要功能包括定时任务的增、删、查及其与调度器的交互。 + * + * @since 1.0 + */ +@Transactional(rollbackFor = Exception.class) +public class ScheduleService { + + @Resource + private BaseMapper scheduleMapper; + + @Resource + private ScheduleManager scheduleManager; + + /** + * 添加新的定时任务。 + * + * @param schedule 定时任务对象 + */ + public void addSchedule(Schedule schedule) { + schedule.setId(IDGenerator.nextStr()); + schedule.setCreateTime(System.currentTimeMillis()); + schedule.setUpdateTime(System.currentTimeMillis()); + schedule.setNum(getNextNum(schedule.getOrganizationId())); + scheduleMapper.insert(schedule); + } + + /** + * 获取下一个任务编号。 + * + * @param organizationId 项目 ID + * + * @return 下一个任务编号 + */ + public long getNextNum(String organizationId) { + return NumGenerator.nextNum(organizationId, ApplicationNumScope.TASK); + } + + /** + * 根据任务 ID 获取定时任务。 + * + * @param scheduleId 定时任务 ID + * + * @return 定时任务对象 + */ + public Schedule getSchedule(String scheduleId) { + return scheduleMapper.selectByPrimaryKey(scheduleId); + } + + /** + * 编辑定时任务信息。 + * + * @param schedule 要更新的定时任务对象 + * + * @return 更新的记录数 + */ + public int editSchedule(Schedule schedule) { + schedule.setUpdateTime(System.currentTimeMillis()); + return scheduleMapper.update(schedule); + } + + /** + * 根据任务标识删除调度器中的任务。 + * + * @param key 任务标识 + * @param job 任务名 + */ + private void removeJob(String key, String job) { + scheduleManager.removeJob(new JobKey(key, job), new TriggerKey(key, job)); + } + + /** + * 添加或更新 Cron 表达式定时任务。 + * 如果定时任务启用且 Cron 表达式有效,则添加或更新任务;否则,移除任务。 + * + * @param request 定时任务请求对象 + * @param jobKey 任务标识 + * @param triggerKey 触发器标识 + * @param clazz 任务类 + */ + public void addOrUpdateCronJob(Schedule request, JobKey jobKey, TriggerKey triggerKey, Class clazz) { + Boolean enable = request.getEnable(); + String cronExpression = request.getValue(); + if (BooleanUtils.isTrue(enable) && StringUtils.isNotBlank(cronExpression)) { + try { + // 添加或更新 Cron 表达式定时任务 + scheduleManager.addOrUpdateCronJob(jobKey, triggerKey, clazz, cronExpression, + scheduleManager.getDefaultJobDataMap(request, cronExpression, request.getCreateUser())); + } catch (SchedulerException e) { + throw new GenericException("定时任务开启异常: " + e.getMessage()); + } + } else { + try { + // 移除定时任务 + scheduleManager.removeJob(jobKey, triggerKey); + } catch (Exception e) { + throw new GenericException("定时任务关闭异常: " + e.getMessage()); + } + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/security/ApiKeyFilter.java b/backend/crm/src/main/java/cn/cordys/common/security/ApiKeyFilter.java new file mode 100644 index 0000000..0b3f821 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/security/ApiKeyFilter.java @@ -0,0 +1,76 @@ +package cn.cordys.common.security; + +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.security.SessionConstants; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.StringUtils; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.authc.UsernamePasswordToken; +import org.apache.shiro.web.filter.authc.AnonymousFilter; +import org.apache.shiro.web.util.WebUtils; + +/** + * 自定义过滤器,用于处理 Web 应用中的 API 密钥认证。 + * 继承 AnonymousFilter 支持 API 密钥认证和常规会话认证。 + */ +public class ApiKeyFilter extends AnonymousFilter { + + private static final String NO_PASSWORD = "no_pass"; // 默认的密码,用于 API 密钥认证 + + /** + * 在处理请求之前调用。该方法检查请求是否使用 API 密钥。 + * 如果没有 API 密钥且用户未认证,则允许请求通过。 + * 如果提供了 API 密钥,则使用该密钥尝试认证用户。 + * + * @param request Servlet 请求 + * @param response Servlet 响应 + * @param mappedValue 过滤器链中的映射值 + * + * @return 如果请求应该继续处理返回 true,否则返回 false + */ + @Override + protected boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) { + HttpServletRequest httpRequest = WebUtils.toHttp(request); + + // 如果不是 API 密钥请求且用户未认证,允许请求继续: + if (!ApiKeyHandler.isApiKeyCall(httpRequest) && !SecurityUtils.getSubject().isAuthenticated()) { + return true; + } + + // 处理 API 密钥认证 + if (!SecurityUtils.getSubject().isAuthenticated()) { + String userId = CommonBeanFactory.getUser(httpRequest); + if (StringUtils.isNotBlank(userId)) { + // 使用 API 密钥中的用户 ID 进行认证,密码设置为默认值 + SecurityUtils.getSubject().login(new UsernamePasswordToken(userId, NO_PASSWORD)); + } + } + + // 如果仍未认证,设置响应头为无效状态 + if (!SecurityUtils.getSubject().isAuthenticated()) { + ((HttpServletResponse) response).setHeader(SessionConstants.AUTHENTICATION_STATUS, "invalid"); + } + + return true; + } + + /** + * 在请求处理之后调用。此方法用于处理 API 密钥退出逻辑。 + * 如果是 API 密钥请求并且用户已认证,则注销当前用户。 + * + * @param request Servlet 请求 + * @param response Servlet 响应 + */ + @Override + protected void postHandle(ServletRequest request, ServletResponse response) { + HttpServletRequest httpRequest = WebUtils.toHttp(request); + + // 如果是 API 密钥请求且用户已认证,则注销用户 + if (ApiKeyHandler.isApiKeyCall(httpRequest) && SecurityUtils.getSubject().isAuthenticated()) { + SecurityUtils.getSubject().logout(); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/security/ApiKeyHandler.java b/backend/crm/src/main/java/cn/cordys/common/security/ApiKeyHandler.java new file mode 100644 index 0000000..f6dd736 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/security/ApiKeyHandler.java @@ -0,0 +1,27 @@ +package cn.cordys.common.security; + +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.lang3.StringUtils; + +/** + * 处理 API 密钥验证的工具类,包括获取用户、验证请求是否包含 API 密钥以及验证签名的功能。 + */ +public class ApiKeyHandler { + + public static final String AUTHORIZATION = "Authorization"; // 授权字段 + + /** + * 判断请求是否包含有效的 API 密钥和签名。 + * + * @param request HTTP 请求 + * + * @return 如果请求包含有效的 API 密钥和签名,返回 true;否则返回 false + */ + public static Boolean isApiKeyCall(HttpServletRequest request) { + if (request == null) { + return false; + } + String authorization = request.getHeader(AUTHORIZATION); + return !StringUtils.isBlank(authorization) && authorization.split(":").length >= 2; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/security/AuthFilter.java b/backend/crm/src/main/java/cn/cordys/common/security/AuthFilter.java new file mode 100644 index 0000000..3b1f8df --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/security/AuthFilter.java @@ -0,0 +1,42 @@ +package cn.cordys.common.security; + +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.shiro.web.filter.authc.FormAuthenticationFilter; + +/** + * @Author: jianxing + * @CreateTime: 2025-04-23 10:47 + */ +public class AuthFilter extends FormAuthenticationFilter { + + + /** + * 重写 onAccessDenied 方法,避免认证失败返回 302 重定向 + * 没有认证返回 401 状态码 + * + * @param request + * @param response + * + * @return + * + * @throws Exception + */ + @Override + protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { + if (isLoginRequest(request, response)) { + if (isLoginSubmission(request, response)) { + return executeLogin(request, response); + } else { + return true; + } + } else { + // 没有登入返回 401 + HttpServletResponse httpResponse = (HttpServletResponse) response; + httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + httpResponse.setContentType("application/json;charset=UTF-8"); + return false; + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/common/security/CsrfFilter.java b/backend/crm/src/main/java/cn/cordys/common/security/CsrfFilter.java new file mode 100644 index 0000000..a033893 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/security/CsrfFilter.java @@ -0,0 +1,138 @@ +package cn.cordys.common.security; + +import cn.cordys.common.util.CodingUtils; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.security.SessionConstants; +import cn.cordys.security.SessionUser; +import cn.cordys.security.SessionUtils; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.web.filter.authc.AnonymousFilter; +import org.apache.shiro.web.util.WebUtils; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpHeaders; + +/** + * 自定义过滤器,用于处理 CSRF 校验。 + * 该过滤器确保只有认证用户才能访问非公开资源,并对请求中的 CSRF token 和 Referer 进行校验。 + */ +public class CsrfFilter extends AnonymousFilter { + + /** + * 在处理请求之前进行 CSRF 校验。 + * 如果请求没有通过认证或请求头中包含有效的 CSRF token,则允许请求继续。 + * 否则,抛出相应的异常或返回无效的认证状态。 + * + * @param request Servlet 请求 + * @param response Servlet 响应 + * @param mappedValue 过滤器链中的映射值 + * + * @return true 如果请求继续处理,false 如果请求被中断 + */ + @Override + protected boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) { + HttpServletRequest httpServletRequest = WebUtils.toHttp(request); + + // 如果用户未认证,返回认证无效状态 + if (!SecurityUtils.getSubject().isAuthenticated()) { + ((HttpServletResponse) response).setHeader(SessionConstants.AUTHENTICATION_STATUS, SessionConstants.AUTHENTICATION_INVALID); + return true; + } + + // 错误页面无需 CSRF 校验 + if (httpServletRequest.getRequestURI().equals("/error")) { + return true; + } + + // API 请求无需 CSRF 校验 + if (ApiKeyHandler.isApiKeyCall(httpServletRequest)) { + return true; + } + + // WebSocket 请求无需 CSRF 校验 + String websocketKey = httpServletRequest.getHeader("Sec-WebSocket-Key"); + if (StringUtils.isNotBlank(websocketKey)) { + return true; + } + + // 允许 Swagger UI 访问,无需 CSRF 校验 + if (Strings.CS.startsWithAny(httpServletRequest.getRequestURI(), "/swagger-ui", "/v3/api-docs")) { + return true; + } + + // 获取请求头中的 CSRF token 和 X-Auth-Token + String csrfToken = httpServletRequest.getHeader(SessionConstants.CSRF_TOKEN); + String xAuthToken = httpServletRequest.getHeader(SessionConstants.HEADER_TOKEN); + + // 校验 CSRF token 和 X-Auth-Token + validateToken(csrfToken, xAuthToken); + + // 校验 Referer + validateReferer(httpServletRequest); + + return true; + } + + /** + * 校验请求中的 Referer 是否符合配置的域名要求。 + * 如果没有配置 Referer 域名,默认不进行校验。 + * + * @param request HttpServletRequest 请求 + */ + private void validateReferer(HttpServletRequest request) { + Environment env = CommonBeanFactory.getBean(Environment.class); + assert env != null; + String domains = env.getProperty("referer.urls"); + + // 如果没有配置 referer.urls,则不校验 + if (StringUtils.isBlank(domains)) { + return; + } + + String[] allowedDomains = StringUtils.split(domains, ","); + String referer = request.getHeader(HttpHeaders.REFERER); + + // 校验 Referer 是否在允许的域名列表中 + if (allowedDomains != null && !ArrayUtils.contains(allowedDomains, referer)) { + throw new RuntimeException("CSRF error: invalid referer"); + } + } + + /** + * 校验请求中的 CSRF token 和 X-Auth-Token 是否有效。 + * 如果 token 无效,抛出异常。 + * + * @param csrfToken CSRF token + * @param xAuthToken X-Auth-Token + */ + private void validateToken(String csrfToken, String xAuthToken) { + if (StringUtils.isBlank(csrfToken)) { + throw new RuntimeException("CSRF token is empty"); + } + + // 解密 CSRF token + csrfToken = CodingUtils.aesDecrypt(csrfToken, SessionUser.secret, CodingUtils.generateIv()); + + String[] signatureArray = StringUtils.split(StringUtils.trimToNull(csrfToken), "|"); + if (signatureArray.length != 4) { + throw new RuntimeException("Invalid CSRF token format"); + } + + // 校验用户 ID 和会话 ID 是否匹配 + if (!Strings.CS.equals(SessionUtils.getUserId(), signatureArray[0])) { + throw new RuntimeException("CSRF token does not match the current user"); + } + + // 校验 sessionId 或 X-Auth-Token 是否匹配 + if (!Strings.CS.equals(SessionUtils.getSessionId(), signatureArray[2]) && + !Strings.CS.equals(xAuthToken, signatureArray[2])) { + throw new RuntimeException("CSRF token does not match the current session"); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/security/realm/LocalRealm.java b/backend/crm/src/main/java/cn/cordys/common/security/realm/LocalRealm.java new file mode 100644 index 0000000..10e84b5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/security/realm/LocalRealm.java @@ -0,0 +1,105 @@ +package cn.cordys.common.security.realm; + + +import cn.cordys.common.constants.UserSource; +import cn.cordys.common.permission.PermissionUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.service.UserLoginService; +import cn.cordys.security.SessionConstants; +import cn.cordys.security.SessionUser; +import cn.cordys.security.SessionUtils; +import cn.cordys.security.UserDTO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.Strings; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.authc.*; +import org.apache.shiro.authz.AuthorizationInfo; +import org.apache.shiro.realm.AuthorizingRealm; +import org.apache.shiro.session.Session; +import org.apache.shiro.subject.PrincipalCollection; +import org.springframework.context.annotation.Lazy; + + +/** + * 自定义Realm 注入service 可能会导致在 service的aop 失效,例如@Transactional, + * 解决方法: + *

+ * 1. 这里改成注入mapper,这样mapper 中的事务失效
+ * 2. 这里仍然注入service,在配置ShiroConfig 的时候不去set realm, 等到spring 初始化完成之后 + * set realm + *

+ */ +@Slf4j +public class LocalRealm extends AuthorizingRealm { + @Resource + @Lazy + private UserLoginService userLoginService; + + @Override + public String getName() { + return "LOCAL"; + } + + /** + * 角色认证 + */ + @Override + protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { + return null; + } + + /** + * 登录认证 + */ + @Override + protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { + UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken; + Session session = SecurityUtils.getSubject().getSession(); + String login = (String) session.getAttribute("authenticate"); + + String userId = token.getUsername(); + String password = String.valueOf(token.getPassword()); + + if (Strings.CS.equals(login, UserSource.LOCAL.name())) { + return loginLocalMode(userId, password); + } + + UserDTO user = getUserWithOutAuthenticate(userId); + userId = user.getId(); + SessionUser sessionUser = SessionUser.fromUser(user, (String) session.getId()); + session.setAttribute(SessionConstants.ATTR_USER, sessionUser); + return new SimpleAuthenticationInfo(userId, password, getName()); + + } + + @Override + public boolean isPermitted(PrincipalCollection principals, String permission) { + return PermissionUtils.hasPermission(permission); + } + + private UserDTO getUserWithOutAuthenticate(String userId) { + UserDTO user = userLoginService.authenticateUser(userId); + if (user == null) { + log.info("The user does not exist: {}", userId); + throw new UnknownAccountException(Translator.get("password_is_incorrect")); + } + return user; + } + + private AuthenticationInfo loginLocalMode(String userId, String password) { + UserDTO user = userLoginService.authenticateUser(userId); + if (user == null) { + log.warn("The user does not exist: {}", userId); + throw new UnknownAccountException(Translator.get("password_is_incorrect")); + } + // 密码验证 + if (!userLoginService.checkUserPassword(user.getId(), password)) { + throw new IncorrectCredentialsException(Translator.get("password_is_incorrect")); + } + SessionUser sessionUser = SessionUser.fromUser(user, SessionUtils.getSessionId()); + SessionUtils.putUser(sessionUser); + return new SimpleAuthenticationInfo(userId, password, getName()); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/common/service/BaseChartService.java b/backend/crm/src/main/java/cn/cordys/common/service/BaseChartService.java new file mode 100644 index 0000000..755829f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/service/BaseChartService.java @@ -0,0 +1,208 @@ +package cn.cordys.common.service; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.ChartAnalysisDbRequest; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.chart.ChartCategoryAxisDbParam; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.common.resolver.field.ModuleFieldResolverFactory; +import cn.cordys.common.utils.IndustryUtils; +import cn.cordys.common.utils.RegionUtils; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.dto.field.MemberField; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormService; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 图标分析通用类 + * + * @author song-cc-rock + */ +@Service +public class BaseChartService { + + @Resource + private ModuleFormService moduleFormService; + + public List translateAxisName(ModuleFormConfigDTO formConfig, ChartAnalysisDbRequest chartAnalysisDbRequest, List chartResults) { + ChartCategoryAxisDbParam categoryAxisParam = chartAnalysisDbRequest.getCategoryAxisParam(); + ChartCategoryAxisDbParam subCategoryAxisParam = chartAnalysisDbRequest.getSubCategoryAxisParam(); + chartResults = chartResults.stream() + .filter(Objects::nonNull) + .toList(); + + BaseField categoryAxisField = getBaseField(formConfig.getFields(), categoryAxisParam.getFieldId()); + BaseField subCategoryAxisField = null; + if (subCategoryAxisParam != null) { + subCategoryAxisField = getBaseField(formConfig.getFields(), subCategoryAxisParam.getFieldId()); + } + + Map> optionMap = getChartOptionMap(formConfig, chartResults, categoryAxisParam, + subCategoryAxisParam); + + Map categoryOptionMap = Optional.ofNullable(optionMap.get(categoryAxisParam.getFieldId())) + .orElse(List.of()) + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + + Map subCategoryOptionMap = null; + if (subCategoryAxisField != null) { + subCategoryOptionMap = Optional.ofNullable(optionMap.get(subCategoryAxisParam.getFieldId())) + .orElse(List.of()) + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + } + + for (ChartResult chartResult : chartResults) { + if (categoryOptionMap.get(chartResult.getCategoryAxis()) != null) { + chartResult.setCategoryAxisName(categoryOptionMap.get(chartResult.getCategoryAxis())); + } else { + chartResult.setCategoryAxisName(chartResult.getCategoryAxis()); + } + + if (subCategoryOptionMap != null && subCategoryOptionMap.get(chartResult.getSubCategoryAxis()) != null) { + chartResult.setSubCategoryAxisName(subCategoryOptionMap.get(chartResult.getSubCategoryAxis())); + } else { + chartResult.setSubCategoryAxisName(chartResult.getSubCategoryAxis()); + } + + if (chartResult.getValueAxis() == null) { + chartResult.setValueAxis(0); + } + + if (categoryAxisField.isLocation() && chartResult.getCategoryAxis()!=null){ + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(categoryAxisField.getType()); + chartResult.setCategoryAxisName(String.valueOf(customFieldResolver.transformToValue(categoryAxisField, chartResult.getCategoryAxis()))); + chartResult.setCategoryAxis(RegionUtils.getCode(chartResult.getCategoryAxis())); + } + + if (subCategoryAxisField != null && subCategoryAxisField.isLocation()) { + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(subCategoryAxisField.getType()); + chartResult.setSubCategoryAxisName(String.valueOf(customFieldResolver.transformToValue(categoryAxisField, chartResult.getSubCategoryAxis()))); + chartResult.setSubCategoryAxis(RegionUtils.getCode(chartResult.getSubCategoryAxis())); + } + + if (categoryAxisField.isIndustry()) { + chartResult.setCategoryAxisName(IndustryUtils.mapping(chartResult.getCategoryAxis(), false)); + } + + if (subCategoryAxisField != null && subCategoryAxisField.isIndustry()) { + chartResult.setSubCategoryAxisName(IndustryUtils.mapping(chartResult.getSubCategoryAxis(), false)); + } + } + + if (categoryAxisField.isLocation()) { + // 合并CategoryAxis相同的项 + chartResults = mergeResult(chartResults, ChartResult::getCategoryAxis); + } + + if (subCategoryAxisField != null && subCategoryAxisField.isLocation()) { + // 合并SubCategoryAxis相同的项 + chartResults = mergeResult(chartResults, ChartResult::getSubCategoryAxis); + } + + return chartResults; + } + + private static BaseField getBaseField(List fields, String fieldKey) { + if (StringUtils.isBlank(fieldKey)) { + return null; + } + return fields.stream() + .filter(field -> Strings.CI.equals(field.getId(), fieldKey) + || Strings.CI.equals(field.getBusinessKey(), fieldKey)) + .findFirst().orElse(null); + } + + private static List mergeResult(List chartResults, Function getKeyFunc) { + Map mergedResults = new HashMap<>(chartResults.size()); + for (ChartResult chartResult : chartResults) { + String categoryAxis = getKeyFunc.apply(chartResult); + if (mergedResults.containsKey(categoryAxis)) { + ChartResult existingResult = mergedResults.get(categoryAxis); + if (existingResult.getValueAxis() instanceof Double && chartResult.getValueAxis() instanceof Double) { + existingResult.setValueAxis((Double) existingResult.getValueAxis() + (Double) chartResult.getValueAxis()); + } else if (existingResult.getValueAxis() instanceof Long && chartResult.getValueAxis() instanceof Long) { + existingResult.setValueAxis((Long) existingResult.getValueAxis() + (Long) chartResult.getValueAxis()); + } else if (existingResult.getValueAxis() instanceof Integer && chartResult.getValueAxis() instanceof Integer) { + existingResult.setValueAxis((Integer) existingResult.getValueAxis() + (Integer) chartResult.getValueAxis()); + } + } else { + mergedResults.put(categoryAxis, chartResult); + } + } + return new ArrayList<>(mergedResults.values()); + } + + private Map> getChartOptionMap( + ModuleFormConfigDTO formConfig, + List chartResults, + ChartCategoryAxisDbParam categoryAxisParam, + ChartCategoryAxisDbParam subCategoryAxisParam) { + + var categoryAxisField = getBaseField(formConfig.getFields(), categoryAxisParam.getFieldId()); + var subCategoryAxisField = subCategoryAxisParam != null + ? getBaseField(formConfig.getFields(), subCategoryAxisParam.getFieldId()) + : null; + + // 收集所有需要转换为 Option 的字段值 + var moduleFieldValues = chartResults.stream() + .flatMap(chartResult -> { + List values = new ArrayList<>(2); + + if (categoryAxisField != null && categoryAxisField.hasOptions()) { + var categoryValue = getBaseModuleFieldValue(categoryAxisParam.getFieldId()); + categoryValue.setFieldValue(chartResult.getCategoryAxis()); + values.add(categoryValue); + } + + if (subCategoryAxisField != null && subCategoryAxisField.hasOptions()) { + var subValue = getBaseModuleFieldValue(subCategoryAxisParam.getFieldId()); + subValue.setFieldValue(chartResult.getSubCategoryAxis()); + values.add(subValue); + } + + return values.stream(); + }) + .filter(BaseModuleFieldValue::valid) + .distinct() + .toList(); + + if (moduleFieldValues.isEmpty()) { + return Map.of(); + } + + return moduleFormService.getOptionMap(formConfig, moduleFieldValues); + } + + private static BaseModuleFieldValue getBaseModuleFieldValue(String fieldKey) { + BaseModuleFieldValue categoryFieldValue = new BaseModuleFieldValue(); + categoryFieldValue.setFieldId(fieldKey); + return categoryFieldValue; + } + + public static List getChartBaseFields() { + BaseField createUserField = new MemberField(); + createUserField.setType(FieldType.MEMBER.name()); + createUserField.setId("createUser"); + createUserField.setBusinessKey("createUser"); + + BaseField updateUserField = new MemberField(); + updateUserField.setType(FieldType.MEMBER.name()); + updateUserField.setId("updateUser"); + updateUserField.setBusinessKey("updateUser"); + + return List.of(createUserField, updateUserField); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/common/service/BaseExportService.java b/backend/crm/src/main/java/cn/cordys/common/service/BaseExportService.java new file mode 100644 index 0000000..f1076b0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/service/BaseExportService.java @@ -0,0 +1,929 @@ +package cn.cordys.common.service; + +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.context.CustomFunction; +import cn.cordys.common.context.ExportTaskFunction; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.*; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.common.resolver.field.ModuleFieldResolverFactory; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.SubListUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.approval.service.ApprovalFlowService; +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.domain.ExportTask; +import cn.cordys.crm.system.dto.field.DatasourceField; +import cn.cordys.crm.system.dto.field.DepartmentField; +import cn.cordys.crm.system.dto.field.SelectField; +import cn.cordys.crm.system.dto.field.SelectMultipleField; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import cn.cordys.crm.system.dto.field.base.SubField; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.excel.domain.MergeResult; +import cn.cordys.crm.system.excel.handler.CustomHeadColWidthStyleStrategy; +import cn.cordys.crm.system.excel.handler.SummaryMergeHandler; +import cn.cordys.crm.system.service.ExportTaskService; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.file.engine.DefaultRepositoryDir; +import cn.cordys.registry.ExportThreadRegistry; +import cn.idev.excel.EasyExcel; +import cn.idev.excel.ExcelWriter; +import cn.idev.excel.support.ExcelTypeEnum; +import cn.idev.excel.write.metadata.WriteSheet; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.springframework.context.i18n.LocaleContextHolder; + +import java.io.File; +import java.lang.reflect.Field; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +public abstract class BaseExportService { + + /** + * 最大查询数量 + */ + public static final int EXPORT_MAX_COUNT = 5000; + /** + * 汇总字段前缀 + */ + private static final String SUM_PREFIX = "sum_"; + public static final String SLASH = "/"; + /** + * SXSSFWorkbook行访问窗口大小 + */ + public static final String ROW_ACCESS_SIZE = "_randomAccessWindowSize"; + + @Resource + private LogService logService; + @Resource + private ExportTaskService exportTaskService; + + public Map getFieldConfigMap(String formKey, String orgId) { + return Objects.requireNonNull(CommonBeanFactory.getBean(ModuleFormService.class)) + .getAllFields(formKey, orgId) + .stream() + .collect(Collectors.toMap(BaseField::getId, Function.identity())); + } + + + public void batchHandleData(String fileId, List> headList, ExportTask task, String fileName, T t, CustomFunction> func) throws InterruptedException { + // 准备导出文件 + File file = prepareExportFile(fileId, fileName, task.getOrganizationId()); + + try (ExcelWriter writer = EasyExcel.write(file) + .head(headList) + .excelType(ExcelTypeEnum.XLSX) + .build()) { + + WriteSheet sheet = EasyExcel.writerSheet("导出数据").build(); + + int current = 1; + t.setPageSize(EXPORT_MAX_COUNT); + + while (true) { + if (ExportThreadRegistry.isInterrupted(task.getId())) { + throw new InterruptedException("线程已被中断,主动退出"); + } + + t.setCurrent(current); + List data = func.apply(t); + if (CollectionUtils.isEmpty(data)) { + break; + } + writer.write(data, sheet); + if (data.size() < EXPORT_MAX_COUNT) { + break; + } + current++; + } + } + + + } + + /** + * 分页写入数据并合并 + * + * @param headList 头信息 + * @param task 任务 + * @param fileName 文件名 + * @param t 请求参数 + * @param func 获取数据方法 + * @param mergeColumns 合并列 + * @param 参数类型 + * + * @throws InterruptedException 异常信息 + */ + public void batchHandleDataWithMergeStrategy(List> headList, ExportTask task, String fileName, + List mergeColumns, T t, + CustomFunction func) throws InterruptedException { + + File file = prepareExportFile(task.getFileId(), fileName, task.getOrganizationId()); + + try (ExcelWriter writer = EasyExcel.write(file) + .head(headList) + .excelType(ExcelTypeEnum.XLSX) + .registerWriteHandler(new CustomHeadColWidthStyleStrategy()) + .build()) { + + WriteSheet sheet = EasyExcel.writerSheet("导出数据").build(); + setRowAccessWindowSize(writer); + + int offset = 2, current = 1; + t.setPageSize(EXPORT_MAX_COUNT); + + while (true) { + if (ExportThreadRegistry.isInterrupted(task.getId())) { + throw new InterruptedException("线程已被中断,主动退出"); + } + + t.setCurrent(current); + MergeResult mergeResult = func.apply(t); + if (CollectionUtils.isEmpty(mergeResult.getDataList())) { + break; + } + // 写入数据 + writer.write(mergeResult.getDataList(), sheet); + // 执行合并策略 + Sheet mergeSheet = writer.writeContext().writeWorkbookHolder().getWorkbook().getSheetAt(0); + SummaryMergeHandler strategy = new SummaryMergeHandler(mergeResult.getMergeRegions(), mergeColumns, getSummaryColIdx(headList, mergeColumns), offset); + strategy.merge(mergeSheet); + if (mergeResult.getHandleCount() < EXPORT_MAX_COUNT) { + break; + } + // 下一页&&记录偏移量 + current++; + offset += mergeResult.getDataList().size(); + } + } + } + + + /** + * 准备导出文件 + * + * @param fileId 文件ID + * @param fileName 文件名 + * + * @return 导出文件 + */ + public File prepareExportFile(String fileId, String fileName, String orgId) { + if (fileId == null || fileName == null || orgId == null) { + throw new IllegalArgumentException("文件ID、文件名和组织ID不能为空"); + } + + // 构建导出目录路径 + String exportDirPath = DefaultRepositoryDir.getDefaultDir() + + File.separator + + DefaultRepositoryDir.getExportDir(orgId) + + File.separator + fileId; + + File dir = new File(exportDirPath); + + // 检查目录创建结果 + if (!dir.exists() && !dir.mkdirs()) { + throw new RuntimeException("无法创建导出目录: " + dir.getAbsolutePath()); + } + + // 返回完整的文件路径 + return new File(dir, fileName + ".xlsx"); + } + + /** + * 根据数据value 转换对应值 + * + * @param headList 头集合信息 + * @param systemFieldMap 系统字段值 + * @param moduleFieldMap 模块字段值 + * @param dataList 数据列表 + * @param fieldConfigMap 字段配置集合 + */ + public List transModuleFieldValue(List headList, LinkedHashMap systemFieldMap, Map moduleFieldMap, List dataList, Map fieldConfigMap) { + headList.forEach(head -> { + if (systemFieldMap.containsKey(head.getKey())) { + //固定字段 + dataList.add(systemFieldMap.get(head.getKey())); + } else if (moduleFieldMap.containsKey(head.getKey())) { + //自定义字段 + Map collect = moduleFieldMap.entrySet().stream() + .filter(entry -> entry.getKey().equals(head.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + getResourceFieldMap(collect, dataList, fieldConfigMap); + } else { + dataList.add(null); + } + + }); + return dataList; + } + + /** + * 导出全部(合并策略) + * + * @param exportParam 导出参数 + * + * @return 导出任务ID + */ + public String exportAllWithMergeStrategy(ExportDTO exportParam) { + List> exportHeads = getExportMergeHeadList(exportParam.getHeadList(), exportParam.getOrgId(), exportParam.getFormKey()); + List mergeColumns = getMergeColumns(exportHeads); + exportParam.setMergeHeads(getMergeHeads(exportParam.getHeadList(), exportParam.getFormKey(), exportParam.getOrgId())); + return exportWithMergeStrategy(exportParam, (task) -> batchHandleDataWithMergeStrategy(exportHeads, task, exportParam.getFileName(), + mergeColumns, exportParam.getPageRequest(), + t -> getExportMergeData(task.getId(), exportParam))); + } + + /** + * 导出选中(合并策略) + * + * @param exportParam 导出参数 + * + * @return 导出任务ID + */ + public String exportSelectWithMergeStrategy(ExportDTO exportParam) { + List> exportHeads = getExportMergeHeadList(exportParam.getHeadList(), exportParam.getOrgId(), exportParam.getFormKey()); + List mergeColumns = getMergeColumns(exportHeads); + exportParam.setMergeHeads(getMergeHeads(exportParam.getHeadList(), exportParam.getFormKey(), exportParam.getOrgId())); + return exportWithMergeStrategy(exportParam, (task) -> { + File file = prepareExportFile(task.getFileId(), exportParam.getFileName(), task.getOrganizationId()); + try (ExcelWriter writer = EasyExcel.write(file).head(exportHeads).excelType(ExcelTypeEnum.XLSX) + .registerWriteHandler(new CustomHeadColWidthStyleStrategy()).build()) { + WriteSheet sheet = EasyExcel.writerSheet("导出数据").build(); + setRowAccessWindowSize(writer); + AtomicInteger offset = new AtomicInteger(2); + SubListUtils.dealForSubList(exportParam.getSelectIds(), SubListUtils.DEFAULT_EXPORT_BATCH_SIZE, (ids) -> { + MergeResult mergeResult = new MergeResult(); + try { + mergeResult = getExportMergeData(task.getId(), exportParam); + } catch (InterruptedException e) { + log.error("任务停止中断", e); + exportTaskService.update(task.getId(), ExportConstants.ExportStatus.STOP.toString(), exportParam.getUserId()); + } + // 写入数据 + writer.write(mergeResult.getDataList(), sheet); + // 执行合并策略 + Sheet mergeSheet = writer.writeContext().writeWorkbookHolder().getWorkbook().getSheetAt(0); + SummaryMergeHandler strategy = new SummaryMergeHandler(mergeResult.getMergeRegions(), mergeColumns, getSummaryColIdx(exportHeads, mergeColumns), offset.get()); + strategy.merge(mergeSheet); + offset.addAndGet(mergeResult.getDataList().size()); + }); + } + }); + } + + /** + * 通用导出(合并策略) + * + * @param exportParam 导出参数 + * @param executor 导出执行器 + * + * @return 导出任务ID + */ + private String exportWithMergeStrategy(ExportDTO exportParam, ExportExecutor executor) { + exportParam.setExportFieldParam(getExportFieldParam(exportParam)); + exportParam.setExportMetas(getExportFieldMeta(exportParam.getExportFieldParam().getFieldConfigMap(), exportParam.getMergeHeads())); + return asyncExport(exportParam.getFileName(), exportParam.getOrgId(), exportParam.getUserId(), exportParam.getLocale(), + exportParam.getLogModule(), exportParam.getExportType(), executor); + } + + /** + * 获取导出的表头信息 + * + * @param headList 表头列表 + * @param currentOrg 当前组织 + * + * @return 表头信息 + */ + private List> getExportMergeHeadList(List headList, String currentOrg, String formKey) { + return Objects.requireNonNull(CommonBeanFactory.getBean(ModuleFormService.class)).getAllExportHeads(headList, formKey, currentOrg); + } + + /** + * 解析字段值 (子表格) + * + * @param metas 表头元信息 + * @param sysFieldValMap 系统字段值 + * + * @return 单行记录值 + */ + public List transFieldValueWithSub(List metas, LinkedHashMap sysFieldValMap, Map normalFieldMap, + Map subRowMap, Map cacheMap) { + List dataList = new ArrayList<>(metas.size()); + for (FieldExportMeta meta : metas) { + BaseField field = meta.getField(); + if (field == null) { + dataList.add(sysFieldValMap.get(meta.getHead())); + continue; + } + + String businessKey = meta.getBusinessKey(); + String fieldId = meta.getFieldId(); + if (StringUtils.isNotEmpty(meta.getPrefixId())) { + businessKey = meta.getPrefixId() + SLASH + meta.getBusinessKey(); + fieldId = meta.getPrefixId() + SLASH + meta.getFieldId(); + } + + Object value = null; + // 引用ID不存在时, 先用Key尝试取值 (取值顺序: 系统字段值 > 普通字段值 > 子表行数据) + if (meta.isNoResource()) { + value = sysFieldValMap.get(businessKey); + if (value != null) { + dataList.add(value); + continue; + } + value = normalFieldMap.get(businessKey); + if (value == null) { + value = subRowMap.get(businessKey); + } + } + // 兜底取值 + if (value == null) { + value = normalFieldMap.get(fieldId); + } + if (value == null) { + value = subRowMap.get(fieldId); + } + + dataList.add(value == null ? null : transformFieldValue(meta.getResolver(), field, value, cacheMap)); + } + + return dataList; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + public Object transformFieldValue(AbstractModuleFieldResolver resolver, BaseField field, Object value, Map cacheMap) { + if (value == null) { + return null; + } + if (field instanceof DatasourceField || + field instanceof DepartmentField || + field instanceof SelectField || + field instanceof SelectMultipleField) { + + if (cacheMap.containsKey(value)) { + return cacheMap.get(value); + } else { + var data = resolver.transformToValue(field, value instanceof List ? JSON.toJSONString(value) : value.toString()); + cacheMap.put(value, data); + return data; + } + } + + return resolver.transformToValue(field, value instanceof List ? JSON.toJSONString(value) : value.toString()); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + public Object transformFieldValue(BaseField field, Object value) { + if (value == null) { + return null; + } + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(field.getType()); + // 将数据库中的字符串值,转换为对应的对象值 + return customFieldResolver.transformToValue(field, value instanceof List ? JSON.toJSONString(value) : value.toString()); + } + + /** + * 构建含有子表格的导出数据 + * + * @param moduleFieldValues 自定义字段值 + * @param exportFieldParam 导出参数 + * @param metas 导出表头元数据 + * @param systemFieldMap 系统字段值 + * + * @return 导出数据列表 + */ + protected List> buildDataWithSub(List moduleFieldValues, ExportFieldParam exportFieldParam, List metas, LinkedHashMap systemFieldMap, Map cacheMap) { + List> dataList = new ArrayList<>(); + if (org.apache.commons.collections4.CollectionUtils.isEmpty(moduleFieldValues)) { + // 无自定义字段, 导出系统字段值, 单行导出 + dataList.add(transFieldValueWithSub(metas, systemFieldMap, new LinkedHashMap<>(), new LinkedHashMap<>(), cacheMap)); + return dataList; + } + + // 拆分子表格字段, 普通字段值 + List subFvs = new ArrayList<>(); + Map normalFvs = new HashMap<>(8); + Set subIds = exportFieldParam.getSubIds(); + for (BaseModuleFieldValue mfv : moduleFieldValues) { + String fieldId = mfv.getFieldId(); + Object fieldValue = mfv.getFieldValue(); + if (subIds.contains(fieldId)) { + subFvs.add(mfv); + } else { + normalFvs.put(fieldId, fieldValue); + } + } + + // 子表格缺失, 无需合并逻辑, 单行导出 (普通字段值). + if (isNullSubValue(subFvs)) { + dataList.add(transFieldValueWithSub(metas, systemFieldMap, normalFvs, new LinkedHashMap<>(), cacheMap)); + return dataList; + } + + // 合并子表格字段值, 构建多行导出数据 + List> alignSubFvs = alignSubFvs(subFvs); + + // 子表格合并为空, 单行导出 (普通字段值). + if (CollectionUtils.isEmpty(alignSubFvs)) { + dataList.add(transFieldValueWithSub(metas, systemFieldMap, normalFvs, new LinkedHashMap<>(), cacheMap)); + return dataList; + } + + // 第一行数据 = 系统字段值 + 普通字段值 + 第一行子表格字段值 (合并时保留) + List data = transFieldValueWithSub(metas, systemFieldMap, normalFvs, alignSubFvs.getFirst(), cacheMap); + dataList.add(data); + + // 剩余子表行 (并行处理) + for (int i = 1; i < alignSubFvs.size(); i++) { + // 其余行只用遍历子表格字段 + dataList.add(transFieldValueWithSub(metas, new LinkedHashMap<>(), + new LinkedHashMap<>(), alignSubFvs.get(i), cacheMap)); + } + + return dataList; + } + + /** + * 解析自定义字段 + * + * @param moduleFieldMap 模块字段值 + * @param dataList 数据列表 + * @param fieldConfigMap 字段配置映射 + */ + public void getResourceFieldMap(Map moduleFieldMap, List dataList, Map fieldConfigMap) { + moduleFieldMap.forEach((key, value) -> { + BaseField fieldConfig = fieldConfigMap.get(key); + if (fieldConfig == null) { + return; + } + dataList.add(transformFieldValue(fieldConfig, value)); + }); + } + + + /** + * 日志 + * + * @param orgId 组织ID + * @param taskId 任务ID + * @param userId 用户ID + * @param logType 日志类型 + * @param moduleType 模块类型 + * @param fileName 文件名 + */ + public void exportLog(String orgId, String taskId, String userId, String logType, String moduleType, String fileName) { + LogDTO logDTO = new LogDTO(orgId, taskId, userId, logType, moduleType, fileName); + logService.add(logDTO); + } + + + public void checkFileName(String fileName) { + if (fileName.contains(SLASH)) { + throw new GenericException(Translator.get("file_name_illegal")); + } + } + + /** + * 构建全部导出数据 + * + * @return 导出数据列表 + * + * @throws InterruptedException 异常信息 + */ + protected List> getExportData(String taskId, ExportDTO exportDTO) throws InterruptedException { + return null; + } + + /** + * 构建导出合并数据 + * + * @param taskId 任务ID + * @param exportParam 导出参数 + * + * @return 导出数据列表 && 合并区域 + * + * @throws InterruptedException 异常信息 + */ + protected MergeResult getExportMergeData(String taskId, ExportDTO exportParam) throws InterruptedException { + return null; + } + + /** + * 构建选择的导出数据 + * + * @return 导出数据列表 + * + * @throws InterruptedException 异常信息 + */ + protected List> getSelectExportData(List ids, String taskId, ExportDTO exportDTO) throws InterruptedException { + return null; + } + + + protected Map getFieldIdValueMap(List fieldValues) { + AtomicReference> moduleFieldMap = new AtomicReference<>(new LinkedHashMap<>()); + Optional.ofNullable(fieldValues).ifPresent(moduleFields -> moduleFieldMap.set( + moduleFields.stream() + .filter(fv -> fv.getFieldValue() != null) + .collect(Collectors.toMap(BaseModuleFieldValue::getFieldId, BaseModuleFieldValue::getFieldValue))) + ); + return moduleFieldMap.get(); + } + + /** + * 异步导出通用方法 + * + * @param exportFileName 导出文件名 + * @param currentOrg 当前组织 + * @param currentUser 当前用户 + * @param locale 语言环境 + * @param logModule 日志模块 + * @param executor 导出执行器 + */ + public String asyncExport(String exportFileName, String currentOrg, String currentUser, Locale locale, String logModule, + String exportType, ExportExecutor executor) { + checkFileName(exportFileName); + exportTaskService.checkUserTaskLimit(currentUser, exportType); + String fileId = IDGenerator.nextStr(); + ExportTask exportTask = exportTaskService.saveTask(currentOrg, fileId, currentUser, exportType, exportFileName); + + runExport(currentOrg, currentUser, logModule, locale, exportTask, exportFileName, + () -> executor.execute(exportTask)); + + return exportTask.getId(); + } + + /** + * 获取一些公共的导出字段参数 + * + * @param exportParam 导出参数 + * + * @return 导出字段参数 + */ + protected ExportFieldParam getExportFieldParam(ExportDTO exportParam) { + ModuleFormConfigDTO formConfig = Objects.requireNonNull(CommonBeanFactory.getBean(ModuleFormCacheService.class)) + .getBusinessFormConfig(exportParam.getFormKey(), exportParam.getOrgId()); + List exportTitles = exportParam.getHeadList().stream().map(ExportHeadDTO::getTitle).toList(); + List flattenFields = Objects.requireNonNull(CommonBeanFactory.getBean(ModuleFormService.class)).flattenFormAllFieldsWithSubId(formConfig); + List subTableIds = flattenFields.stream().filter(f -> f instanceof SubField && exportTitles.contains(f.getName())).map(BaseField::getId).toList(); + Map fieldConfigMap = flattenFields.stream().collect(Collectors.toMap(BaseField::getId, f -> f, (f1, f2) -> f1)); + return ExportFieldParam.builder().subIds(new HashSet<>(subTableIds)).fieldConfigMap(fieldConfigMap) + .formConfig(formConfig) + .build(); + } + + /** + * 预处理表头字段信息 + * + * @param fieldConfigMap 字段配置 + * @param heads 表头信息集合 + * + * @return 预处理后的表头字段信息集合 + */ + private List getExportFieldMeta(Map fieldConfigMap, List heads) { + List metas = new ArrayList<>(heads.size()); + for (String head : heads) { + FieldExportMeta meta = new FieldExportMeta(); + String realHead = head; + // 子表格汇总字段截取 + if (head.contains(SUM_PREFIX)) { + realHead = head.substring(head.indexOf(SUM_PREFIX) + SUM_PREFIX.length()); + } + // 表头Key包含下划线, 含有子表格字段, 截取下划线前部分作为前缀ID, 用于取值区分不同子表格 (如果存在同名字段), 后半部分作为实际字段ID + if (realHead.contains(SLASH)) { + String[] ks = realHead.split(SLASH); + meta.setPrefixId(ks[0]); + realHead = ks[1]; + } + + BaseField field = fieldConfigMap.get(realHead); + meta.setHead(realHead); + meta.setField(field); + if (field != null) { + meta.setResolver(ModuleFieldResolverFactory.getResolver(field.getType())); + meta.setNoResource(StringUtils.isEmpty(field.getResourceFieldId())); + meta.setFieldId(field.getId()); + meta.setBusinessKey(field.getBusinessKey()); + } + metas.add(meta); + } + return metas; + } + + /** + * 获取表头ID集合 + * + * @param exportHeads 表头信息 + * @param currentOrg 当前组织 + * @param formKey 表单Key + * + * @return 表头ID集合 + */ + private List getMergeHeads(List exportHeads, String formKey, String currentOrg) { + return Objects.requireNonNull(CommonBeanFactory.getBean(ModuleFormService.class)).getExportMergeHeads(formKey, currentOrg, exportHeads); + } + + /** + * 获取需要合并的列索引 + * + * @param heads 导出的表头信息 + * + * @return 需要合并的列索引集合 + */ + private List getMergeColumns(List> heads) { + List mergeColumns = new ArrayList<>(); + for (int i = 0; i < heads.size(); i++) { + List headCols = heads.get(i); + if (headCols.size() == 1) { + mergeColumns.add(i); + } + } + if (mergeColumns.size() == heads.size()) { + // 因为子表格缺失, 全部列都需要合并. 跳过即可 + mergeColumns.clear(); + } + return mergeColumns; + } + + public void runExport(String orgId, String userId, String module, Locale locale, ExportTask exportTask, String fileName, ExportTaskFunction func) { + Thread.startVirtualThread(() -> { + try { + LocaleContextHolder.setLocale(locale); + ExportThreadRegistry.register(exportTask.getId(), Thread.currentThread()); + + func.apply(); + + exportTaskService.update( + exportTask.getId(), + ExportConstants.ExportStatus.SUCCESS.name(), + userId + ); + } catch (InterruptedException e) { + log.error("任务停止中断", e); + exportTaskService.update( + exportTask.getId(), + ExportConstants.ExportStatus.STOP.name(), + userId + ); + } catch (Exception e) { + log.error("导出任务异常", e); + exportTaskService.update( + exportTask.getId(), + ExportConstants.ExportStatus.ERROR.name(), + userId + ); + } finally { + ExportThreadRegistry.remove(exportTask.getId()); + exportLog(orgId, exportTask.getId(), userId, LogType.EXPORT, module, fileName); + } + }); + } + + protected void exportSelectData(ExportTask exportTask, ExportDTO exportDTO) { + LocaleContextHolder.setLocale(exportDTO.getLocale()); + ExportThreadRegistry.register(exportTask.getId(), Thread.currentThread()); + //表头信息 + List> headList = exportDTO.getHeadList().stream() + .map(head -> Collections.singletonList(head.getTitle())) + .toList(); + // 准备导出文件 + File file = prepareExportFile(exportTask.getFileId(), exportDTO.getFileName(), exportTask.getOrganizationId()); + try (ExcelWriter writer = EasyExcel.write(file) + .head(headList) + .excelType(ExcelTypeEnum.XLSX) + .build()) { + WriteSheet sheet = EasyExcel.writerSheet("导出数据").build(); + + SubListUtils.dealForSubList(exportDTO.getSelectIds(), SubListUtils.DEFAULT_EXPORT_BATCH_SIZE, (subIds) -> { + List> data = null; + try { + data = getSelectExportData(subIds, exportTask.getId(), exportDTO); + } catch (InterruptedException e) { + log.error("任务停止中断", e); + exportTaskService.update(exportTask.getId(), ExportConstants.ExportStatus.STOP.toString(), exportDTO.getUserId()); + } + writer.write(data, sheet); + }); + } + + //更新导出任务状态 + exportTaskService.update(exportTask.getId(), ExportConstants.ExportStatus.SUCCESS.toString(), exportDTO.getUserId()); + } + + /** + * 导出选择数据 + * + * @return 导出任务ID + */ + public String exportSelect(ExportDTO exportDTO) { + String fileName = exportDTO.getFileName(); + String userId = exportDTO.getUserId(); + String orgId = exportDTO.getOrgId(); + checkFileName(fileName); + // 用户导出数量限制 + exportTaskService.checkUserTaskLimit(userId, exportDTO.getExportType()); + + String fileId = IDGenerator.nextStr(); + ExportTask exportTask = exportTaskService.saveTask(orgId, fileId, userId, exportDTO.getExportType(), fileName); + + runExport(orgId, userId, exportDTO.getLogModule(), exportDTO.getLocale(), exportTask, exportDTO.getFileName(), + () -> exportSelectData(exportTask, exportDTO)); + + return exportTask.getId(); + } + + public String export(ExportDTO exportDTO) { + String userId = exportDTO.getUserId(); + String orgId = exportDTO.getOrgId(); + String exportType = exportDTO.getExportType(); + String fileName = exportDTO.getFileName(); + checkFileName(exportDTO.getFileName()); + //用户导出数量 限制 + exportTaskService.checkUserTaskLimit(userId, exportType); + + String fileId = IDGenerator.nextStr(); + ExportTask exportTask = exportTaskService.saveTask(orgId, fileId, userId, exportType, fileName); + + runExport(orgId, userId, exportDTO.getLogModule(), exportDTO.getLocale(), exportTask, exportDTO.getFileName(), + () -> exportCustomerData(exportTask, exportDTO)); + + return exportTask.getId(); + } + + public void exportCustomerData(ExportTask exportTask, ExportDTO exportDTO) throws Exception { + LocaleContextHolder.setLocale(exportDTO.getLocale()); + ExportThreadRegistry.register(exportTask.getId(), Thread.currentThread()); + //表头信息 + List> headList = exportDTO.getHeadList().stream() + .map(head -> Collections.singletonList(head.getTitle())) + .toList(); + //分批查询数据并写入文件 + batchHandleData(exportTask.getFileId(), + headList, + exportTask, + exportDTO.getFileName(), + exportDTO.getPageRequest(), + t -> getExportData(exportTask.getId(), exportDTO)); + //更新状态 + exportTaskService.update(exportTask.getId(), ExportConstants.ExportStatus.SUCCESS.toString(), exportDTO.getUserId()); + } + + protected String getOptionLabel(String value, List options) { + for (OptionProp option : options) { + if (Strings.CS.equals(option.getValue(), value)) { + return option.getLabel(); + } + } + return null; + } + + /** + * 是否为空子表格 + * + * @param subFvs 子表格字段值 + * + * @return 是否为空子表格 + */ + private boolean isNullSubValue(List subFvs) { + if (CollectionUtils.isEmpty(subFvs)) { + return true; + } + boolean allNull = true; + for (BaseModuleFieldValue subFv : subFvs) { + if (CollectionUtils.isNotEmpty((List) subFv.getFieldValue())) { + allNull = false; + break; + } + } + return allNull; + } + + /** + * 合并对齐多个子表格的值 + * + * @param subFvs 多子表格字段值 + * + * @return 合并后的子表格值列表 + */ + @SuppressWarnings("unchecked") + private List> alignSubFvs(List subFvs) { + int maxSize = 0; + for (BaseModuleFieldValue subFv : subFvs) { + List list = (List) subFv.getFieldValue(); + if (list != null && list.size() > maxSize) { + maxSize = list.size(); + } + } + + List> alignedList = new ArrayList<>(maxSize); + + for (int i = 0; i < maxSize; i++) { + alignedList.add(new HashMap<>(8)); + } + + for (BaseModuleFieldValue subFv : subFvs) { + List list = (List) subFv.getFieldValue(); + if (list == null) { + continue; + } + for (int i = 0; i < list.size(); i++) { + Map row = alignedList.get(i); + Map subRowMap = (Map) list.get(i); + subRowMap.forEach((k, v) -> row.put(subFv.getFieldId() + SLASH + k, v)); + } + } + return alignedList; + } + + /** + * 获取合并列中的汇总列索引 + * + * @param headList 表头信息 + * @param mergeColumns 合并的列索引 + * + * @return 汇总列索引 + */ + private List getSummaryColIdx(List> headList, List mergeColumns) { + if (CollectionUtils.isEmpty(headList) || CollectionUtils.isEmpty(mergeColumns)) { + return Collections.emptyList(); + } + return mergeColumns.stream().filter(col -> { + List mergeHead = headList.get(col); + if (mergeHead == null) { + return false; + } + if (!Strings.CI.equals(mergeHead.getFirst(), mergeHead.getLast())) { + return false; + } + String headName = mergeHead.getFirst(); + return headName != null && headName.startsWith(Translator.get("sum")); + }).toList(); + } + + /** + * 根据审批流状态权限过滤可导出的数据 + * + * @param resources 原始数据列表 + * @param orgId 组织ID + * @param formKey 表单类型 + * @param idGetter 获取资源ID的函数 + * @param statusGetter 获取审批状态的函数 + * @param approvalFlowService 审批流服务 + * @param 资源类型 + * @return 过滤后可导出的数据列表 + */ + protected List filterApprovalExportPermission(List resources, String orgId, String formKey, + Function idGetter, + Function statusGetter, + ApprovalFlowService approvalFlowService) { + if (CollectionUtils.isEmpty(resources)) { + return resources; + } + List filteredIds = approvalFlowService.filterResourcesWithExportPermission(formKey, resources, orgId, idGetter, statusGetter); + if (CollectionUtils.isEmpty(filteredIds)) { + return new ArrayList<>(); + } + Map resourceMap = resources.stream().collect(Collectors.toMap(idGetter, Function.identity(), (a, b) -> a)); + return filteredIds.stream() + .map(resourceMap::get) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + /** + * 设置SXSSFWorkbook的行数据窗口大小 + * + * @param writer ExcelWriter对象 + */ + private void setRowAccessWindowSize(ExcelWriter writer) { + SXSSFWorkbook workbook = (SXSSFWorkbook) writer.writeContext().writeWorkbookHolder().getWorkbook(); + try { + // _rowAccessWindowSize为-1,禁用窗口机制, 避免合并行数据时, 数据被写入磁盘导致无法访问的问题 + Field f = SXSSFWorkbook.class.getDeclaredField(ROW_ACCESS_SIZE); + f.setAccessible(true); + f.set(workbook, -1); + } catch (Exception e) { + log.warn("Fail to set random access window size, {}", e.getMessage()); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/service/BaseResourceFieldService.java b/backend/crm/src/main/java/cn/cordys/common/service/BaseResourceFieldService.java new file mode 100644 index 0000000..23fc9a1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/service/BaseResourceFieldService.java @@ -0,0 +1,1402 @@ +package cn.cordys.common.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.context.SourceDetailResolveContext; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.domain.BaseResourceField; +import cn.cordys.common.domain.BaseResourceSubField; +import cn.cordys.common.dto.BatchUpdateDbParam; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.mapper.CommonMapper; +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.common.resolver.field.ModuleFieldResolverFactory; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.uid.SerialNumGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.form.service.CustomFormDataFieldService; +import cn.cordys.crm.system.constants.FieldSourceType; +import cn.cordys.crm.system.domain.ModuleField; +import cn.cordys.crm.system.dto.field.DatasourceField; +import cn.cordys.crm.system.dto.field.SerialNumberField; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.SubField; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.request.UploadTransferRequest; +import cn.cordys.crm.system.service.AttachmentService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.EntityTableMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.ListUtils; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.context.annotation.Lazy; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.*; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * 资源与模块字段值的公共处理类 + * + * @author jianxing + * @date 2025-01-03 12:01:54 + */ +@Slf4j +public abstract class BaseResourceFieldService extends BaseResourceService { + + @Resource + private SerialNumGenerator serialNumGenerator; + @Resource + private CommonMapper commonMapper; + @Resource + private BaseMapper moduleFieldMapper; + @Lazy + @Resource + private FieldSourceServiceProvider fieldSourceServiceProvider; + + private static final String SOURCE_DETAIL_ID = "id"; + private static final String ROW_BIZ_ID = "id"; + public static final String DETAIL_FIELD_PARAM_NAME = "moduleFields"; + private static final String PRICE_SUB_ROW_KEY = "price_sub"; + public static final int MAX_NUMBER_LENGTH = 50; + public static final String REF_UNDERLINE = "_ref_"; + + /** + * 获取资源字段类型 (T) + * + * @return 资源字段类型 + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private Class getResourceFieldClass() { + Type type = getGenericType(0); + if (type instanceof Class tClass) { + return tClass; + } else { + throw new IllegalArgumentException("Type cannot be converted to Class: " + type); + } + } + + /** + * 获取资源大字段类型 (V) + * + * @return 资源大字段类型 + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private Class getResourceFieldBlobClass() { + Type type = getGenericType(1); + if (type instanceof Class vClass) { + return vClass; + } else { + throw new IllegalArgumentException("Type cannot be converted to Class: " + type); + } + } + + /** + * 获取泛型类型 + * + * @param index 泛型参数索引 + * + * @return 泛型类型 + */ + protected Type getGenericType(int index) { + // 获取当前类的泛型父类 + Type superclass = getClass().getGenericSuperclass(); + + // 检查是否是ParameterizedType + if (superclass instanceof ParameterizedType parameterizedType) { + + // 获取泛型类型的实际类型参数 + Type[] typeArguments = parameterizedType.getActualTypeArguments(); + + // 返回第一个泛型类型参数 + return typeArguments[index]; + } + + throw new IllegalStateException("No generic type found"); + } + + /** + * 获取表单Key + * + * @return 表单Key + */ + protected abstract String getFormKey(); + + /** + * 获取资源字段Mapper + * + * @return FieldMapper + */ + protected abstract BaseMapper getResourceFieldMapper(); + + /** + * 获取资源大字段Mapper + * + * @return BlobFieldMapper + */ + protected abstract BaseMapper getResourceFieldBlobMapper(); + + /** + * 获取资源所有字段值 + * + * @param resourceId 资源ID + * + * @return 字段值集合 + */ + public List getModuleFieldValuesByResourceId(String resourceId) { + List fieldValues = getResourceFieldMap(List.of(resourceId), true).get(resourceId); + return fieldValues == null ? new ArrayList<>(0) : fieldValues; + } + + /** + * 保存字段值 + * + * @param resource 资源 + * @param orgId 组织ID + * @param userId 用户ID + * @param moduleFieldValues 自定义字段值 + * @param update 是否更新 + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + public void saveModuleField(K resource, String orgId, String userId, List moduleFieldValues, boolean update) { + List allFields = Objects.requireNonNull(CommonBeanFactory.getBean(ModuleFormService.class)) + .getAllFields(getFormKey(), OrganizationContext.getOrganizationId()); + if (CollectionUtils.isEmpty(allFields)) { + return; + } + + String resourceId = (String) getResourceFieldValue(resource, "id"); + + Map fieldValueMap; + if (CollectionUtils.isNotEmpty(moduleFieldValues)) { + fieldValueMap = moduleFieldValues.stream().collect(Collectors.toMap(BaseModuleFieldValue::getFieldId, v -> v)); + } else { + fieldValueMap = new HashMap<>(8); + } + + // 校验业务字段,字段值是否重复 + businessFieldRepeatCheck(orgId, resource, update ? List.of(resourceId) : List.of(), allFields); + + // 处理字段值 + Map serialNumCache = new HashMap<>(2); + List customerFields = new ArrayList<>(); + List customerFieldBlobs = new ArrayList<>(); + allFields.stream() + .filter(field -> { + BaseModuleFieldValue fieldValue = fieldValueMap.get(field.getId()); + return (fieldValue != null && fieldValue.valid()) || field.isSerialNumber() || field.isSubField() || field.includeFormula(); + }) + .sorted(Comparator.comparing((BaseField f) -> !f.isSerialNumber()).thenComparing(f -> f.getPos() != null ? f.getPos() : Long.MAX_VALUE)) + .forEach(field -> { + // 引用字段跳过 + if (StringUtils.isNotEmpty(field.getResourceFieldId())) { + return; + } + BaseModuleFieldValue fieldValue = processSpecialFieldValue(resource, field, fieldValueMap, serialNumCache, update, orgId); + // 空值跳过 + if (fieldValue == null || fieldValue.getFieldValue() == null) { + return; + } + // 处理子表格值 + if (field.isSubField() && fieldValue.getFieldValue() != null) { + saveSubFieldValue(resourceId, (SubField) field, fieldValue, customerFields, customerFieldBlobs); + return; + } + + if (field.needRepeatCheck()) { + checkUnique(fieldValue, field); + } + + // 获取字段解析器 + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(field.getType()); + // 校验参数值 + customFieldResolver.validate(field, fieldValue.getFieldValue()); + // 将参数值转换成字符串入库 + String strValue = customFieldResolver.convertToString(field, fieldValue.getFieldValue()); + if (field.isBlob()) { + customerFieldBlobs.add(supplyNewResource(this::newResourceFieldBlob, resourceId, fieldValue.getFieldId(), strValue)); + } else { + customerFields.add(supplyNewResource(this::newResourceField, resourceId, fieldValue.getFieldId(), strValue)); + } + }); + + // 处理附件类型字段 + List attachmentFieldVals = allFields.stream() + .filter(field -> { + BaseModuleFieldValue fieldValue = fieldValueMap.get(field.getId()); + return (fieldValue != null && fieldValue.valid()) && field.isAttachment(); + }).map(field -> fieldValueMap.get(field.getId())).toList(); + List processVal = attachmentFieldVals.stream().map(val -> (List) val.getFieldValue()).flatMap(List::stream).toList(); + preProcessTempAttachment(orgId, resourceId, userId, processVal); + + // 字段值入库 + if (CollectionUtils.isNotEmpty(customerFields)) { + getResourceFieldMapper().batchInsert(customerFields); + } + if (CollectionUtils.isNotEmpty(customerFieldBlobs)) { + getResourceFieldBlobMapper().batchInsert(customerFieldBlobs); + } + } + + /** + * 保存子字段值 + * + * @param resourceId 资源ID + * @param subField 子字段信息 + * @param fieldValue 子字段值 + * @param fields 字段值集合 + * @param fieldBlobs 大字段值集合 + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + public void saveSubFieldValue(String resourceId, SubField subField, BaseModuleFieldValue fieldValue, + List fields, List fieldBlobs) { + List> subValues = (List) fieldValue.getFieldValue(); + if (CollectionUtils.isEmpty(subValues)) { + return; + } + Map subFieldMap = new HashMap<>(subField.getSubFields().size()); + subField.getSubFields().forEach(f -> { + if (StringUtils.isNotEmpty(f.getResourceFieldId())) { + return; + } + if (StringUtils.isNotEmpty(f.getBusinessKey())) { + subFieldMap.put(f.getBusinessKey(), f); + } else { + subFieldMap.put(f.getId(), f); + } + }); + int rowId = 1; + for (Map subValue : subValues) { + // 子表行数据, 如果存在ID, 则使用旧ID作为bizId, 不存在则生成一个唯一ID, 保证行数据关联正确 + String bizId = subValue.containsKey("id") ? subValue.get("id").toString() : IDGenerator.nextStr(); + for (Map.Entry kv : subValue.entrySet()) { + if (Strings.CS.equals(kv.getKey(), PRICE_SUB_ROW_KEY) && kv.getValue() != null) { + T t = supplyNewResource(this::newResourceField, resourceId, kv.getKey(), kv.getValue().toString()); + setResourceFieldValue(t, "rowId", String.valueOf(rowId)); + setResourceFieldValue(t, "refSubId", subField.getId()); + setResourceFieldValue(t, "bizId", bizId); + fields.add(t); + continue; + } + BaseField field = subFieldMap.get(kv.getKey()); + if (field == null) { + continue; + } + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(field.getType()); + customFieldResolver.validate(field, kv.getValue()); + if (kv.getValue() == null) { + continue; + } + String strValue = customFieldResolver.convertToString(field, kv.getValue()); + if (field.isBlob()) { + V v = supplyNewResource(this::newResourceFieldBlob, resourceId, kv.getKey(), strValue); + setResourceFieldValue(v, "rowId", String.valueOf(rowId)); + setResourceFieldValue(v, "refSubId", subField.getId()); + setResourceFieldValue(v, "bizId", bizId); + fieldBlobs.add(v); + } else { + T t = supplyNewResource(this::newResourceField, resourceId, kv.getKey(), strValue); + setResourceFieldValue(t, "rowId", String.valueOf(rowId)); + setResourceFieldValue(t, "refSubId", subField.getId()); + setResourceFieldValue(t, "bizId", bizId); + fields.add(t); + } + } + rowId++; + } + } + + /** + * 校验业务字段,字段值是否重复 + * + * @param orgId 组织ID + * @param resource 资源 + * @param updateIds 资源ID集合 + * @param allFields 所有字段 + * @param 资源类型 + */ + private void businessFieldRepeatCheck(String orgId, K resource, List updateIds, List allFields) { + Map businessModuleFieldMap = Arrays.stream(BusinessModuleField.values()). + collect(Collectors.toMap(BusinessModuleField::getKey, Function.identity())); + + allFields.forEach(field -> { + if (businessModuleFieldMap.containsKey(field.getInternalKey())) { + BusinessModuleField businessModuleField = businessModuleFieldMap.get(field.getInternalKey()); + businessFieldRepeatCheck(orgId, resource, updateIds, field, businessModuleField.getBusinessKey()); + } + }); + } + + /** + * 批量更新自定义字段或业务字段值 + * + * @param request 批量参数 + * @param field 字段信息 + * @param originResourceList 旧的字段值 + * @param clazz 资源类对象 + * @param logModule 日志类型 + * @param batchInsertFunc 批量更新回调函数 + * @param userId 当前用户ID + * @param orgId 当前组织ID + * @param 资源类型 + */ + public void batchUpdate(ResourceBatchEditRequest request, + BaseField field, + List originResourceList, + Class clazz, + String logModule, + Consumer batchInsertFunc, + String userId, + String orgId) { + + if (field.needRepeatCheck() && request.getIds().size() > 1 && isNotBlank(request.getFieldValue())) { + // 如果字段唯一,则校验不能同时修改多条 + throw new GenericException(Translator.getWithArgs("common.field_value.repeat", field.getName())); + } + + BatchUpdateDbParam updateParam = new BatchUpdateDbParam(); + updateParam.setIds(request.getIds()); + // 修改更新时间和用户 + updateParam.setUpdateTime(System.currentTimeMillis()); + updateParam.setUpdateUser(userId); + + if (StringUtils.isNotBlank(field.getBusinessKey())) { + // 设置值&&唯一性校验 + K resource = newInstance(clazz); + setResourceFieldValue(resource, field.getBusinessKey(), request.getFieldValue()); + businessFieldRepeatCheck(orgId, resource, request.getIds(), field, field.getBusinessKey()); + updateParam.setFieldName(field.getBusinessKey()); + updateParam.setFieldValue(request.getFieldValue()); + // 添加日志 + addBusinessFieldBatchUpdateLog(originResourceList, field, request, logModule, userId, orgId); + } else { + ModuleField moduleField = moduleFieldMapper.selectByPrimaryKey(request.getFieldId()); + List originFields; + if (field.isBlob()) { + originFields = getResourceFieldBlob(request.getIds(), request.getFieldId()); + } else { + originFields = getResourceField(request.getIds(), request.getFieldId()); + } + // 删除旧值 && 唯一性校验 && 添加新值 + batchDeleteFieldValues(request, moduleField); + if (field.needRepeatCheck() && isNotBlank(request.getFieldValue())) { + checkUnique(BeanUtils.copyBean(new BaseModuleFieldValue(), request), field); + } + if (isNotBlank(request.getFieldValue())) { + batchUpdateFieldValues(request, field, moduleField); + } + // 添加日志 + addCustomFieldBatchUpdateLog(originResourceList, originFields, request, field, logModule, userId, orgId); + } + // 批量修改业务字段和更新时间等 + batchInsertFunc.accept(updateParam); + } + + /** + * 查询指定资源的模块字段值 + * + * @param resourceIds 资源ID集合 + * @param withBlob 是否包含大文本字段 + * + * @return 字段集合 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public Map> getResourceFieldMap(List resourceIds, boolean withBlob) { + if (CollectionUtils.isEmpty(resourceIds)) { + return new HashMap<>(2); + } + SourceDetailResolveContext.start(); + try { + List flattenFormFields = Objects.requireNonNull(CommonBeanFactory.getBean(ModuleFormService.class)). + getFlattenFormFields(getFormKey(), OrganizationContext.getOrganizationId()); + Map fieldConfigMap = flattenFormFields.stream().collect(Collectors.toMap(BaseField::getId, f -> f, (prev, next) -> next)); + Map> resourceMap = new HashMap<>(8); + List resourceFields = getResourceField(resourceIds); + getSourceDetailMapByIds(flattenFormFields, resourceFields); + resourceFields.forEach(resourceField -> { + if (resourceField instanceof BaseResourceSubField subResourceField && StringUtils.isNotEmpty(subResourceField.getRefSubId())) { + return; + } + if (resourceField.getFieldValue() != null) { + BaseField fieldConfig = fieldConfigMap.get(resourceField.getFieldId()); + if (fieldConfig == null) { + return; + } + // 获取字段解析器 + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(fieldConfig.getType()); + // 将数据库中的字符串值,转换为对应的对象值 + Object objectValue = null; + try { + objectValue = customFieldResolver.convertToValue(fieldConfig, resourceField.getFieldValue().toString()); + resourceField.setFieldValue(objectValue); + } catch (Exception e) { + log.error("Convert field value error: {}", e.getMessage()); + } + + if (objectValue == null) { + return; + } + String resourceId = resourceField.getResourceId(); + resourceMap.putIfAbsent(resourceId, new ArrayList<>()); + resourceMap.get(resourceId).add(new BaseModuleFieldValue(resourceField.getFieldId(), objectValue)); + // 处理数据源字段显示值 + if (fieldConfig instanceof DatasourceField sourceField && CollectionUtils.isNotEmpty(sourceField.getShowFields())) { + // 处理展示列 + Map detailMap = SourceDetailResolveContext.getSourceMap().get(objectValue.toString()); + if (MapUtils.isEmpty(detailMap)) { + return; + } + sourceField.getShowFields().forEach(id -> { + BaseField showFieldConfig = fieldConfigMap.get(sourceField.getId() + REF_UNDERLINE + id); + if (showFieldConfig == null) { + return; + } + Object value = getFieldValueOfDetailMap(showFieldConfig, detailMap, sourceField); + if (value == null) { + return; + } + resourceMap.get(resourceId).add(new BaseModuleFieldValue(showFieldConfig.getId(), value)); + }); + } + } + }); + // 提前获取大文本字段值 + List resourceFieldBlobs = getResourceFieldBlob(resourceIds); + // 处理子表格字段值 + setResourceSubFieldValue(resourceMap, fieldConfigMap, ListUtils.union(resourceFields, resourceFieldBlobs)); + if (!withBlob) { + return resourceMap; + } + resourceFieldBlobs.forEach(resourceFieldBlob -> { + if (resourceFieldBlob instanceof BaseResourceSubField subResourceField && StringUtils.isNotEmpty(subResourceField.getRefSubId())) { + return; + } + // 处理大文本 + if (resourceFieldBlob != null && resourceFieldBlob.getFieldValue() != null) { + BaseField fieldConfig = fieldConfigMap.get(resourceFieldBlob.getFieldId()); + if (fieldConfig == null) { + return; + } + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(fieldConfig.getType()); + Object objectValue = customFieldResolver.convertToValue(fieldConfig, resourceFieldBlob.getFieldValue().toString()); + + String resourceId = resourceFieldBlob.getResourceId(); + resourceMap.putIfAbsent(resourceId, new ArrayList<>()); + resourceMap.get(resourceId).add(new BaseModuleFieldValue(resourceFieldBlob.getFieldId(), objectValue)); + } + }); + return resourceMap; + } catch (Exception e) { + log.error(e.getMessage(), e); + return new HashMap<>(2); + } finally { + SourceDetailResolveContext.end(); + } + } + + /** + * 数据源业务字段引用值回显 + * + * @param details 详情集合 + * @param fields 表单字段集合 + * + * @return 字段值集合 + */ + @SuppressWarnings("unchecked") + public Map> setBusinessRefFieldValue( + List details, List fields, Map> resourceFieldMap) { + + List sourceBusinessFields = fields.stream() + .filter(field -> field instanceof DatasourceField sourceField && + CollectionUtils.isNotEmpty(sourceField.getShowFields()) && + StringUtils.isNotEmpty(sourceField.getBusinessKey())) + .collect(Collectors.toList()); + + if (CollectionUtils.isEmpty(sourceBusinessFields)) { + return resourceFieldMap; + } + + Map fieldConfigMap = fields.stream() + .collect(Collectors.toMap(BaseField::getId, f -> f, (prev, next) -> next)); + + // 按照数据源类型来分组 + Map> idsToFetch = new HashMap<>(FieldSourceType.values().length); + for (Object detail : details) { + Map detailMap = JSON.MAPPER.convertValue(detail, Map.class); + for (BaseField bsf : sourceBusinessFields) { + DatasourceField sourceField = (DatasourceField) bsf; + Object val = detailMap.get(sourceField.getBusinessKey()); + if (val == null || StringUtils.isEmpty(val.toString()) || + SourceDetailResolveContext.getSourceMap().containsKey(val.toString())) { + continue; + } + String dataSourceType = sourceField.getDataSourceType(); + idsToFetch.computeIfAbsent(dataSourceType, k -> new ArrayList<>()).add(val.toString()); + } + } + + SourceDetailResolveContext.start(); + + idsToFetch.forEach((dataSourceType, ids) -> { + List uniqueIds = ids.stream().distinct().collect(Collectors.toList()); + List sourceObjList; + FieldSourceType sourceType = FieldSourceType.safeValueOf(dataSourceType); + if (sourceType == FieldSourceType.CUSTOM_FORM) { + // 自定义表单:设置 formId 到 ThreadLocal + try { + CustomFormDataFieldService.setFormKey(dataSourceType); + sourceObjList = fieldSourceServiceProvider.batchGetSimpleByIds(sourceType, uniqueIds); + } finally { + CustomFormDataFieldService.clearFormKey(); + } + } else { + sourceObjList = fieldSourceServiceProvider.batchGetSimpleByIds(sourceType, uniqueIds); + } + if (CollectionUtils.isNotEmpty(sourceObjList)) { + sourceObjList.stream() + .filter(Objects::nonNull) + .map(sourceObj -> JSON.MAPPER.convertValue(sourceObj, Map.class)) + .filter(sourceMap -> sourceMap.containsKey("id")) + .forEach(sourceMap -> { + String sourceId = sourceMap.get("id").toString(); + if (uniqueIds.contains(sourceId)) { + SourceDetailResolveContext.put(sourceId, sourceMap); + } + }); + } + }); + + details.forEach(detail -> { + Map detailMap = JSON.MAPPER.convertValue(detail, Map.class); + sourceBusinessFields.forEach(bsf -> { + DatasourceField sourceField = (DatasourceField) bsf; + Object val = detailMap.get(sourceField.getBusinessKey()); + if (val == null) { + return; + } + Map sourceDetail = SourceDetailResolveContext.getSourceMap().get(val.toString()); + if (MapUtils.isEmpty(sourceDetail) || !detailMap.containsKey(SOURCE_DETAIL_ID)) { + return; + } + + sourceField.getShowFields().forEach(id -> { + BaseField showFieldConfig = fieldConfigMap.get(sourceField.getId() + REF_UNDERLINE + id); + if (showFieldConfig != null) { + String resourceId = detailMap.get(SOURCE_DETAIL_ID).toString(); + resourceFieldMap.putIfAbsent(resourceId, new ArrayList<>()); + Object showFieldValue = getFieldValueOfDetailMap(showFieldConfig, sourceDetail, sourceField); + if (showFieldValue != null) { + resourceFieldMap.get(resourceId).add(new BaseModuleFieldValue(showFieldConfig.getId(), showFieldValue)); + } + } + }); + }); + }); + + SourceDetailResolveContext.end(); + return resourceFieldMap; + } + + /** + * 获取详情中的字段值 + * + * @param field 字段信息 + * @param sourceDetailMap 来源详情 + * @param refSourceField 被引用的数据源字段信息 + * + * @return 字段值 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public Object getFieldValueOfDetailMap(BaseField field, Map sourceDetailMap, DatasourceField refSourceField) { + if (MapUtils.isEmpty(sourceDetailMap)) { + return null; + } + if (Strings.CI.equals(refSourceField.getDataSourceType(), FieldSourceType.BUSINESS_TITLE.name())) { + // 工商抬头数据源特殊处理 + String fieldKey = field.getId().replace((field.getResourceFieldId() + REF_UNDERLINE), StringUtils.EMPTY).replace("business_title_", StringUtils.EMPTY); + return sourceDetailMap.get(fieldKey); + } + if (StringUtils.isNotEmpty(field.getBusinessKey())) { + return sourceDetailMap.get(field.getBusinessKey()); + } + if (sourceDetailMap.containsKey(DETAIL_FIELD_PARAM_NAME)) { + List fvs = (List) sourceDetailMap.get(DETAIL_FIELD_PARAM_NAME); + if (CollectionUtils.isEmpty(fvs)) { + return null; + } + for (Map fv : fvs) { + String actualFieldId = field.getId().replace((field.getResourceFieldId() + REF_UNDERLINE), StringUtils.EMPTY); + if (Strings.CI.equals(actualFieldId, fv.get("fieldId").toString())) { + return fv.get("fieldValue"); + } + } + } + return null; + } + + /** + * 子字段值回显 + * + * @param targetId 显示的子字段 + * @param sourceDetail 来源详情 + * @param subKey 子表格业务Key + * @param rowKey 行Key + * + * @return 匹配值 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public Object matchSubFieldValueOfDetailMap(String targetId, Map sourceDetail, String subKey, String rowKey) { + if (MapUtils.isEmpty(sourceDetail) || !sourceDetail.containsKey(subKey)) { + return null; + } + // 子表格数据集合 + List> rows = (List) sourceDetail.get(subKey); + for (Map row : rows) { + // 匹配行数据 + if (row.containsKey(ROW_BIZ_ID) && Strings.CS.equals(rowKey, row.get(ROW_BIZ_ID).toString())) { + if (targetId.contains(REF_UNDERLINE)) { + return row.get(targetId.split(REF_UNDERLINE)[1]); + } else { + return row.get(targetId); + } + } + } + return null; + } + + /** + * 删除指定资源的模块字段值 + * + * @param resourceId 资源ID + */ + public void deleteByResourceId(String resourceId) { + T example = newResourceField(); + example.setResourceId(resourceId); + getResourceFieldMapper().delete(example); + + V blobExample = newResourceFieldBlob(); + blobExample.setResourceId(resourceId); + getResourceFieldBlobMapper().delete(blobExample); + } + + /** + * 删除指定资源的模块字段值 + * + * @param resourceIds 资源ID集合 + */ + public void deleteByResourceIds(List resourceIds) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(BaseResourceField::getResourceId, resourceIds); + getResourceFieldMapper().deleteByLambda(wrapper); + + LambdaQueryWrapper blobWrapper = new LambdaQueryWrapper<>(); + blobWrapper.in(BaseResourceField::getResourceId, resourceIds); + getResourceFieldBlobMapper().deleteByLambda(blobWrapper); + } + + /** + * 保存指定资源的模块字段值 + * + * @param moduleFieldValues 字段值集合 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public void saveModuleFieldByResourceIds(List resourceIds, List moduleFieldValues) { + if (CollectionUtils.isEmpty(moduleFieldValues)) { + return; + } + + Map fieldConfigMap = Objects.requireNonNull(CommonBeanFactory.getBean(ModuleFormService.class)) + .getAllFields(getFormKey(), OrganizationContext.getOrganizationId()) + .stream() + .collect(Collectors.toMap(BaseField::getId, Function.identity())); + + List customerFields = new ArrayList<>(); + List customerFieldBlobs = new ArrayList<>(); + moduleFieldValues.stream() + .filter(BaseModuleFieldValue::valid) + .forEach(fieldValue -> { + BaseField fieldConfig = fieldConfigMap.get(fieldValue.getFieldId()); + if (fieldConfig == null) { + return; + } + + // 获取字段解析器 + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(fieldConfig.getType()); + // 校验参数值 + customFieldResolver.validate(fieldConfig, fieldValue.getFieldValue()); + // 将参数值转换成字符串入库 + String strValue = customFieldResolver.convertToString(fieldConfig, fieldValue.getFieldValue()); + for (String resourceId : resourceIds) { + if (fieldConfig.isBlob()) { + customerFieldBlobs.add(supplyNewResource(this::newResourceFieldBlob, resourceId, fieldValue.getFieldId(), strValue)); + } else { + customerFields.add(supplyNewResource(this::newResourceField, resourceId, fieldValue.getFieldId(), strValue)); + } + } + }); + + if (CollectionUtils.isNotEmpty(customerFields)) { + getResourceFieldMapper().batchInsert(customerFields); + } + if (CollectionUtils.isNotEmpty(customerFieldBlobs)) { + getResourceFieldBlobMapper().batchInsert(customerFieldBlobs); + } + } + + /** + * 批量更新自定义字段值 + * + * @param request 批量编辑参数 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public void batchUpdateFieldValues(ResourceBatchEditRequest request, BaseField field, ModuleField moduleField) { + // 获取字段解析器 + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(field.getType()); + // 校验参数值 + customFieldResolver.validate(field, request.getFieldValue()); + // 将参数值转换成字符串入库 + String strValue = customFieldResolver.convertToString(field, request.getFieldValue()); + if (moduleField == null) { + throw new GenericException(Translator.get("module.field.not_exist")); + } + if (BaseField.isBlob(moduleField.getType())) { + List resourceFields = request.getIds().stream() + .map(id -> supplyNewResource(this::newResourceFieldBlob, id, request.getFieldId(), strValue)).collect(Collectors.toList()); + getResourceFieldBlobMapper().batchInsert(resourceFields); + } else { + List resourceFields = request.getIds().stream() + .map(id -> supplyNewResource(this::newResourceField, id, request.getFieldId(), strValue)).collect(Collectors.toList()); + getResourceFieldMapper().batchInsert(resourceFields); + } + } + + /** + * 批量删除自定义字段值 + * + * @param request 批量参数 + */ + public void batchDeleteFieldValues(ResourceBatchEditRequest request, ModuleField moduleField) { + if (moduleField == null) { + throw new GenericException(Translator.get("module.field.not_exist")); + } + if (BaseField.isBlob(moduleField.getType())) { + // 先删除 + LambdaQueryWrapper example = new LambdaQueryWrapper<>(); + example.eq(BaseResourceField::getFieldId, request.getFieldId()); + example.in(BaseResourceField::getResourceId, request.getIds()); + getResourceFieldBlobMapper().deleteByLambda(example); + } else { + // 先删除 + LambdaQueryWrapper example = new LambdaQueryWrapper<>(); + example.eq(BaseResourceField::getFieldId, request.getFieldId()); + example.in(BaseResourceField::getResourceId, request.getIds()); + getResourceFieldMapper().deleteByLambda(example); + } + } + + /** + * 更新自定义字段值 (MCP调用) + * + * @param resource 资源对象 + * @param originCustomerFields 原有字段值 + * @param moduleFields 新的字段值 + * @param orgId 组织ID + * @param userId 当前用户ID + * @param 资源类型 + */ + public void updateModuleFieldByAgent(K resource, List originCustomerFields, List moduleFields, String orgId, String userId) { + if (moduleFields == null) { + // 如果为 null,则不更新 + return; + } + if (CollectionUtils.isEmpty(originCustomerFields)) { + saveModuleField(resource, orgId, userId, moduleFields, false); + } else { + updateModuleField(resource, orgId, userId, moduleFields, true); + } + } + + /** + * 更新自定义字段值 + * + * @param resource 资源对象 + * @param orgId 组织ID + * @param userId 当前用户ID + * @param moduleFieldValues 字段值集合 + * @param update 是否更新操作 + * @param 资源类型 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public void updateModuleField(K resource, String orgId, String userId, List moduleFieldValues, boolean update) { + List allFields = Objects.requireNonNull(CommonBeanFactory.getBean(ModuleFormService.class)) + .getAllFields(getFormKey(), OrganizationContext.getOrganizationId()); + if (CollectionUtils.isEmpty(allFields)) { + return; + } + + String resourceId = (String) getResourceFieldValue(resource, "id"); + List resourceFields = getResourceField(List.of(resourceId)); + Map moduleFieldValueMap = moduleFieldValues.stream().collect(Collectors.toMap(BaseModuleFieldValue::getFieldId, t -> t)); + + // 校验业务字段,字段值是否重复 + businessFieldRepeatCheck(orgId, resource, List.of(resourceId), allFields); + List updateFields = new ArrayList<>(); + List updateBlobFields = new ArrayList<>(); + + resourceFields.forEach(resourceField -> { + if (moduleFieldValueMap.containsKey(resourceField.getFieldId())) { + BaseModuleFieldValue fieldValue = moduleFieldValueMap.get(resourceField.getFieldId()); + BaseField base = allFields.stream().filter(baseField -> baseField.getId().equals(resourceField.getFieldId())).findFirst().orElse(null); + if (base != null) { + if (base.needRepeatCheck()) { + checkUnique(fieldValue, base); + } + // 获取字段解析器 + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(base.getType()); + // 校验参数值 + customFieldResolver.validate(base, fieldValue.getFieldValue()); + // 将参数值转换成字符串入库 + String strValue = customFieldResolver.convertToString(base, fieldValue.getFieldValue()); + if (base.isBlob()) { + V fieldBlob = newResourceFieldBlob(); + fieldBlob.setId(resourceField.getId()); + fieldBlob.setResourceId(resourceField.getResourceId()); + fieldBlob.setFieldId(resourceField.getFieldId()); + fieldBlob.setFieldValue(strValue); + updateBlobFields.add(fieldBlob); + } else { + T field = newResourceField(); + field.setId(resourceField.getId()); + field.setResourceId(resourceField.getResourceId()); + field.setFieldId(resourceField.getFieldId()); + field.setFieldValue(strValue); + updateFields.add(field); + } + } + } + }); + Map resourceMap = resourceFields.stream().collect(Collectors.toMap(BaseResourceField::getFieldId, Function.identity())); + Map allbaseFieldMap = allFields.stream().collect(Collectors.toMap(BaseField::getId, Function.identity())); + List addlist = moduleFieldValues.stream().filter(moduleField -> + allbaseFieldMap.containsKey(moduleField.getFieldId()) && !resourceMap.containsKey(moduleField.getFieldId()) + ).toList(); + + saveModuleField(resource, orgId, userId, addlist, update); + updateData(updateFields, updateBlobFields); + } + + /** + * 批量更新字段值 + * + * @param updateFields 更新字段值 + * @param updateBlobFields 更新大字段值 + */ + private void updateData(List updateFields, List updateBlobFields) { + for (T resourceField : updateFields) { + getResourceFieldMapper().update(resourceField); + } + + for (V updateBlobField : updateBlobFields) { + getResourceFieldBlobMapper().update(updateBlobField); + } + } + + /** + * 处理子表格字段值 (查询) + * + * @param resourceMap 返回的资源字段值 + * @param fieldConfigMap 字段配置 + * @param resourceFields 值集合 + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private void setResourceSubFieldValue(Map> resourceMap, Map fieldConfigMap, + List resourceFields) { + Map subFieldMap = fieldConfigMap.values().stream().filter(f -> f instanceof SubField).collect(Collectors.toMap(BaseField::getId, Function.identity())); + if (!subFieldMap.isEmpty()) { + Set refSubSet = subFieldMap.keySet(); + List subFields = subFieldMap.values().stream().map(subField -> ((SubField) subField).getSubFields()).flatMap(List::stream).toList(); + Map subFieldConfigMap = subFields.stream().filter(f -> StringUtils.isEmpty(f.getResourceFieldId())).collect(Collectors.toMap(BaseField::idOrBusinessKey, f -> f, (f1, f2) -> f1)); + Map subFieldIdConfigMap = subFields.stream().collect(Collectors.toMap(BaseField::getId, f -> f, (f1, f2) -> f1)); + + Map> subResourceMap = resourceFields.stream() + .filter(r -> refSubSet.contains(((BaseResourceSubField) r).getRefSubId())) + .collect(Collectors.groupingBy(BaseResourceField::getResourceId)); + subResourceMap.forEach((resourceId, subResources) -> { + subResources.sort(Comparator.comparingInt(resource -> Integer.parseInt(((BaseResourceSubField) resource).getRowId())) + .thenComparing(resource -> Strings.CS.equals(((BaseResourceSubField) resource).getFieldId(), PRICE_SUB_ROW_KEY) ? 0 : 1)); + Map>> subFieldValueMap = new HashMap<>(8); + subResources.forEach(resource -> { + if (resource.getFieldValue() != null) { + BaseResourceSubField subResource = (BaseResourceSubField) resource; + subFieldValueMap.putIfAbsent(subResource.getRefSubId(), new ArrayList<>()); + int rowIndex = Integer.parseInt(subResource.getRowId()) - 1; + if (subFieldValueMap.get(subResource.getRefSubId()).size() <= rowIndex) { + Map initRowMap = new HashMap<>(8) {{ + put(ROW_BIZ_ID, subResource.getBizId()); + }}; + subFieldValueMap.get(subResource.getRefSubId()).add(initRowMap); + } + Map rowMap = subFieldValueMap.get(subResource.getRefSubId()).get(rowIndex); + if (Strings.CS.equals(resource.getFieldId(), PRICE_SUB_ROW_KEY)) { + rowMap.put(subResource.getFieldId(), resource.getFieldValue()); + return; + } + BaseField fieldConfig = subFieldConfigMap.get(resource.getFieldId()); + if (fieldConfig == null) { + return; + } + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(fieldConfig.getType()); + Object objectValue = null; + try { + objectValue = customFieldResolver.convertToValue(fieldConfig, resource.getFieldValue().toString()); + rowMap.put(subResource.getFieldId(), objectValue); + } catch (Exception e) { + log.error("Convert sub field value error: {}", e.getMessage()); + } + if (objectValue == null || !SourceDetailResolveContext.getSourceMap().containsKey(objectValue.toString())) { + return; + } + if (fieldConfig instanceof DatasourceField sourceField && CollectionUtils.isNotEmpty(sourceField.getShowFields())) { + // 处理展示列 + Map detailMap = SourceDetailResolveContext.getSourceMap().get(objectValue.toString()); + if (MapUtils.isEmpty(detailMap)) { + return; + } + sourceField.getShowFields().forEach(id -> { + BaseField showFieldConfig = subFieldIdConfigMap.get(sourceField.getId() + REF_UNDERLINE + id); + if (showFieldConfig == null) { + return; + } + if (StringUtils.isNotEmpty(showFieldConfig.getSubTableFieldId()) && rowMap.containsKey(PRICE_SUB_ROW_KEY)) { + Object matchVal = matchSubFieldValueOfDetailMap(showFieldConfig.idOrBusinessKey(), detailMap, BusinessModuleField.PRICE_PRODUCT_TABLE.getBusinessKey(), + rowMap.get(PRICE_SUB_ROW_KEY).toString()); + if (matchVal != null) { + rowMap.put(showFieldConfig.getId(), matchVal); + } + } else { + rowMap.put(showFieldConfig.getId(), getFieldValueOfDetailMap(showFieldConfig, detailMap, sourceField)); + } + }); + } + subFieldValueMap.get(subResource.getRefSubId()).set(rowIndex, rowMap); + } + }); + List subTableFieldValues = new ArrayList<>(); + subFieldValueMap.forEach((subFieldId, subFieldValues) -> { + BaseModuleFieldValue fieldValue = new BaseModuleFieldValue(); + fieldValue.setFieldId(subFieldId); + fieldValue.setFieldValue(subFieldValues); + subTableFieldValues.add(fieldValue); + }); + resourceMap.putIfAbsent(resourceId, new ArrayList<>()); + resourceMap.get(resourceId).addAll(subTableFieldValues); + }); + } + } + + /** + * 获取数据源详情上下文 + * + * @param flattenFields 平铺字段集合 + * @param resourceFields 资源字段值集合 + */ + @SuppressWarnings("unchecked") + private void getSourceDetailMapByIds(List flattenFields, List resourceFields) { + // 深度 > 1, 跳过, 不处理嵌套引用 [A -> B -> C] + if (SourceDetailResolveContext.getDepth() > 1) { + return; + } + + // 收集所有数据源字段的类型和ID映射关系 + var sourceIdType = flattenFields.stream() + .filter(f -> f instanceof DatasourceField ds && CollectionUtils.isNotEmpty(ds.getShowFields())) + .collect(Collectors.toMap( + BaseField::idOrBusinessKey, + f -> ((DatasourceField) f).getDataSourceType(), + (prev, next) -> next + )); + + // 按照数据源类型分组 + Map> groupedIds = new HashMap<>(FieldSourceType.values().length); + resourceFields.stream() + .filter(rf -> sourceIdType.containsKey(rf.getFieldId()) && rf.getFieldValue() != null) + .forEach(rf -> { + String value = rf.getFieldValue().toString(); + if (SourceDetailResolveContext.contains(value)) { + return; + } + groupedIds.computeIfAbsent(sourceIdType.get(rf.getFieldId()), k -> new ArrayList<>()).add(value); + }); + + // 批量查询每个类型的数据源详情 + groupedIds.forEach((dataSourceType, ids) -> { + if (CollectionUtils.isEmpty(ids)) { + return; + } + + // 去重 & 提前数据源详情上下文占位 (防止相互引用时无限递归 [A -> B -> A]) + List distinctIds = ids.stream().distinct().toList(); + distinctIds.forEach(SourceDetailResolveContext::putPlaceholder); + + // 批量查询 + FieldSourceType sourceType = FieldSourceType.safeValueOf(dataSourceType); + List details; + if (sourceType == FieldSourceType.CUSTOM_FORM) { + String formKey = getFormKey(); + // 自定义表单:设置form_key作为上下文 + try { + CustomFormDataFieldService.setFormKey(dataSourceType); + details = fieldSourceServiceProvider.batchGetSimpleByIds(sourceType, distinctIds); + } finally { + CustomFormDataFieldService.clearFormKey(); + if (StringUtils.isNotBlank(formKey)) { + // 设置父 formKey + CustomFormDataFieldService.setFormKey(formKey); + } + } + } else { + details = fieldSourceServiceProvider.batchGetSimpleByIds(sourceType, distinctIds); + } + if (CollectionUtils.isEmpty(details)) { + return; + } + + // 缓存上下文 + for (Object detail : details) { + if (detail == null) { + continue; + } + try { + // 通过反射获取ID字段 + Map detailMap = JSON.MAPPER.convertValue(detail, Map.class); + Object idValue = detailMap.get("id"); + if (idValue != null) { + SourceDetailResolveContext.put(idValue.toString(), detailMap); + } + } catch (Exception e) { + log.error("处理数据源详情异常: {}", e.getMessage()); + } + } + }); + } + + /** + * 处理临时附件 + */ + @SuppressWarnings("unchecked") + private void preProcessTempAttachment(String orgId, String resourceId, String userId, Object processValue) { + try { + List tmpPicIds = new ArrayList<>(); + if (processValue instanceof String) { + tmpPicIds.add(processValue.toString()); + } else if (processValue instanceof List) { + tmpPicIds.addAll((List) processValue); + } + AttachmentService attachmentService = CommonBeanFactory.getBean(AttachmentService.class); + UploadTransferRequest transferRequest = new UploadTransferRequest(orgId, resourceId, userId, tmpPicIds); + if (attachmentService != null) { + attachmentService.processTemp(transferRequest); + } + } catch (Exception e) { + log.error("临时附件处理失败: {}", e.getMessage()); + } + } + + /** + * 处理特殊的字段值 + * + * @param field 字段信息 + * @param fieldValueMap 字段值集合 + * @param update 是否更新操作 + * @param orgId 组织ID + * + * @return 字段值 + */ + private BaseModuleFieldValue processSpecialFieldValue(K resource, BaseField field, Map fieldValueMap, + Map serialNumCache, boolean update, String orgId) { + // 流水号 (需缓存, 公式字段要做占位替换) + if (field.isSerialNumber() && !update) { + if (StringUtils.isNotEmpty(field.getBusinessKey())) { + // 流水号作为业务字段, 从业务资源中获取前缀值 + Object serialNoPrefix = getResourceFieldValue(resource, field.getBusinessKey()); + String serialNo = serialNumGenerator.generateByRules(((SerialNumberField) field).getSerialNumberRules( + serialNoPrefix != null ? serialNoPrefix.toString().replace("${" + field.getName() + "}", StringUtils.EMPTY) : StringUtils.EMPTY), + orgId, getFormKey()); + // 业务流水号字段过长, SQL错误 + if (serialNo.length() > MAX_NUMBER_LENGTH) { + throw new GenericException(Translator.get("number.length.exceed")); + } + setResourceFieldValue(resource, field.getBusinessKey(), serialNo); + serialNumCache.put("${" + field.getName() + "}", serialNo); + return null; + } else { + // 流水号作为自定义字段, 需要统一生成. + BaseModuleFieldValue fieldValue = new BaseModuleFieldValue(); + fieldValue.setFieldId(field.getId()); + BaseModuleFieldValue fv = fieldValueMap.get(field.getId()); + String serialNo = serialNumGenerator.generateByRules(((SerialNumberField) field).getSerialNumberRules( + (fv != null && fv.getFieldValue() != null) ? fv.getFieldValue().toString().replace("${" + field.getName() + "}", StringUtils.EMPTY) : StringUtils.EMPTY), + orgId, getFormKey()); + fieldValue.setFieldValue(serialNo); + serialNumCache.put("${" + field.getName() + "}", serialNo); + return fieldValue; + } + } + + // 子表格 + if (field.isSubField()) { + BaseModuleFieldValue fieldValue = new BaseModuleFieldValue(); + fieldValue.setFieldId(field.getId()); + String businessKey = field.getBusinessKey(); + if (StringUtils.isNotEmpty(businessKey)) { + fieldValue.setFieldValue(fieldValueMap.get(businessKey).getFieldValue()); + } else { + fieldValue = fieldValueMap.get(field.getId()); + } + return fieldValue; + } + + // 公式字段 + if (field.includeFormula()) { + if (StringUtils.isNotEmpty(field.getBusinessKey())) { + // 业务字段直接替换, 公式字段的业务字段不入库. + Object serialNo = getResourceFieldValue(resource, field.getBusinessKey()); + if (serialNo != null) { + String strValue = replaceSerialNumberPlaceholder(serialNo.toString(), serialNumCache); + setResourceFieldValue(resource, field.getBusinessKey(), strValue); + } + return null; + } else { + // 自定义字段需要替换占位符&&入库. + BaseModuleFieldValue fieldValue = fieldValueMap.get(field.getId()); + if (fieldValue != null && fieldValue.getFieldValue() != null) { + String strValue = replaceSerialNumberPlaceholder(fieldValue.getFieldValue().toString(), serialNumCache); + fieldValue.setFieldValue(strValue); + } + return fieldValue; + } + } + + // 其他字段直接返回 + return fieldValueMap.get(field.getId()); + } + + /** + * 替换字符串中的流水号占位符 + * + * @param text 包含占位符的字符串,格式如:xxxx${fieldName} + * @param serialNumMap 流水号缓存Map + * + * @return 替换后的字符串,如果未找到对应流水号则保持不变 + */ + private String replaceSerialNumberPlaceholder(String text, Map serialNumMap) { + if (StringUtils.isEmpty(text) || MapUtils.isEmpty(serialNumMap)) { + return text; + } + String result = text; + for (Map.Entry entry : serialNumMap.entrySet()) { + String placeholder = entry.getKey(); + if (result.contains(placeholder)) { + result = result.replace(placeholder, entry.getValue()); + } + } + return result; + } + + /** + * 获取资源字段值 + * + * @param resourceId 资源ID + * @param fieldId 字段ID + * + * @return 字段值 + */ + public Object getResourceFieldValue(String resourceId, String fieldId) { + T example = newResourceField(); + example.setResourceId(resourceId); + example.setFieldId(fieldId); + T resourceField = getResourceFieldMapper().selectOne(example); + if (resourceField != null) { + return resourceField.getFieldValue(); + } + return null; + } + + /** + * 获取资源字段值集合 + * + * @param resourceIds 资源ID集合 + * + * @return 字段值集合 + */ + private List getResourceField(List resourceIds) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(BaseResourceField::getResourceId, resourceIds); + return getResourceFieldMapper().selectListByLambda(wrapper); + } + + /** + * 获取资源指定字段值集合 + * + * @param resourceIds 资源ID集合 + * @param fieldId 字段ID + * + * @return 指定字段值集合 + */ + private List getResourceField(List resourceIds, String fieldId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(BaseResourceField::getResourceId, resourceIds); + wrapper.eq(BaseResourceField::getFieldId, fieldId); + return getResourceFieldMapper().selectListByLambda(wrapper); + } + + /** + * 获取资源大字段值集合 + * + * @param resourceIds 资源ID集合 + * + * @return 大字段值集合 + */ + private List getResourceFieldBlob(List resourceIds) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(BaseResourceField::getResourceId, resourceIds); + return getResourceFieldBlobMapper().selectListByLambda(wrapper); + } + + /** + * 获取资源指定大字段值集合 + * + * @param resourceIds 资源ID集合 + * @param fieldId 大字段ID + * + * @return 指定大字段值集合 + */ + private List getResourceFieldBlob(List resourceIds, String fieldId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(BaseResourceField::getResourceId, resourceIds); + wrapper.eq(BaseResourceField::getFieldId, fieldId); + return getResourceFieldBlobMapper().selectListByLambda(wrapper); + } + + /** + * 提供新的字段或大字段实例 (通用&新增) + * + * @param instance 字段值实例 + * @param resourceId 资源ID + * @param fieldId 字段ID + * @param strValue 字段值 + * @param 字段类型 + * + * @return 字段实例 + */ + private R supplyNewResource(Supplier instance, String resourceId, String fieldId, String strValue) { + R resourceField = instance.get(); + resourceField.setId(IDGenerator.nextStr()); + resourceField.setResourceId(resourceId); + resourceField.setFieldId(fieldId); + resourceField.setFieldValue(strValue); + return resourceField; + } + + /** + * 创建新的资源字段实例 + * + * @return 资源字段实例 + */ + private T newResourceField() { + try { + return getResourceFieldClass().getConstructor().newInstance(); + } catch (Exception e) { + log.error(e.getMessage(), e); + throw new GenericException(e); + } + } + + /** + * 创建新的资源大字段实例 + * + * @return 资源大字段实例 + */ + private V newResourceFieldBlob() { + try { + return getResourceFieldBlobClass().getConstructor().newInstance(); + } catch (Exception e) { + log.error(e.getMessage(), e); + throw new GenericException(e); + } + } + + /** + * 字段值唯一性校验 + * + * @param fieldValue 字段值 + * @param field 字段信息 + */ + protected void checkUnique(BaseModuleFieldValue fieldValue, BaseField field) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(BaseResourceField::getFieldId, fieldValue.getFieldId()); + wrapper.eq(BaseResourceField::getFieldValue, fieldValue.getFieldValue()); + if (!getResourceFieldMapper().selectListByLambda(wrapper).isEmpty()) { + throw new GenericException(Translator.getWithArgs("common.field_value.repeat", field.getName())); + } + } + + /** + * 获取并校验字段是否存在 + * + * @param fieldId 字段ID + * @param organizationId 组织ID + * + * @return 字段信息 + */ + public BaseField getAndCheckField(String fieldId, String organizationId) { + List flattenFormFields = Objects.requireNonNull(CommonBeanFactory.getBean(ModuleFormService.class)). + getFlattenFormFields(getFormKey(), organizationId); + return flattenFormFields.stream().filter(f -> fieldId.equals(f.getId())) + .findFirst() + .orElseThrow(() -> new GenericException(Translator.get("module.field.not_exist"))); + } + + /** + * 校验业务字段值是否重复 + * + * @param orgId 组织ID + * @param resource 资源对象 + * @param updateIds 排除的ID集合 + * @param field 字段信息 + * @param fieldName 字段名称 + * @param 资源类型 + */ + private void businessFieldRepeatCheck(String orgId, K resource, List updateIds, BaseField field, String fieldName) { + if (!field.needRepeatCheck() || StringUtils.contains(field.getId(), REF_UNDERLINE)) { + return; + } + Class clazz = resource.getClass(); + String tableName = EntityTableMapper.generateTableName(clazz); + Object fieldValue = getResourceFieldValue(resource, fieldName); + if (isNotBlank(fieldValue)) { + boolean repeat; + if (CollectionUtils.isNotEmpty(updateIds)) { + repeat = commonMapper.checkUpdateExist(tableName, fieldName, fieldValue.toString(), orgId, updateIds); + } else { + repeat = commonMapper.checkAddExist(tableName, fieldName, fieldValue.toString(), orgId); + } + if (repeat) { + throw new GenericException(Translator.getWithArgs("common.field_value.repeat", field.getName())); + } + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/common/service/BaseResourceService.java b/backend/crm/src/main/java/cn/cordys/common/service/BaseResourceService.java new file mode 100644 index 0000000..56b0e0d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/service/BaseResourceService.java @@ -0,0 +1,246 @@ +package cn.cordys.common.service; + +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.domain.BaseResourceField; +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.common.resolver.field.ModuleFieldResolverFactory; +import cn.cordys.common.util.CaseFormatUtils; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.service.LogService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.data.util.ReflectionUtils; + +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 资源服务通用类 + * + * @author song-cc-rock + */ +@Slf4j +public abstract class BaseResourceService { + + @Resource + private LogService logService; + + /** + * 获取资源字段值 + * + * @param resource 资源对象 + * @param fieldName 字段名称 + * @param 资源类型 + * + * @return 值 + */ + protected Object getResourceFieldValue(K resource, String fieldName) { + Class clazz = resource.getClass(); + // 获取字段值 + Object fieldValue = null; + try { + fieldValue = clazz.getMethod("get" + CaseFormatUtils.capitalizeFirstLetter(fieldName)) + .invoke(resource); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + return fieldValue; + } + + /** + * 设置资源字段值 + * + * @param resource 资源对象 + * @param fieldName 字段名称 + * @param value 值 + * @param 资源类型 + */ + public void setResourceFieldValue(K resource, String fieldName, Object value) { + try { + if (value != null) { + Class clazz = resource.getClass(); + // 使用 ReflectionUtils 递归查找父类字段 + Field field = ReflectionUtils.findField(clazz, f -> Strings.CS.equals(f.getName(), fieldName)); + if (field != null) { + field.setAccessible(true); + // 数值类型转换 + if (value instanceof String strValue) { + Class type = field.getType(); + if (type.equals(BigDecimal.class)) { + value = new BigDecimal(strValue); + } else if (type.equals(Long.class)) { + value = Long.valueOf(strValue); + } else if (type.equals(Integer.class)) { + value = Integer.valueOf(strValue); + } + } else if (value instanceof Integer i) { + if (field.getType().equals(BigDecimal.class)) { + value = BigDecimal.valueOf(i); + } else if (field.getType().equals(Long.class)) { + value = Long.valueOf(i); + } + } + // 通过 Field 直接设置值,支持父类字段 + field.set(resource, value); + } + } + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + + /** + * 创建资源实例 + * + * @param clazz 资源类对象 + * @param 资源类型 + * + * @return 资源实例 + */ + protected K newInstance(Class clazz) { + K resource; + try { + resource = clazz.getConstructor().newInstance(); + } catch (Exception e) { + log.error(e.getMessage(), e); + throw new RuntimeException(e); + } + return resource; + } + + /** + * 判断值非空 + * + * @param value 值 + * + * @return 是否非空 + */ + @SuppressWarnings("rawtypes") + protected boolean isNotBlank(Object value) { + switch (value) { + case null -> { + return false; + } + case String str -> { + return StringUtils.isNotBlank(str); + } + case List list -> { + return CollectionUtils.isNotEmpty(list); + } + default -> { + return true; + } + } + } + + /** + * 记录自定义字段批量更新日志 + * + * @param originResourceList 资源集合 + * @param originFields 旧的字段值 + * @param field 字段信息 + * @param request 请求参数 + * @param userId 用户ID + * @param orgId 组织ID + * @param 资源类型 + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + protected void addCustomFieldBatchUpdateLog(List originResourceList, + List originFields, + ResourceBatchEditRequest request, + BaseField field, + String logModule, + String userId, + String orgId) { + Map fieldMap = originFields.stream() + .collect(Collectors.toMap(BaseResourceField::getResourceId, Function.identity())); + // 记录日志 + List logs = originResourceList.stream() + .map(resource -> { + Object id = getResourceFieldValue(resource, "id"); + Object name = getResourceFieldValue(resource, "name"); + Object customFormId = getResourceFieldValue(resource, "customFormId"); + + BaseResourceField baseResourceField = fieldMap.get(id.toString()); + Map originResource = new HashMap(); + if (baseResourceField != null && isNotBlank(baseResourceField.getFieldValue())) { + // 获取字段解析器 + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(field.getType()); + // 将数据库中的字符串值,转换为对应的对象值 + Object objectValue = customFieldResolver.convertToValue(field, baseResourceField.getFieldValue().toString()); + baseResourceField.setFieldValue(objectValue); + originResource.put(request.getFieldId(), baseResourceField.getFieldValue()); + } + + Map modifiedResource = new HashMap(); + if (isNotBlank(request.getFieldValue())) { + modifiedResource.put(request.getFieldId(), request.getFieldValue()); + } + + if (customFormId != null) { + originResource.put("customFormId", customFormId); + modifiedResource.put("customFormId", customFormId); + } + + LogDTO logDTO = new LogDTO(orgId, id.toString(), userId, LogType.UPDATE, logModule, name.toString()); + logDTO.setOriginalValue(originResource); + logDTO.setModifiedValue(modifiedResource); + return logDTO; + }).toList(); + + logService.batchAdd(logs); + } + + /** + * 记录业务字段批量更新日志 + * + * @param originResourceList 资源集合 + * @param field 字段信息 + * @param request 请求参数 + * @param userId 用户ID + * @param orgId 组织ID + * @param 资源类型 + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + protected void addBusinessFieldBatchUpdateLog(List originResourceList, + BaseField field, + ResourceBatchEditRequest request, + String logModule, + String userId, + String orgId) { + // 记录日志 + List logs = originResourceList.stream() + .map(resource -> { + Map originResource = new HashMap(); + if (isNotBlank(getResourceFieldValue(resource, field.getBusinessKey()))) { + originResource.put(field.getBusinessKey(), getResourceFieldValue(resource, field.getBusinessKey())); + } + + Map modifiedResource = new HashMap(); + if (isNotBlank(request.getFieldValue())) { + modifiedResource.put(field.getBusinessKey(), request.getFieldValue()); + } + + Object id = getResourceFieldValue(resource, "id"); + Object name = getResourceFieldValue(resource, "name"); + + LogDTO logDTO = new LogDTO(orgId, id.toString(), userId, LogType.UPDATE, logModule, name.toString()); + logDTO.setOriginalValue(originResource); + logDTO.setModifiedValue(modifiedResource); + return logDTO; + }).toList(); + + logService.batchAdd(logs); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/common/service/BaseService.java b/backend/crm/src/main/java/cn/cordys/common/service/BaseService.java new file mode 100644 index 0000000..b20ff8b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/service/BaseService.java @@ -0,0 +1,878 @@ +package cn.cordys.common.service; + +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.UserDeptDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.approval.constants.ApprovalNodeTypeEnum; +import cn.cordys.crm.approval.constants.ApprovalStatus; +import cn.cordys.crm.approval.constants.ApprovalTaskType; +import cn.cordys.crm.approval.domain.ApprovalInstance; +import cn.cordys.crm.approval.domain.ApprovalNode; +import cn.cordys.crm.approval.domain.ApprovalNodeLink; +import cn.cordys.crm.approval.domain.ApprovalTask; +import cn.cordys.crm.approval.mapper.ExtApprovalInstanceMapper; +import cn.cordys.crm.approval.service.ApprovalFlowService; +import cn.cordys.crm.approval.service.ApprovalInstanceService; +import cn.cordys.crm.clue.mapper.ExtClueMapper; +import cn.cordys.crm.customer.mapper.ExtCustomerContactMapper; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityMapper; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.domain.ModuleField; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.SubField; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.dto.response.UserResponse; +import cn.cordys.crm.system.mapper.ExtAttachmentMapper; +import cn.cordys.crm.system.mapper.ExtModuleFieldMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationUserMapper; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.Method; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * @author jianxing + * @date 2025-01-03 12:01:54 + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class BaseService { + @Resource + private ExtUserMapper extUserMapper; + @Resource + private BaseMapper userMapper; + @Resource + private ExtCustomerContactMapper extCustomerContactMapper; + @Resource + private ExtOrganizationUserMapper extOrganizationUserMapper; + @Resource + private ExtCustomerMapper extCustomerMapper; + @Resource + private ExtOpportunityMapper extOpportunityMapper; + @Resource + private ExtClueMapper extClueMapper; + @Resource + private ExtModuleFieldMapper extModuleFieldMapper; + @Resource + private BaseMapper moduleFieldMapper; + @Resource + private ExtAttachmentMapper extAttachmentMapper; + @Resource + private ApprovalInstanceService approvalInstanceService; + @Resource + private BaseMapper nodeLinkMapper; + @Resource + private BaseMapper approvalNodeMapper; + @Resource + private BaseMapper approvalTaskMapper; + @Lazy + @Resource + private ApprovalFlowService approvalFlowService; + @Resource + private ExtApprovalInstanceMapper extApprovalInstanceMapper; + + + /** + * 设置创建人和更新人名称 + * + * @param object + * @param + * @return + */ + public T setCreateAndUpdateUserName(T object) { + return setCreateAndUpdateUserName(List.of(object)).getFirst(); + } + + /** + * 设置创建人和更新人名称 + * + * @param list + * @param + * @return + */ + public List setCreateAndUpdateUserName(List list) { + if (CollectionUtils.isEmpty(list)) { + return list; + } + try { + + Class clazz = list.getFirst().getClass(); + Method setCreateUserName = clazz.getMethod("setCreateUserName", String.class); + Method setUpdateUserName = clazz.getMethod("setUpdateUserName", String.class); + Method getCreateUser = clazz.getMethod("getCreateUser"); + Method getUpdateUser = clazz.getMethod("getUpdateUser"); + + Set userIds = new HashSet<>(); + for (T role : list) { + userIds.add((String) getCreateUser.invoke(role)); + userIds.add((String) getUpdateUser.invoke(role)); + } + + Map userNameMap = getUserNameMap(userIds); + for (T item : list) { + String createUserId = (String) getCreateUser.invoke(item); + String updateUserId = (String) getUpdateUser.invoke(item); + + String createUserName = getAndCheckOptionName(userNameMap.get(createUserId)); + String updateUserName = getAndCheckOptionName(userNameMap.get(updateUserId)); + + setCreateUserName.invoke(item, createUserName); + setUpdateUserName.invoke(item, updateUserName); + } + } catch (Exception e) { + throw new GenericException(e); + } + return list; + } + + /** + * 设置创建人、更新人和责任人名称 + * + * @param object + * @param + * @return + */ + public T setCreateUpdateOwnerUserName(T object) { + return setCreateUpdateOwnerUserName(List.of(object)).getFirst(); + } + + /** + * 设置创建人、更新人和责任人名称 + * + * @param object + * @param + * @return + */ + public T setCreateAndUpdateOwnerUserName(T object) { + return setCreateUpdateOwnerUserName(List.of(object)).getFirst(); + } + + /** + * 设置创建人、更新人和责任人名称 + * + * @param list + * @param + * @return + */ + public List setCreateUpdateOwnerUserName(List list) { + if (CollectionUtils.isEmpty(list)) { + return list; + } + try { + + Class clazz = list.getFirst().getClass(); + Method setCreateUserName = clazz.getMethod("setCreateUserName", String.class); + Method setUpdateUserName = clazz.getMethod("setUpdateUserName", String.class); + Method setOwnerName = clazz.getMethod("setOwnerName", String.class); + Method getCreateUser = clazz.getMethod("getCreateUser"); + Method getUpdateUser = clazz.getMethod("getUpdateUser"); + Method getOwner = clazz.getMethod("getOwner"); + + Set userIds = new HashSet<>(); + for (T role : list) { + userIds.add((String) getCreateUser.invoke(role)); + userIds.add((String) getUpdateUser.invoke(role)); + userIds.add((String) getOwner.invoke(role)); + } + + Map userNameMap = getUserNameMap(userIds); + for (T item : list) { + + String createUserId = (String) getCreateUser.invoke(item); + String updateUserId = (String) getUpdateUser.invoke(item); + String ownerId = (String) getOwner.invoke(item); + + String createUserName = getAndCheckOptionName(userNameMap.get(createUserId)); + String updateUserName = getAndCheckOptionName(userNameMap.get(updateUserId)); + String ownerName = getAndCheckOptionName(userNameMap.get(ownerId)); + + setCreateUserName.invoke(item, createUserName); + setUpdateUserName.invoke(item, updateUserName); + setOwnerName.invoke(item, ownerName); + } + } catch (Exception e) { + throw new GenericException(e); + } + return list; + } + + /** + * 根据用户ID列表,获取用户ID和名称的映射 + * + * @param userIds + * @return + */ + public Map getUserNameMap(List userIds) { + if (CollectionUtils.isEmpty(userIds)) { + return Collections.emptyMap(); + } + return extUserMapper.selectUserOptionByIds(userIds) + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + } + + public String getUserName(String userId) { + if (StringUtils.isBlank(userId)) { + return null; + } + User user = userMapper.selectByPrimaryKey(userId); + if (user != null) { + return user.getName(); + } + return null; + } + + /** + * 根据用户ID列表,获取用户ID和名称的映射 + * + * @param userIds + * @return + */ + public Map getUserNameMap(Set userIds) { + return getUserNameMap(new ArrayList<>(userIds)); + } + + public Map getUserDeptMapByUserIds(Set ownerIds, String orgId) { + return getUserDeptMapByUserIds(new ArrayList<>(ownerIds), orgId); + } + + public UserDeptDTO getUserDeptMapByUserId(String ownerId, String orgId) { + List userDeptList = extUserMapper.getUserDeptByUserIds(List.of(ownerId), orgId); + return CollectionUtils.isEmpty(userDeptList) ? null : userDeptList.getFirst(); + } + + public Map getUserDeptMapByUserIds(List ownerIds, String orgId) { + if (CollectionUtils.isEmpty(ownerIds)) { + return Collections.emptyMap(); + } + return extUserMapper.getUserDeptByUserIds(ownerIds, orgId) + .stream() + .collect(Collectors.toMap(UserDeptDTO::getUserId, Function.identity())); + } + + + /** + * 获取联系人ID和名称的映射 + * + * @param contactIds + * @return + */ + public Map getContactMap(List contactIds) { + if (CollectionUtils.isEmpty(contactIds)) { + return Collections.emptyMap(); + } + return extCustomerContactMapper.selectContactOptionByIds(contactIds) + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + } + + + public Map getUserDepAndPhoneByUserIds(List ownerIds, String orgId) { + if (CollectionUtils.isEmpty(ownerIds)) { + return Collections.emptyMap(); + } + List userResponseList = extOrganizationUserMapper.getUserDepAndPhoneByUserIds(ownerIds, orgId); + return userResponseList.stream().collect(Collectors.toMap(UserResponse::getUserId, Function.identity())); + } + + public void handleAddLog(T resource, List moduleFields) { + Map resourceLog = JSON.parseToMap(JSON.toJSONString(resource)); + + if (moduleFields != null) { + moduleFields.stream() + .filter(BaseModuleFieldValue::valid) + .forEach(field -> + resourceLog.put(field.getFieldId(), field.getFieldValue()) + ); + } + + writeAddLogContext(resource, resourceLog); + } + + /** + * 新增日志, 替换资源名称 + * @param resource 资源信息 + * @param moduleFields 自定义字段 + * @param 资源类型 + */ + public void handleAddLogWithResourceName(T resource, List moduleFields) { + Map resourceLog = JSON.parseToMap(JSON.toJSONString(resource)); + + if (moduleFields != null) { + moduleFields.stream() + .filter(BaseModuleFieldValue::valid) + .forEach(field -> + resourceLog.put(field.getFieldId(), field.getFieldValue()) + ); + } + + writeAddLogContextWithResourceName(resource, resourceLog); + } + + public void handleAddLogWithSubTable( + T resource, + List moduleFields, + String subTableKeyName, + ModuleFormConfigDTO moduleFormConfigDTO) { + + Map resourceLog = JSON.parseToMap(JSON.toJSONString(resource)); + Set subRefKey = getSubTableRefIds(moduleFormConfigDTO); + if (moduleFields != null) { + Map fieldNameMap = getFieldNameMap(moduleFields, moduleFormConfigDTO); + List validFields = moduleFields.stream() + .filter(BaseModuleFieldValue::valid) + .toList(); + Map subTableIdKeyMap = getSubTableIdKeyMap(moduleFormConfigDTO); + fillResourceLog(resourceLog, validFields, fieldNameMap, subTableIdKeyMap, subTableKeyName, subRefKey, moduleFormConfigDTO); + } + + writeAddLogContextWithResourceName(resource, resourceLog); + } + + + /** + * 写入操作日志上下文 + */ + private void writeAddLogContext(T resource, Map resourceLog) { + try { + Method idGetter = resource.getClass().getMethod("getId"); + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId((String) idGetter.invoke(resource)) + .modifiedValue(resourceLog) + .build() + ); + } catch (Exception e) { + throw new GenericException(e); + } + } + + /** + * 写入操作日志上下文,替换资源名称 (参数资源名存在占位符) + * @param resource 资源 + * @param resourceLog 日志信息 + * @param 资源类型 + */ + private void writeAddLogContextWithResourceName(T resource, Map resourceLog) { + try { + Method idGetter = resource.getClass().getMethod("getId"); + Method nameGetter = resource.getClass().getMethod("getName"); + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId((String) idGetter.invoke(resource)) + .resourceName((String) nameGetter.invoke(resource)) + .modifiedValue(resourceLog) + .build() + ); + } catch (Exception e) { + throw new GenericException(e); + } + } + + public void handleUpdateLog( + T originResource, + T modifiedResource, + List originResourceFields, + List modifiedResourceFields, + String id, + String name) { + + Map originResourceLog = JSON.parseToMap(JSON.toJSONString(originResource)); + Map modifiedResourceLog = JSON.parseToMap(JSON.toJSONString(modifiedResource)); + + // 收集所有字段ID,检测附件类型字段 + Set allFieldIds = new HashSet<>(); + if (originResourceFields != null) { + originResourceFields.stream() + .filter(BaseModuleFieldValue::valid) + .forEach(field -> allFieldIds.add(field.getFieldId())); + } + if (modifiedResourceFields != null) { + modifiedResourceFields.stream() + .filter(BaseModuleFieldValue::valid) + .forEach(field -> allFieldIds.add(field.getFieldId())); + } + Set attachmentFieldIds = filterAttachmentFieldIds(allFieldIds); + + // 添加原始字段值(附件类型字段将ID替换为名称) + if (originResourceFields != null) { + originResourceFields.stream() + .filter(BaseModuleFieldValue::valid) + .forEach(field -> { + Object value = field.getFieldValue(); + if (attachmentFieldIds.contains(field.getFieldId())) { + value = replaceAttachmentIdsWithNames(value); + } + originResourceLog.put(field.getFieldId(), value); + }); + } + + // 添加修改后的字段值(过滤无效字段,附件类型字段将ID替换为名称) + if (modifiedResourceFields != null) { + modifiedResourceFields.stream() + .filter(BaseModuleFieldValue::valid) + .forEach(field -> { + Object value = field.getFieldValue(); + if (attachmentFieldIds.contains(field.getFieldId())) { + value = replaceAttachmentIdsWithNames(value); + } + modifiedResourceLog.put(field.getFieldId(), value); + }); + } + + try { + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(id) + .resourceName(name) + .originalValue(originResourceLog) + .modifiedValue(modifiedResourceLog) + .build() + ); + } catch (Exception e) { + throw new GenericException(e); + } + } + + + public void handleUpdateLogWithSubTable( + T originResource, + T modifiedResource, + List originResourceFields, + List modifiedResourceFields, + String id, + String name, + String subTableKeyName, + ModuleFormConfigDTO moduleFormConfigDTO) { + + Map originResourceLog = JSON.parseToMap(JSON.toJSONString(originResource)); + Map modifiedResourceLog = JSON.parseToMap(JSON.toJSONString(modifiedResource)); + Set subRefKey = getSubTableRefIds(moduleFormConfigDTO); + + // 从表单配置中获取附件类型字段ID集合 + Set attachmentFieldIds = getAttachmentFieldIdsFromFormConfig(moduleFormConfigDTO); + + if (CollectionUtils.isNotEmpty(originResourceFields)) { + Map oldFieldNameMap = getFieldNameMap(originResourceFields, moduleFormConfigDTO); + List validFields = originResourceFields.stream() + .filter(BaseModuleFieldValue::valid) + .toList(); + Map subTableIdKeyMap = getSubTableIdKeyMap(moduleFormConfigDTO); + fillResourceLog(originResourceLog, validFields, oldFieldNameMap, subTableIdKeyMap, subTableKeyName, subRefKey, moduleFormConfigDTO); + } + + if (CollectionUtils.isNotEmpty(modifiedResourceFields)) { + Map newFieldNameMap = getFieldNameMap(modifiedResourceFields, moduleFormConfigDTO); + List validFields = modifiedResourceFields.stream() + .filter(BaseModuleFieldValue::valid) + .toList(); + Map subTableIdKeyMap = getSubTableIdKeyMap(moduleFormConfigDTO); + fillResourceLog(modifiedResourceLog, validFields, newFieldNameMap, subTableIdKeyMap, subTableKeyName, subRefKey, moduleFormConfigDTO); + } + + // 将附件字段中的附件ID替换为附件名称 + replaceAttachmentValuesInLog(originResourceLog, attachmentFieldIds); + replaceAttachmentValuesInLog(modifiedResourceLog, attachmentFieldIds); + + try { + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(id) + .resourceName(name) + .originalValue(originResourceLog) + .modifiedValue(modifiedResourceLog) + .build() + ); + } catch (Exception e) { + throw new GenericException(e); + } + } + + /** + * 处理普通字段 + 子表字段 + */ + private void fillResourceLog( + Map resourceLog, + List fields, + Map fieldNameMap, + Map subTableKeyMap, + String subTableKeyName, + Set subRefKey, + ModuleFormConfigDTO moduleFormConfigDTO) { + fields.forEach(field -> { + String fieldId = field.getFieldId(); + // 普通字段 + if (!subTableKeyMap.containsKey(fieldId) && !subTableKeyMap.containsValue(fieldId)) { + resourceLog.put(fieldId, field.getFieldValue()); + return; + } + + // 子表字段 + List> subTableList = + JSON.parseArray(JSON.toJSONString(field.getFieldValue()), new TypeReference<>() { + }); + if (CollectionUtils.isEmpty(subTableList)) { + return; + } + AtomicReference subTableName = new AtomicReference<>(moduleFormConfigDTO.getFields().stream().filter(BaseField::isSubField).filter(subField -> subField.getId().equals(fieldId)).findFirst().map(BaseField::getName).orElse(subTableKeyName)); + if (Strings.CI.equals(fieldId, "products")) { + subTableKeyMap.forEach((key, value) -> { + if (StringUtils.isNotBlank(value) && Strings.CI.equals(value, fieldId)) + subTableName.set(moduleFormConfigDTO.getFields().stream().filter(BaseField::isSubField).filter(subField -> subField.getId().equals(key)).findFirst().map(BaseField::getName).orElse(subTableKeyName)); + }); + } + + + int size = subTableList.size(); + for (int i = 0; i < size; i++) { + Map row = subTableList.get(i); + if (size > 1) { + int finalI = i; + String finalSubTableName1 = subTableName.get(); + row.forEach((key, value) -> { + if (!fieldNameMap.containsKey(key) || subRefKey.contains(key)) { + return; + } + resourceLog.put(finalSubTableName1 + "-" + fieldNameMap.get(key) + "-" + Translator.get("row") + (finalI + 1) + "-" + key, value); + }); + } else { + String finalSubTableName = subTableName.get(); + row.forEach((key, value) -> { + if (!fieldNameMap.containsKey(key) || subRefKey.contains(key)) { + return; + } + resourceLog.put(finalSubTableName + "-" + fieldNameMap.get(key) + "-" + key, value); + }); + } + } + }); + } + + + /** + * 字段 ID → 字段名称 映射表 + */ + private Map getFieldNameMap(List fields, ModuleFormConfigDTO moduleFormConfigDTO) { + List fieldIds = new ArrayList<>(fields.stream() + .map(BaseModuleFieldValue::getFieldId) + .distinct() + .toList()); + List fieldOptions = extModuleFieldMapper.getSourceOptionsByIds("sys_module_field", fieldIds); + Map nameMap = fieldOptions.stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + + if (CollectionUtils.isNotEmpty(moduleFormConfigDTO.getFields())) { + for (BaseField field : moduleFormConfigDTO.getFields()) { + if (field.isSubField()) { + if (field instanceof SubField objectSubField) { + objectSubField.getSubFields().forEach(subField -> { + nameMap.put(StringUtils.isNotBlank(subField.getBusinessKey()) ? subField.getBusinessKey() : subField.getId(), subField.getName()); + }); + } + } + + } + } + return nameMap; + } + + public Map getSubTableIdKeyMap(ModuleFormConfigDTO moduleFormConfigDTO) { + Map subTableIdKeyMap = new HashMap<>(); + if (CollectionUtils.isNotEmpty(moduleFormConfigDTO.getFields())) { + for (BaseField field : moduleFormConfigDTO.getFields()) { + if (field.isSubField()) { + subTableIdKeyMap.put(field.getId(), field.getBusinessKey()); + } + } + } + return subTableIdKeyMap; + + } + + /** + * 获取子表引用字段ID集合 + * + * @param formConfig 表单陪配置 + * @return 子表引用字段ID集合 + */ + private Set getSubTableRefIds(ModuleFormConfigDTO formConfig) { + if (CollectionUtils.isNotEmpty(formConfig.getFields())) { + Optional subOptional = formConfig.getFields().stream().filter(BaseField::isSubField).findAny(); + if (subOptional.isPresent()) { + SubField subField = (SubField) subOptional.get(); + return subField.getSubFields().stream() + .filter(f -> StringUtils.isNotBlank(f.getResourceFieldId())) + .map(BaseField::getId) + .collect(Collectors.toSet()); + } + } + return Collections.emptySet(); + } + + + /** + * 客户id与名称映射 + * + * @param customerIds + * @return + */ + public Map getCustomerMap(List customerIds) { + if (CollectionUtils.isEmpty(customerIds)) { + return Collections.emptyMap(); + } + return extCustomerMapper.getCustomerOptionsByIds(customerIds) + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + } + + /** + * 商机id与名称映射 + * + * @param opportunityIds + * @return + */ + public Map getOpportunityMap(List opportunityIds) { + if (CollectionUtils.isEmpty(opportunityIds)) { + return Collections.emptyMap(); + } + return extOpportunityMapper.getOpportunityOptionsByIds(opportunityIds) + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + } + + + /** + * 线索id与名称映射 + * + * @param clueIds + * @return + */ + public Map getClueMap(List clueIds) { + if (CollectionUtils.isEmpty(clueIds)) { + return Collections.emptyMap(); + } + return extClueMapper.selectOptionByIds(clueIds) + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + + } + + /** + * 联系人id和电话映射 + * + * @param contactIds + * @return + */ + public Map getContactPhone(List contactIds) { + if (CollectionUtils.isEmpty(contactIds)) { + return Collections.emptyMap(); + } + return extCustomerContactMapper.selectContactPhoneOptionByIds(contactIds) + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + } + + public String getAndCheckOptionName(String option) { + return option == null ? Translator.get("common.option.not_exist") : option; + } + + /** + * 获取审批中的业务资源是否第一个审批节点已通过 + * @param resourceIds 资源ID集合 + * @return 是否第一个审批节点已通过 + */ + public Map getApprovingResourceFirstNodeApproved(List resourceIds, String currentOrgId) { + if (CollectionUtils.isEmpty(resourceIds)) { + return Map.of(); + } + Map firstNodeApproved = new HashMap<>(resourceIds.size()); + List latestInstances = approvalInstanceService.getLatestInstances(resourceIds); + if (CollectionUtils.isEmpty(latestInstances)) { + return Map.of(); + } + List flowVersionIds = latestInstances.stream().map(ApprovalInstance::getFlowVersionId).distinct().toList(); + List nodeLinks = nodeLinkMapper.selectListByLambda(new LambdaQueryWrapper().in(ApprovalNodeLink::getFlowVersionId, flowVersionIds)); + List allNodes = approvalNodeMapper.selectListByLambda(new LambdaQueryWrapper().in(ApprovalNode::getFlowVersionId, flowVersionIds)); + List allTasks = approvalTaskMapper.selectListByLambda(new LambdaQueryWrapper().in(ApprovalTask::getInstanceId, latestInstances.stream().map(ApprovalInstance::getId).toList())); + Map nodeTypeMap = allNodes.stream().collect(Collectors.toMap(ApprovalNode::getId, ApprovalNode::getNodeType)); + Map> nodeLinkMap = nodeLinks.stream().filter(nodeLink -> nodeTypeMap.containsKey(nodeLink.getToNodeId()) && ApprovalNodeTypeEnum.valueOf(nodeTypeMap.get(nodeLink.getToNodeId())) != ApprovalNodeTypeEnum.END) + .collect(Collectors.groupingBy(ApprovalNodeLink::getToNodeId, Collectors.mapping(ApprovalNodeLink::getFromNodeId, Collectors.toList()))); + latestInstances.forEach(latestInstance -> { + if (isFirstApproverNode(nodeLinkMap, nodeTypeMap, latestInstance.getCurrentNodeId())) { + // 当前审批实例处于第一个审批节点, 所以第一个节点为审批中 + boolean multiApprover = approvalFlowService.isCurrentNodeMultiApprover(latestInstance.getCurrentNodeId(), latestInstance.getSubmitterId(), currentOrgId); + if (!multiApprover) { + // 单人审批, 判断是否存在加签通过 + Integer nodeRound = extApprovalInstanceMapper.getNodeRound(latestInstance.getId(), latestInstance.getCurrentNodeId()); + Optional signApproved = allTasks.stream().filter(task -> Strings.CI.equals(task.getType(), ApprovalTaskType.SN.name()) && Strings.CI.equals(task.getNodeId(), latestInstance.getCurrentNodeId()) + && Strings.CI.equals(task.getInstanceId(), latestInstance.getId()) && nodeRound.equals(task.getNodeRound()) && Strings.CI.equals(task.getStatus(), ApprovalStatus.APPROVED.name())).findAny(); + if (signApproved.isPresent()) { + firstNodeApproved.put(latestInstance.getResourceId(), true); + return; + } + } + firstNodeApproved.put(latestInstance.getResourceId(), false); + } else { + // 当前审批实例不是处于第一个审批节点, 所以第一个节点已完成 + firstNodeApproved.put(latestInstance.getResourceId(), true); + } + }); + return firstNodeApproved; + } + + + /** + * 当前节点是否第一个审批节点 + * @param nodeLinkMap 节点链接信息(toNodeId -> fromNodeId列表) + * @param nodeTypeMap 节点类型集合 + * @param currentNodeId 当前节点ID + * @return 是否第一个审批节点 + */ + private boolean isFirstApproverNode(Map> nodeLinkMap, Map nodeTypeMap, String currentNodeId) { + return isFirstApproverNodeRecursive(nodeLinkMap, nodeTypeMap, currentNodeId, new HashSet<>()); + } + + /** + * 递归判断当前节点是否第一个审批节点 + * @param nodeLinkMap 节点链接信息 + * @param nodeTypeMap 节点类型集合 + * @param nodeId 当前节点ID + * @param visited 已访问的节点集合(防止循环) + * @return 是否第一个审批节点 + */ + private boolean isFirstApproverNodeRecursive(Map> nodeLinkMap, Map nodeTypeMap, String nodeId, Set visited) { + // 防止循环引用 + if (visited.contains(nodeId)) { + return false; + } + visited.add(nodeId); + + // 获取当前节点的前驱节点列表 + List preNodeIds = nodeLinkMap.get(nodeId); + if (CollectionUtils.isEmpty(preNodeIds)) { + // 没有前驱节点,说明是第一个审批节点 + return true; + } + + // 遍历所有前驱节点 + for (String preNodeId : preNodeIds) { + if (StringUtils.isBlank(preNodeId)) { + continue; + } + String nodeType = nodeTypeMap.get(preNodeId); + if (StringUtils.isBlank(nodeType)) { + continue; + } + ApprovalNodeTypeEnum nodeTypeEnum = ApprovalNodeTypeEnum.valueOf(nodeType); + if (nodeTypeEnum == ApprovalNodeTypeEnum.START) { + // 找到 START 节点,说明当前节点不是第一个审批节点 + return true; + } + if (nodeTypeEnum == ApprovalNodeTypeEnum.CONDITION || nodeTypeEnum == ApprovalNodeTypeEnum.DEFAULT) { + // 继续往前找 + boolean isFirst = isFirstApproverNodeRecursive(nodeLinkMap, nodeTypeMap, preNodeId, visited); + if (isFirst) { + return true; + } + } + } + return false; + } + + /** + * 从字段ID集合中筛选出附件类型的字段ID + * + * @param fieldIds 字段ID集合 + * @return 附件类型字段ID集合 + */ + private Set filterAttachmentFieldIds(Set fieldIds) { + if (fieldIds.isEmpty()) { + return Collections.emptySet(); + } + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.in(ModuleField::getId, new ArrayList<>(fieldIds)) + .eq(ModuleField::getType, FieldType.ATTACHMENT.name()); + List attachmentFields = moduleFieldMapper.selectListByLambda(queryWrapper); + return attachmentFields.stream().map(ModuleField::getId).collect(Collectors.toSet()); + } + + /** + * 将附件字段值中的附件ID替换为附件名称 + * 在记录操作日志时调用,确保即使附件被删除,日志中仍保留附件名称 + * + * @param fieldValue 附件字段值(附件ID列表) + * @return 替换为附件名称后的值 + */ + private Object replaceAttachmentIdsWithNames(Object fieldValue) { + if (fieldValue == null) { + return null; + } + List ids; + if (fieldValue instanceof List) { + ids = ((List) fieldValue).stream().map(String::valueOf).toList(); + } else { + ids = JSON.parseArray(fieldValue.toString(), String.class); + } + if (CollectionUtils.isEmpty(ids)) { + return fieldValue; + } + List names = extAttachmentMapper.selectNameByIds(ids); + if (CollectionUtils.isNotEmpty(names)) { + return names; + } + return fieldValue; + } + + /** + * 从表单配置中获取附件类型字段ID集合 + * + * @param formConfig 表单配置 + * @return 附件类型字段ID集合 + */ + private Set getAttachmentFieldIdsFromFormConfig(ModuleFormConfigDTO formConfig) { + if (formConfig == null || CollectionUtils.isEmpty(formConfig.getFields())) { + return Collections.emptySet(); + } + return formConfig.getFields().stream() + .filter(field -> Strings.CS.equals(field.getType(), FieldType.ATTACHMENT.name())) + .map(BaseField::getId) + .collect(Collectors.toSet()); + } + + /** + * 替换日志中附件字段的值(将附件ID替换为附件名称) + * + * @param resourceLog 日志数据 + * @param attachmentFieldIds 附件类型字段ID集合 + */ + private void replaceAttachmentValuesInLog(Map resourceLog, Set attachmentFieldIds) { + if (CollectionUtils.isEmpty(attachmentFieldIds) || resourceLog == null) { + return; + } + for (String fieldId : attachmentFieldIds) { + if (resourceLog.containsKey(fieldId)) { + resourceLog.put(fieldId, replaceAttachmentIdsWithNames(resourceLog.get(fieldId))); + } + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/common/service/DataInitService.java b/backend/crm/src/main/java/cn/cordys/common/service/DataInitService.java new file mode 100644 index 0000000..b9a795c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/service/DataInitService.java @@ -0,0 +1,141 @@ +package cn.cordys.common.service; + + +import cn.cordys.common.util.OnceInterface; +import cn.cordys.common.util.OnceInterfaceAction; +import cn.cordys.crm.clue.service.ClueService; +import cn.cordys.crm.contract.service.ContractInvoiceService; +import cn.cordys.crm.contract.service.ContractService; +import cn.cordys.crm.opportunity.service.OpportunityQuotationService; +import cn.cordys.crm.order.service.OrderService; +import cn.cordys.crm.system.domain.Parameter; +import cn.cordys.crm.system.service.ModuleFieldExtService; +import cn.cordys.crm.system.service.ModuleFieldService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.crm.system.service.ModuleService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.redisson.Redisson; +import org.redisson.api.RLock; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * @author jianxing + * @date 2025-01-03 12:01:54 + */ +@Service +@Slf4j +public class DataInitService { + @Resource + private ModuleService moduleService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private ModuleFieldExtService moduleFieldExtService; + @Resource + private BaseMapper parameterMapper; + @Resource + private Redisson redisson; + @Resource + private ModuleFieldService moduleFieldService; + @Resource + private ClueService clueService; + @Resource + private ContractService contractService; + @Resource + private ContractInvoiceService contractInvoiceService; + @Resource + private OpportunityQuotationService opportunityQuotationService; + @Resource + private OrderService orderService; + + public void initOneTime() { + RLock lock = redisson.getLock("init_data_lock"); + lock.lock(); + try { + initOneTime(moduleService::initDefaultOrgModule, "init.module"); + initOneTime(moduleFormService::initForm, "init.form"); + initOneTime(moduleFieldService::modifyDateProp, "modify.form.date"); + initOneTime(moduleFormService::modifyFormLinkProp, "modify.form.link"); + initOneTime(moduleFormService::modifyFormProp, "modify.form.prop"); + initOneTime(moduleFormService::modifyFieldMobile, "modify.field.mobile"); + initOneTime(moduleFormService::processOldLinkData, "process.old.link.data"); + initOneTime(moduleFormService::initFormScenarioProp, "init.record.form.scenario"); + initOneTime(clueService::processTransferredCluePlanAndRecord, "process.transferred.clue"); + initOneTime(moduleFormService::initUpgradeForm, "init.upgrade.form.v1.4.0"); + initOneTime(moduleFormService::initUpgradeForm, "init.upgrade.form.v1.5.0"); + initOneTime(moduleFormService::initUpgradeForm, "init.upgrade.form.v1.5.1"); + initOneTime(moduleFormService::initExtFieldsByVer, "1.5.0", "init.ext.fields.v1.5.0"); + initOneTime(moduleFormService::initExtFieldsByVer, "1.5.1", "init.ext.fields.v1.5.1"); + initOneTime(moduleFieldExtService::setDefaultOptionSource, "set.default.option.source"); + initOneTime(moduleFieldExtService::refreshPlanFieldPos, "refresh.plan.field.pos"); + initOneTime(moduleFormService::initInvoiceFormScenarioProp, "init.invoice.form.scenario"); + initOneTime(moduleFieldService::modifyInvoiceShowFields, "init.invoice.show.fields"); + initOneTime(moduleService::deleteExtraModules, "delete.extra.modules"); + initOneTime(moduleFieldExtService::modifySubProductSumColumn, "modify.quotation.product.sum.column"); + initOneTime(moduleFormService::initUpgradeForm, "init.upgrade.form.v1.6.0"); + initOneTime(moduleFieldService::initOrderFields, "init.order.fields"); + initOneTime(moduleFormService::initContactFormLinkRules, "init.contact.form.link.rules"); + initOneTime(moduleFormService::initContractToOrderLinkScenario, "init.order.form.link.rules"); + initOneTime(moduleFormService::initOrderFormScenarioProp, "init.order.form.scenario"); + initOneTime(moduleFieldExtService::modifyInternalSubSumColumn, "modify.internal.sum.column"); + initOneTime(moduleFieldExtService::modifyInternalSubCalcFormula, "modify.internal.calc.formula"); + initOneTime(moduleFieldExtService::refreshFormulaOldReferencedId, "refresh.formula.old.referenced.id"); + initOneTime(contractService::handleOldApprovalData, "handler.contract.approval.status"); + initOneTime(contractInvoiceService::handleOldApprovalData, "handler.contract.invoice.approval.status"); + initOneTime(opportunityQuotationService::handleOldApprovalData, "handler.quotation.approval.status"); + initOneTime(orderService::handleOldApprovalData, "handler.order.approval.status"); + } finally { + lock.unlock(); + } + } + + private void initOneTime(OnceInterface onceFunc, final String key) { + try { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(Parameter::getParamKey, key); + List parameters = parameterMapper.selectListByLambda(queryWrapper); + if (CollectionUtils.isEmpty(parameters)) { + onceFunc.execute(); + insertParameterOnceKey(key); + } + } catch (Throwable e) { + log.error(e.getMessage(), e); + } + } + + /** + * 执行单次接口 (带参数) + * + * @param onceFunc 执行函数 + * @param param 参数 + * @param key 执行Key + * @param

参数类型 + */ + private

void initOneTime(OnceInterfaceAction

onceFunc, P param, final String key) { + try { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(Parameter::getParamKey, key); + List parameters = parameterMapper.selectListByLambda(queryWrapper); + if (CollectionUtils.isEmpty(parameters)) { + onceFunc.execute(param); + insertParameterOnceKey(key); + } + } catch (Throwable e) { + log.error(e.getMessage(), e); + } + } + + private void insertParameterOnceKey(String key) { + Parameter parameter = new Parameter(); + parameter.setParamKey(key); + parameter.setParamValue("done"); + parameter.setType("text"); + parameterMapper.insert(parameter); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/common/service/DataScopeService.java b/backend/crm/src/main/java/cn/cordys/common/service/DataScopeService.java new file mode 100644 index 0000000..e3a2544 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/service/DataScopeService.java @@ -0,0 +1,277 @@ +package cn.cordys.common.service; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.InternalUserView; +import cn.cordys.common.constants.RoleDataScope; +import cn.cordys.common.dto.*; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.response.result.CrmHttpResultCode; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.domain.OrganizationUser; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.crm.system.service.RoleService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * @author jianxing + * @date 2025-01-03 12:01:54 + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class DataScopeService { + @Resource + private DepartmentService departmentService; + @Resource + private BaseMapper organizationUserMapper; + @Resource + private RoleService roleService; + @Resource + private BaseService baseService; + @Resource + private PermissionCache permissionCache; + + public DeptDataPermissionDTO getDeptDataPermission(String userId, String orgId, String viewId, String permission) { + if (InternalUserView.isSelf(viewId)) { + // 只查看自己的数据 + DeptDataPermissionDTO dto = new DeptDataPermissionDTO(); + dto.setViewId(viewId); + dto.setSelf(true); + return dto; + } + + if (InternalUserView.isVisible(viewId)) { + // 查看设置为可见的数据 + DeptDataPermissionDTO dto = new DeptDataPermissionDTO(); + dto.setViewId(viewId); + dto.setVisible(true); + return dto; + } + + // 获取常规数据权限 + DeptDataPermissionDTO deptDataPermission = getDeptDataPermission(userId, orgId, permission); + deptDataPermission.setViewId(viewId); + + // 数据权限是全部,但是查询条件是部门,则按照部门查询 + if (deptDataPermission.getAll() && InternalUserView.isDepartment(viewId)) { + return getDeptDataPermissionForAllPermission(userId, orgId); + } + + return deptDataPermission; + } + + /** + * 获取用户角色的数据权限 + * + * @param userId + * @param orgId + * + * @return + */ + public DeptDataPermissionDTO getDeptDataPermission(String userId, String orgId, String permission) { + DeptDataPermissionDTO deptDataPermission = new DeptDataPermissionDTO(); + + if (Strings.CS.equals(userId, InternalUser.ADMIN.getValue())) { + // 超级管理员查看所有数据 + deptDataPermission.setAll(true); + return deptDataPermission; + } + + Map> dataScopeRoleMap = getDataScopeRoleMap(userId, orgId); + + boolean hasAllPermission = hasDataScopePermission(dataScopeRoleMap, RoleDataScope.ALL.name(), permission); + + if (hasAllPermission) { + // 可以查看所有数据 + deptDataPermission.setAll(true); + return deptDataPermission; + } + + List userDeptRoles = dataScopeRoleMap.get(RoleDataScope.DEPT_AND_CHILD.name()); + List customDeptRoles = dataScopeRoleMap.get(RoleDataScope.DEPT_CUSTOM.name()); + + if (CollectionUtils.isEmpty(userDeptRoles) + && CollectionUtils.isEmpty(customDeptRoles)) { + // 如果没有部门权限,则默认只能查看自己的数据 + deptDataPermission.setSelf(true); + return deptDataPermission; + } + + return getDeptDataPermissionForDept(userId, orgId, dataScopeRoleMap, permission); + } + + private DeptDataPermissionDTO getDeptDataPermissionForAllPermission(String userId, String orgId) { + DeptDataPermissionDTO deptDataPermission = new DeptDataPermissionDTO(); + // 查询部门树 + List tree = departmentService.getTree(orgId); + OrganizationUser organizationUser = getOrganizationUser(userId, orgId); + if (organizationUser == null) { + return deptDataPermission; + } + List deptIds = getDeptIdsWithChild(tree, Set.of(organizationUser.getDepartmentId())); + deptDataPermission.getDeptIds().addAll(deptIds); + return deptDataPermission; + } + + private DeptDataPermissionDTO getDeptDataPermissionForDept(String userId, String orgId, + Map> dataScopeRoleMap, + String permission) { + DeptDataPermissionDTO deptDataPermission = new DeptDataPermissionDTO(); + // 查询部门树 + List tree = departmentService.getTree(orgId); + + boolean hasDeptAndChildPermission = hasDataScopePermission(dataScopeRoleMap, RoleDataScope.DEPT_AND_CHILD.name(), permission); + + if (hasDeptAndChildPermission) { + // 查看用户部门及其子部门数据 + OrganizationUser organizationUser = getOrganizationUser(userId, orgId); + List deptIds = getDeptIdsWithChild(tree, Set.of(organizationUser.getDepartmentId())); + deptDataPermission.getDeptIds().addAll(deptIds); + } + + List customDeptRoles = dataScopeRoleMap.get(RoleDataScope.DEPT_CUSTOM.name()); + boolean hasDeptCustomPermission = hasDataScopePermission(dataScopeRoleMap, RoleDataScope.DEPT_CUSTOM.name(), permission); + + if (hasDeptCustomPermission) { + // 查看指定部门及其子部门数据 + List customDeptRolesIds = customDeptRoles.stream() + .map(RoleDataScopeDTO::getId) + .toList(); + List parentDeptIds = roleService.getDeptIdsByRoleIds(customDeptRolesIds); + List deptIds = getDeptIdsWithChild(tree, new HashSet<>(parentDeptIds)); + deptDataPermission.getDeptIds().addAll(deptIds); + } + return deptDataPermission; + } + + public boolean hasDataScopePermission(Map> dataScopeRoleMap, String dataScope, String permission) { + List roleDataScopes = dataScopeRoleMap.get(dataScope); + if (roleDataScopes == null) { + return false; + } + for (RolePermissionDTO roleDataScope : roleDataScopes) { + if (roleDataScope.getPermissions().contains(permission)) { + return true; + } + } + return false; + } + + public Map> getDataScopeRoleMap(String userId, String orgId) { + return permissionCache.getRolePermissions(userId, orgId) + .stream() + .collect(Collectors.groupingBy(RolePermissionDTO::getDataScope, Collectors.toList())); + } + + public OrganizationUser getOrganizationUser(String userId, String orgId) { + OrganizationUser example = new OrganizationUser(); + example.setUserId(userId); + example.setOrganizationId(orgId); + return organizationUserMapper.selectOne(example); + } + + /** + * 根据已选的部门ID + * 获取部门及其子部门的ID + * + * @param tree + * @param deptIds + * + * @return + */ + public List getDeptIdsWithChild(List tree, Set deptIds) { + List childDeptIds = new ArrayList<>(); + for (BaseTreeNode node : tree) { + if (deptIds.contains(node.getId())) { + childDeptIds.add(node.getId()); + childDeptIds.addAll(getNodeIdsWithChild(node.getChildren())); + } else { + childDeptIds.addAll(getDeptIdsWithChild(node.getChildren(), deptIds)); + } + } + return childDeptIds; + } + + /** + * 获取树节点及其子节点的ID + * + * @param tree + * + * @return + */ + private List getNodeIdsWithChild(List tree) { + if (CollectionUtils.isEmpty(tree)) { + return List.of(); + } + List childDeptIds = new ArrayList<>(); + for (BaseTreeNode node : tree) { + childDeptIds.add(node.getId()); + childDeptIds.addAll(getNodeIdsWithChild(node.getChildren())); + } + return childDeptIds; + } + + /** + * 校验数据权限 + * + * @param userId + * @param orgId + */ + public void checkDataPermission(String userId, String orgId, List owners, String permission) { + if (CollectionUtils.isEmpty(owners)) { + throw new GenericException(Translator.get("data.permission")); + } + boolean hasPermission = hasDataPermission(userId, orgId, owners, permission); + if (!hasPermission) { + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + } + + public boolean hasDataPermission(String userId, String orgId, String owner, String permission) { + if (StringUtils.isBlank(owner)) { + throw new GenericException(Translator.get("data.permission")); + } + return hasDataPermission(userId, orgId, List.of(owner), permission); + } + + public boolean hasDataPermission(String userId, String orgId, List owners, String permission) { + DeptDataPermissionDTO deptDataPermission = getDeptDataPermission(userId, orgId, permission); + if (deptDataPermission.getAll() || Strings.CS.equals(userId, InternalUser.ADMIN.getValue())) { + return true; + } + + if (deptDataPermission.getSelf()) { + for (String owner : owners) { + // 是否是自己的客户 + if (!Strings.CS.equals(owner, userId)) { + return false; + } + } + return true; + } + + if (CollectionUtils.isNotEmpty(deptDataPermission.getDeptIds())) { + Map userDeptMapByUserIds = baseService.getUserDeptMapByUserIds(owners, orgId); + for (String owner : owners) { + UserDeptDTO customerOwnerDept = userDeptMapByUserIds.get(owner); + // 部门权限是否有该客户的权限 + if (customerOwnerDept == null || !deptDataPermission.getDeptIds().contains(customerOwnerDept.getDeptId())) { + if (!Strings.CS.equals(owner, userId)) { + return false; + } + } + } + return true; + } + return false; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/common/service/ExportExecutor.java b/backend/crm/src/main/java/cn/cordys/common/service/ExportExecutor.java new file mode 100644 index 0000000..3cf0a7f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/service/ExportExecutor.java @@ -0,0 +1,18 @@ +package cn.cordys.common.service; + +import cn.cordys.crm.system.domain.ExportTask; + +/** + * 通用导出执行器 + * @author song-cc-rock + */ +@FunctionalInterface +public interface ExportExecutor { + + /** + * 导出执行方法 + * @param task 导出任务 + * @throws Exception 异常信息 + */ + void execute(ExportTask task) throws Exception; +} diff --git a/backend/crm/src/main/java/cn/cordys/common/service/FieldSourceServiceProvider.java b/backend/crm/src/main/java/cn/cordys/common/service/FieldSourceServiceProvider.java new file mode 100644 index 0000000..6b118c1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/service/FieldSourceServiceProvider.java @@ -0,0 +1,218 @@ +package cn.cordys.common.service; + + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.crm.clue.service.ClueService; +import cn.cordys.crm.contract.service.*; +import cn.cordys.crm.customer.service.CustomerContactService; +import cn.cordys.crm.customer.service.CustomerService; +import cn.cordys.crm.form.service.CustomFormDataService; +import cn.cordys.crm.opportunity.service.OpportunityQuotationService; +import cn.cordys.crm.opportunity.service.OpportunityService; +import cn.cordys.crm.order.service.OrderService; +import cn.cordys.crm.product.service.ProductPriceService; +import cn.cordys.crm.product.service.ProductService; +import cn.cordys.crm.system.constants.FieldSourceType; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @author song-cc-rock + */ +@Service +@Slf4j +public class FieldSourceServiceProvider { + + private static final Map SERVICE_MAP = new HashMap<>(); + + /** + * 开启的审批流表单表格映射 + */ + private static final Map APPROVAL_FORM_SERVICE_MAP = new HashMap<>(4); + + @Resource + private ClueService clueService; + @Resource + private CustomerService customerService; + @Resource + private OpportunityService opportunityService; + @Resource + private CustomerContactService customerContactService; + @Resource + private ProductService productService; + @Resource + private ProductPriceService productPriceService; + @Resource + private OpportunityQuotationService opportunityQuotationService; + @Resource + private ContractService contractService; + @Resource + private BusinessTitleService businessTitleService; + @Resource + private OrderService orderService; + @Resource + private ContractPaymentRecordService paymentRecordService; + @Resource + private ContractPaymentPlanService paymentPlanService; + @Resource + private ContractInvoiceService invoiceService; + + @PostConstruct + public void init() { + SERVICE_MAP.put(FieldSourceType.CLUE, clueService); + SERVICE_MAP.put(FieldSourceType.CUSTOMER, customerService); + SERVICE_MAP.put(FieldSourceType.OPPORTUNITY, opportunityService); + SERVICE_MAP.put(FieldSourceType.CONTACT, customerContactService); + SERVICE_MAP.put(FieldSourceType.PRODUCT, productService); + SERVICE_MAP.put(FieldSourceType.PRICE, productPriceService); + SERVICE_MAP.put(FieldSourceType.QUOTATION, opportunityQuotationService); + SERVICE_MAP.put(FieldSourceType.CONTRACT, contractService); + SERVICE_MAP.put(FieldSourceType.BUSINESS_TITLE, businessTitleService); + SERVICE_MAP.put(FieldSourceType.ORDER, orderService); + SERVICE_MAP.put(FieldSourceType.CONTRACT_PAYMENT_RECORD, paymentRecordService); + SERVICE_MAP.put(FieldSourceType.PAYMENT_PLAN, paymentPlanService); + + APPROVAL_FORM_SERVICE_MAP.put(FormKey.QUOTATION.getKey(), opportunityQuotationService); + APPROVAL_FORM_SERVICE_MAP.put(FormKey.CONTRACT.getKey(), contractService); + APPROVAL_FORM_SERVICE_MAP.put(FormKey.INVOICE.getKey(), invoiceService); + APPROVAL_FORM_SERVICE_MAP.put(FormKey.ORDER.getKey(), orderService); + } + + /** + * 根据来源类型获取对应的 Service + */ + @SuppressWarnings("unchecked") + public static T getService(FieldSourceType type) { + Object service = SERVICE_MAP.get(type); + if (service != null) { + return (T) service; + } + return (T) CommonBeanFactory.getBean(CustomFormDataService.class); + } + + /** + * 根据来源类型获取对应的 Service + */ + @SuppressWarnings("unchecked") + public static T getServiceOfKey(String key) { + return (T) APPROVAL_FORM_SERVICE_MAP.get(key); + } + + /** + * 执行资源详情方法(单个或批量) + * + * @param type 资源或数据源类型 + * @param id 单个或批量ID + * @param methodName 方法名称 + * + * @return 单个数据对象或数据对象列表 + */ + public Object executeServiceMethod(FieldSourceType type, Object id, String methodName) { + Object service = getService(type); + if (service == null) { + log.error("数据源引用失败, 类型 {} 有误", type.name()); + return id instanceof List ? Collections.emptyList() : null; + } + try { + if (id instanceof List idList) { + if (CollectionUtils.isEmpty(idList)) { + return Collections.emptyList(); + } + List ids = idList.stream().map(Object::toString).toList(); + Method method = service.getClass().getMethod(methodName, List.class); + return method.invoke(service, ids); + } else { + if (!(id instanceof String) || ((String) id).isEmpty()) { + return null; + } + Method method = service.getClass().getMethod(methodName, String.class); + return method.invoke(service, id.toString()); + } + } catch (Exception e) { + log.error("获取数据源详情异常:type={}, id={}, error={}", type.name(), id, e.getMessage(), e); + return id instanceof List ? Collections.emptyList() : null; + } + } + + public Object executeServiceMethod(String formKey, Object id, String methodName) { + Object service = getServiceOfKey(formKey); + if (service == null) { + log.error("审批表单类型 {} 有误", formKey); + return id instanceof List ? Collections.emptyList() : null; + } + try { + if (id instanceof List idList) { + if (CollectionUtils.isEmpty(idList)) { + return Collections.emptyList(); + } + List ids = idList.stream().map(Object::toString).toList(); + Method method = service.getClass().getMethod(methodName, List.class); + return method.invoke(service, ids); + } else { + if (!(id instanceof String) || ((String) id).isEmpty()) { + return null; + } + Method method = service.getClass().getMethod(methodName, String.class); + return method.invoke(service, id.toString()); + } + } catch (Exception e) { + log.error("获取业务数据详情异常:type={}, id={}, error={}", formKey, id, e.getMessage(), e); + return id instanceof List ? Collections.emptyList() : null; + } + } + + /** + * 挂起事务, 防止下游方法异常回滚当前事务 + * + * @param type 数据源类型 + * @param id 主键ID + * + * @return 数据对象 + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public Object safeGetSimpleById(FieldSourceType type, String id) { + return executeServiceMethod(type, id, "getSimple"); + } + + /** + * 获取表单业务数据详情 + * @param formKey 表单 + * @param id 主表ID + * @return 业务数据详情 + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public Object safeGetSimpleById(String formKey, String id) { + return executeServiceMethod(formKey, id, "getSimple"); + } + + /** + * 批量获取数据源详情(挂起事务,防止下游方法异常回滚当前事务) + * + * @param type 数据源类型 + * @param ids 主键ID集合 + * + * @return 数据对象列表 + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @SuppressWarnings("unchecked") + public List batchGetSimpleByIds(FieldSourceType type, List ids) { + return (List) executeServiceMethod(type, ids, "batchGetSimpleByIds"); + } + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public Object safeGetFieldsById(String formKey, String id) { + return executeServiceMethod(formKey, id, "getFieldValues"); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/common/service/SSRFValidationService.java b/backend/crm/src/main/java/cn/cordys/common/service/SSRFValidationService.java new file mode 100644 index 0000000..6ad202d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/service/SSRFValidationService.java @@ -0,0 +1,181 @@ +package cn.cordys.common.service; + +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.util.Translator; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +@Service +@Slf4j +public class SSRFValidationService { + + @Value("${allowed.ip.ranges.enabled}") + private Boolean whitelistEnabled; + + @Value("#{'${allowed.ip.ranges:}'.split(',')}") + private List allowedList; + + /** + * 只允许 http 和 https 协议 + */ + private static final List ALLOWED_PROTOCOLS = List.of("http", "https"); + + /** + * 禁用的私有/保留 IP 段 + */ + private static final List BLOCKED_IP_PREFIXES = List.of( + "0.", // 当前网络 + "10.", // A 类私有 + "127.", // 回环 + "169.254.", // 链路本地(含云元数据) + "172.16.", "172.17.", "172.18.", "172.19.", + "172.20.", "172.21.", "172.22.", "172.23.", + "172.24.", "172.25.", "172.26.", "172.27.", + "172.28.", "172.29.", "172.30.", "172.31.", // B 类私有 + "192.168.", // C 类私有 + "224.", "225.", "226.", "227.", "228.", "229.", + "230.", "231.", "232.", "233.", "234.", "235.", + "236.", "237.", "238.", "239.", // D 类多播 + "240.", "241.", "242.", "243.", "244.", "245.", + "246.", "247.", "248.", "249.", "250.", "251.", + "252.", "253.", "254.", "255." // E 类保留 + ); + + /** + * 校验 URL 是否安全,防止 SSRF + * + * @param url 用户提供的目标 URL + * + * @throws IllegalArgumentException 若 URL 不合规 + */ + public void validate(String url) { + if (StringUtils.isBlank(url)) { + throw new IllegalArgumentException("URL 不能为空"); + } + + URI uri = parseUri(url); + + // 1. 协议白名单 + String scheme = uri.getScheme(); + if (scheme == null || !ALLOWED_PROTOCOLS.contains(scheme.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("仅允许 http/https 协议"); + } + + // 2. 禁止用户信息(防止绕过:http://safe@evil.com) + if (uri.getUserInfo() != null) { + throw new IllegalArgumentException("URL 中不允许包含用户信息"); + } + + String host = uri.getHost(); + if (StringUtils.isBlank(host)) { + throw new IllegalArgumentException("URL 缺少主机名"); + } + + // 3. 白名单检查(基于主机名,避免重复解析 URL) + validateAgainstWhitelist(host); + + // 4. 解析为 IP 并检查 + InetAddress inetAddress = resolveHost(host); + String ip = inetAddress.getHostAddress(); + log.info("解析 {} -> {}", host, ip); + + // 5. 回环地址 + if (inetAddress.isLoopbackAddress()) { + throw new IllegalArgumentException("禁止访问回环地址"); + } + + // 6. 站点本地地址(私有网络) + if (inetAddress.isSiteLocalAddress()) { + throw new IllegalArgumentException("禁止访问内网地址"); + } + + // 7. 链路本地地址(含 169.254.x.x 云元数据) + if (inetAddress.isLinkLocalAddress()) { + throw new IllegalArgumentException("禁止访问链路本地地址(含云元数据)"); + } + + // 8. IP 前缀黑名单兜底(如 0.x.x.x 容器网络、D/E 类保留地址) + for (String prefix : BLOCKED_IP_PREFIXES) { + if (ip.startsWith(prefix)) { + throw new IllegalArgumentException("禁止访问保留地址: " + ip); + } + } + } + + // ========== 私有辅助方法 ========== + + /** + * 解析 URL 字符串为 URI + */ + private static URI parseUri(String url) { + try { + return new URI(url); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("URL 格式不合法", e); + } + } + + /** + * DNS 解析主机名 + */ + private static InetAddress resolveHost(String host) { + try { + return InetAddress.getByName(host); + } catch (UnknownHostException e) { + throw new IllegalArgumentException("无法解析主机名: " + host, e); + } + } + + /** + * 检查主机名是否在白名单中(白名单功能启用时) + *

支持三种模式:

+ *
    + *
  • {@code *} — 放行全部
  • + *
  • {@code *.example.com} — 通配子域名
  • + *
  • {@code exact.host.com} — 精确匹配
  • + *
+ */ + public void validateAgainstWhitelist(String host) { + if (!Boolean.TRUE.equals(whitelistEnabled)) { + return; + } + + String normalizedHost = host.toLowerCase(Locale.ROOT).trim(); + List allowed = allowedList == null ? Collections.emptyList() : allowedList; + + boolean matched = allowed.stream() + .filter(Objects::nonNull) + .map(s -> s.trim().toLowerCase(Locale.ROOT)) + .filter(StringUtils::isNotBlank) + .anyMatch(pattern -> matchesPattern(normalizedHost, pattern)); + + if (!matched) { + throw new GenericException(Translator.get("dashboard_url_not_allowed")); + } + } + + /** + * 判断 host 是否匹配白名单模式 + */ + private static boolean matchesPattern(String host, String pattern) { + if ("*".equals(pattern)) { + return true; + } + if (pattern.startsWith("*.")) { + // *.example.com → .example.com,匹配 foo.example.com + return host.endsWith(pattern.substring(1)); + } + return host.equals(pattern); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/common/utils/BeanCopyUtils.java b/backend/crm/src/main/java/cn/cordys/common/utils/BeanCopyUtils.java new file mode 100644 index 0000000..b466338 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/utils/BeanCopyUtils.java @@ -0,0 +1,33 @@ +package cn.cordys.common.utils; + +import java.lang.reflect.Field; + +public class BeanCopyUtils { + + public static void fillEmptyFields(Object target, Object source) { + if (target == null || source == null) { + return; + } + + Field[] fields = target.getClass().getDeclaredFields(); + + for (Field field : fields) { + try { + field.setAccessible(true); + + Object targetValue = field.get(target); + Object sourceValue = field.get(source); + + // target为空,并且source有值,才赋值 + if ((targetValue == null || "".equals(targetValue)) + && sourceValue != null + ) { + field.set(target, sourceValue); + } + + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/common/utils/ConditionFilterUtils.java b/backend/crm/src/main/java/cn/cordys/common/utils/ConditionFilterUtils.java new file mode 100644 index 0000000..966b96a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/utils/ConditionFilterUtils.java @@ -0,0 +1,381 @@ +package cn.cordys.common.utils; + + +import cn.cordys.common.constants.InternalUserView; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.ChartAnalysisDbRequest; +import cn.cordys.common.dto.ChartAnalysisRequest; +import cn.cordys.common.dto.chart.ChartCategoryAxisDbParam; +import cn.cordys.common.dto.chart.ChartValueAxisDbParam; +import cn.cordys.common.dto.condition.BaseCondition; +import cn.cordys.common.dto.condition.CombineSearch; +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.dto.condition.FilterDBCondition; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.common.uid.utils.EnumUtils; +import cn.cordys.common.util.*; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.FieldSourceType; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.dto.field.DatasourceField; +import cn.cordys.crm.system.dto.field.DepartmentField; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.mapper.ExtAttachmentMapper; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.UserViewService; +import cn.cordys.security.SessionUtils; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.stream.Collectors; + +import static cn.cordys.common.utils.IndustryUtils.getIndustries; + +/** + * @Author: jianxing + * @CreateTime: 2025-07-28 17:31 + * 拦截高级搜索等查询 + * 处理高级搜索等通用查询条件 + * 1. 处理视图查询条件 + * 2. 预先过滤不合法的查询条件 + * 3. 处理成员选项中的 CURRENT_USER + */ +@Aspect +@Component +public class ConditionFilterUtils { + + private static final String COUNT_FIELD = "COUNT"; + + public static ChartAnalysisDbRequest parseChartAnalysisRequest(ChartAnalysisRequest request, ModuleFormConfigDTO formConfig) { + List fields = formConfig.getFields(); + + BaseField departmentField = new DepartmentField(); + departmentField.setId("departmentId"); + departmentField.setType(FieldType.DEPARTMENT.name()); + departmentField.setBusinessKey("departmentId"); + fields.add(departmentField); + + ChartAnalysisDbRequest chartAnalysisDbRequest = BeanUtils.copyBean(new ChartAnalysisDbRequest(), request); + chartAnalysisDbRequest.setCategoryAxisParam(BeanUtils.copyBean(new ChartCategoryAxisDbParam(), request.getChartConfig().getCategoryAxis())); + if (request.getChartConfig().getSubCategoryAxis() != null) { + chartAnalysisDbRequest.setSubCategoryAxisParam(BeanUtils.copyBean(new ChartCategoryAxisDbParam(), request.getChartConfig().getSubCategoryAxis())); + } + chartAnalysisDbRequest.setValueAxisParam(BeanUtils.copyBean(new ChartValueAxisDbParam(), request.getChartConfig().getValueAxis())); + + // 处理视图查询条件 + chartAnalysisDbRequest.setViewFilterCondition(getViewCondition(chartAnalysisDbRequest.getViewId())); + + replaceDBCondition(fields, chartAnalysisDbRequest.getFilterCondition()); + replaceDBCondition(fields, chartAnalysisDbRequest.getViewFilterCondition()); + + // 处理高级搜索条件 + parseCondition(chartAnalysisDbRequest.getFilterCondition()); + // 处理视图筛选条件 + parseCondition(chartAnalysisDbRequest.getViewFilterCondition()); + + ChartCategoryAxisDbParam xAxisParam = chartAnalysisDbRequest.getCategoryAxisParam(); + ChartCategoryAxisDbParam subXAxisParam = chartAnalysisDbRequest.getSubCategoryAxisParam(); + ChartValueAxisDbParam yAxisParam = chartAnalysisDbRequest.getValueAxisParam(); + + BaseField xBaseField = getBaseFieldWithCheck(fields, xAxisParam.getFieldId()); + BaseField yBaseField = getBaseFieldWithCheck(fields, yAxisParam.getFieldId()); + + xAxisParam.setBlob(xBaseField.isBlob()); + xAxisParam.setBusinessField(StringUtils.isNotBlank(xBaseField.getBusinessKey())); + xAxisParam.setBusinessFieldName(CaseFormatUtils.camelToUnderscore(xBaseField.getBusinessKey())); + + if (yBaseField != null) { + yAxisParam.setBlob(yBaseField.isBlob()); + yAxisParam.setBusinessField(StringUtils.isNotBlank(yBaseField.getBusinessKey())); + yAxisParam.setBusinessFieldName(CaseFormatUtils.camelToUnderscore(yBaseField.getBusinessKey())); + } + + if (subXAxisParam != null) { + BaseField subXBaseField = getBaseFieldWithCheck(fields, subXAxisParam.getFieldId()); + subXAxisParam.setBlob(subXBaseField.isBlob()); + subXAxisParam.setBusinessField(StringUtils.isNotBlank(subXBaseField.getBusinessKey())); + subXAxisParam.setBusinessFieldName(CaseFormatUtils.camelToUnderscore(subXBaseField.getBusinessKey())); + } + + return chartAnalysisDbRequest; + } + + private static BaseField getBaseFieldWithCheck(List fields, String fieldKey) { + if (StringUtils.isBlank(fieldKey)) { + return null; + } + if (Strings.CS.equals(fieldKey, COUNT_FIELD)) { + return null; + } + return fields.stream() + .filter(field -> Strings.CI.equals(field.getId(), fieldKey) + || Strings.CI.equals(field.getBusinessKey(), fieldKey)) + .findFirst().orElseThrow(() -> new GenericException(Translator.get("module.field.not_exist"))); + } + + public static void parseCondition(BaseCondition baseCondition) { + parseCondition(baseCondition, null); + } + + public static void parseCondition(BaseCondition baseCondition, String formKey) { + ModuleFormCacheService moduleFormCacheService = CommonBeanFactory.getBean(ModuleFormCacheService.class); + ModuleFormConfigDTO businessFormConfig = Objects.requireNonNull(moduleFormCacheService).getBusinessFormConfig(formKey, OrganizationContext.getOrganizationId()); + List fields = businessFormConfig.getFields(); + + // 处理视图查询条件 + baseCondition.setViewCombineSearch(getViewCondition(baseCondition.getViewId())); + + CombineSearch combineSearch = baseCondition.getCombineSearch(); + CombineSearch viewCombineSearch = baseCondition.getViewCombineSearch(); + + replaceFilterDBCondition(fields, baseCondition); + replaceDBCondition(fields, combineSearch); + replaceDBCondition(fields, viewCombineSearch); + + // 处理高级搜索条件 + parseCondition(combineSearch); + // 处理视图筛选条件 + parseCondition(viewCombineSearch); + } + + /** + * 将 FilterCondition 替换成 FilterDBCondition,并设置数据库查询相关属性 + * + * @param fields + * @param combineSearch + */ + private static void replaceDBCondition(List fields, CombineSearch combineSearch) { + if (combineSearch == null || CollectionUtils.isEmpty(combineSearch.getConditions())) { + return; + } + List conditions = combineSearch.getConditions(); + replaceDBConditions(fields, conditions); + // 重新设置conditions + combineSearch.setConditions(conditions); + } + + private static void replaceFilterDBCondition(List fields, BaseCondition baseCondition) { + if (baseCondition == null || CollectionUtils.isEmpty(baseCondition.getFilters())) { + return; + } + List conditions = baseCondition.getFilters(); + replaceDBConditions(fields, conditions); + // 重新设置 filters + baseCondition.setFilters(conditions); + } + + private static void replaceDBConditions(List fields, List conditions) { + Map fieldMap = fields.stream() + .collect(Collectors.toMap(BaseField::getId, field -> field)); + + for (int i = 0; i < conditions.size(); i++) { + FilterCondition condition = conditions.get(i); + // 将 FilterCondition 替换成 FilterDBCondition + FilterDBCondition dbCondition = BeanUtils.copyBean(new FilterDBCondition(), condition); + conditions.set(i, dbCondition); + + BaseField baseField = fieldMap.get(condition.getName()); + if (baseField != null) { + dbCondition.setBlob(baseField.isBlob()); + dbCondition.setMultipleValue(baseField.multiple()); + if (StringUtils.isBlank(baseField.getResourceFieldId())) { + // 处理表单字段 + parseFilterDBCondition(dbCondition, baseField); + } else { + // 处理显示字段 + BaseField refResourceField = fieldMap.get(baseField.getResourceFieldId()); + if (refResourceField instanceof DatasourceField datasourceField) { + parseFilterDBCondition(dbCondition, baseField); + if (baseField.hasBusinessKey()) { + // 暂时只处理显示字段 + dbCondition.setName(CaseFormatUtils.camelToUnderscore(baseField.getBusinessKey())); + } else { + String actualFieldId = baseField.getId().replace((baseField.getResourceFieldId() + BaseResourceFieldService.REF_UNDERLINE), StringUtils.EMPTY); + dbCondition.setName(actualFieldId); + } + dbCondition.setRefFiled(true); + + dbCondition.setRefMainFieldName(refResourceField.idOrBusinessKey()); + if (refResourceField.hasBusinessKey()) { + dbCondition.setRefMainFieldName(CaseFormatUtils.camelToUnderscore(refResourceField.getBusinessKey())); + } else { + dbCondition.setRefMainFieldName(refResourceField.getId()); + } + dbCondition.setRefMainCustomField(!refResourceField.hasBusinessKey()); + + FieldSourceType fieldSourceType = FieldSourceType.safeValueOf(datasourceField.getDataSourceType()); + if (fieldSourceType != null) { + dbCondition.setRefMainTableName(fieldSourceType.getTableName()); + } else { + dbCondition.setRefMainTableName(FieldSourceType.CUSTOM_FORM.getTableName()); + } + } + } + } + } + } + + private static void parseFilterDBCondition(FilterDBCondition dbCondition, BaseField baseField) { + dbCondition.setName(baseField.idOrBusinessKey()); + dbCondition.setCustomField(!baseField.hasBusinessKey()); + dbCondition.setMultipleValue(baseField.multiple()); + } + + private static void parseCondition(CombineSearch combineSearch) { + if (combineSearch == null) { + return; + } + + List validConditions = getValidConditions(combineSearch.getConditions()); + validConditions.forEach(item -> { + Object combineValue = item.getCombineValue(); + String combineOperator = item.getCombineOperator(); + item.setValue(combineValue); + item.setOperator(combineOperator); + if (item.getValue() != null && item.getCombineValue() instanceof String strValue + && Strings.CS.equalsAny(item.getCombineOperator(), FilterCondition.CombineConditionOperator.CONTAINS.name(), + FilterCondition.CombineConditionOperator.NOT_CONTAINS.name())) { + // 转义 mysql 的特殊字符 + item.setValue(BaseCondition.transferKeyword(strValue)); + } + + if (item.getValue() != null && Strings.CS.contains(item.getType(), "MULTIPLE") + && Strings.CS.equalsAny(item.getCombineOperator(), FilterCondition.CombineConditionOperator.EQUALS.name(), + FilterCondition.CombineConditionOperator.NOT_EQUALS.name())) { + // 转义 mysql 的特殊字符 + item.setValue(JSON.toJSONString(item.getCombineValue())); + } + + if (item.getValue() != null && Strings.CS.equals(item.getType(), FieldType.ATTACHMENT.name())) { + // 附件类型转义名称 + List attachmentNames = List.of(item.getCombineValue().toString().split(StringUtils.SPACE)); + ExtAttachmentMapper attachmentMapper = CommonBeanFactory.getBean(ExtAttachmentMapper.class); + List attachmentIds = Objects.requireNonNull(attachmentMapper).getAttachmentIdsByNames(attachmentNames); + item.setValue(CollectionUtils.isEmpty(attachmentIds) ? attachmentNames : attachmentIds); + } + + + if (item.getValue() != null && Strings.CS.equals(item.getType(), FieldType.INDUSTRY.name())) { + String str = item.getValue().toString() + .replace("[", "") + .replace("]", ""); + + List list = Arrays.stream(str.split(",")) + .map(String::trim) + .collect(Collectors.toList()); + List values = IndustryUtils.getValues(getIndustries(), list); + values.addAll(list); + item.setValue(values.stream().distinct().toList()); + } + }); + replaceCurrentUser(validConditions); + combineSearch.setConditions(validConditions); + } + + private static CombineSearch getViewCondition(String viewId) { + if (StringUtils.isNotBlank(viewId) && !InternalUserView.isInternalUserView(viewId)) { + // 查询视图 + UserViewService userViewService = CommonBeanFactory.getBean(UserViewService.class); + String viewSearchMode = Objects.requireNonNull(userViewService).getSearchMode(viewId); + List viewConditions = userViewService.getFilterConditions(viewId); + + //新增子节点数据 + List tree = Objects.requireNonNull(CommonBeanFactory.getBean(DepartmentService.class)).getTree(OrganizationContext.getOrganizationId()); + buildConditions(viewConditions, tree); + + CombineSearch viewCondition = new CombineSearch(); + viewCondition.setSearchMode(viewSearchMode); + viewCondition.setConditions(viewConditions); + return viewCondition; + } + return null; + } + + + /** + * 包含新增子节点数据 + * + * @param conditions + */ + private static void buildConditions(List conditions, List tree) { + if (CollectionUtils.isNotEmpty(conditions)) { + conditions.forEach(condition -> { + if (CollectionUtils.isNotEmpty(condition.getContainChildIds())) { + condition.getContainChildIds().forEach(id -> { + List ids = getIds(tree, id); + ids.addAll(condition.getContainChildIds()); + condition.setValue(ids.stream().distinct().toList()); + }); + } + }); + } + } + + + public static List getIds(List tree, String targetId) { + List ids = new ArrayList<>(); + for (BaseTreeNode node : tree) { + BaseTreeNode targetNode = findNode(node, targetId); + if (targetNode != null) { + collectIds(targetNode, ids); + break; + } + } + return ids; + } + + private static BaseTreeNode findNode(BaseTreeNode node, String targetId) { + if (node.getId().equals(targetId)) { + return node; + } + for (BaseTreeNode child : node.getChildren()) { + BaseTreeNode result = findNode(child, targetId); + if (result != null) { + return result; + } + } + return null; + } + + private static void collectIds(BaseTreeNode node, List ids) { + ids.add(node.getId()); + for (BaseTreeNode child : node.getChildren()) { + collectIds(child, ids); + } + } + + /** + * 处理成员选项中的 CURRENT_USER + * 替换当前用户的用户ID + * + * @param validConditions + */ + private static void replaceCurrentUser(List validConditions) { + for (FilterCondition validCondition : validConditions) { + Object value = validCondition.getCombineValue(); + if (value instanceof List arrayValues) { + for (int i = 0; i < arrayValues.size(); i++) { + Object arrayValue = arrayValues.get(i); + if (arrayValue != null && Strings.CS.equals(arrayValue.toString(), InternalUserView.CURRENT_USER)) { + // 替换当前用户的用户ID + arrayValues.set(i, SessionUtils.getUserId()); + } + } + } + } + } + + public static List getValidConditions(List conditions) { + if (CollectionUtils.isEmpty(conditions)) { + return List.of(); + } + return conditions.stream().filter(FilterCondition::valid).collect(Collectors.toList()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/utils/IndustryUtils.java b/backend/crm/src/main/java/cn/cordys/common/utils/IndustryUtils.java new file mode 100644 index 0000000..4121444 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/utils/IndustryUtils.java @@ -0,0 +1,250 @@ +package cn.cordys.common.utils; + +import cn.cordys.common.resolver.field.IndustryResolver; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.dto.form.IndustryDict; +import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.io.InputStream; +import java.lang.ref.SoftReference; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; + +/** + * @author song-cc-rock + */ +public class IndustryUtils { + + /** + * split str + */ + private static final String SPILT_STR = "/"; + private static SoftReference> industryRef; + + /** + * 获取所有行业分类 + * + * @return categories 行业分类 + */ + public static List getIndustries() { + List industries; + if (industryRef == null || (industries = industryRef.get()) == null) { + synchronized (IndustryResolver.class) { + if (industryRef == null || (industries = industryRef.get()) == null) { + industries = loadData(); + industryRef = new SoftReference<>(industries); + } + } + } + return industries; + } + + /** + * 加载行业分类字典数据 + * + * @return 行业分类树 + */ + private static List loadData() { + try (InputStream is = IndustryResolver.class.getClassLoader() + .getResourceAsStream("dict/industry.json")) { + return JSON.parseObject(is, new TypeReference<>() { + }); + } catch (Exception e) { + throw new RuntimeException("加载行业分类失败", e); + } + } + + /** + * 行业分类映射 + * + * @param str 解析字符串 + * @param nameToCode 是否名称转编码 + * @return 编码 + */ + public static String mapping(String str, boolean nameToCode) { + if (StringUtils.isBlank(str)) { + return StringUtils.EMPTY; + } + + List path = new ArrayList<>(); + Queue findQueue = new LinkedList<>(); + CollectionUtils.addAll(findQueue, str.split(SPILT_STR)); + List industries = getIndustries(); + boolean found = findRecursive(industries, findQueue, path, nameToCode); + return found ? String.join(SPILT_STR, path) : StringUtils.EMPTY; + } + + /** + * 编码转父子层级名称 + * 例如: code=13 -> "制造业/农副食品加工业" + * + * @param code 字典值编码 + * @return 父子层级的行业名称 + */ + public static String codeToParentLabel(String code) { + if (StringUtils.isBlank(code)) { + return StringUtils.EMPTY; + } + + List industries = getIndustries(); + List path = new ArrayList<>(); + boolean found = findParentPath(industries, code, path); + + return found ? String.join(SPILT_STR, path) : StringUtils.EMPTY; + } + + /** + * 名称转编码,只返回叶子节点编码 + * 例如: name="制造业/农副食品加工业" -> "13" + * + * @param name 行业名称 + * @return 叶子节点的编码 + */ + public static String nameToCodeOnlyLeaf(String name) { + if (StringUtils.isBlank(name)) { + return StringUtils.EMPTY; + } + + Queue findQueue = new LinkedList<>(); + CollectionUtils.addAll(findQueue, name.split(SPILT_STR)); + List industries = getIndustries(); + String[] result = new String[1]; + findLeafCode(industries, findQueue, result); + return result[0] != null ? result[0] : StringUtils.EMPTY; + } + + /** + * 递归查找父级路径 + */ + private static boolean findParentPath(List industryTree, String code, List path) { + if (CollectionUtils.isEmpty(industryTree)) { + return false; + } + for (IndustryDict industry : industryTree) { + // 先递归查找子节点 + if (industry.getChildren() != null) { + List childPath = new ArrayList<>(); + if (findParentPath(industry.getChildren(), code, childPath)) { + // 找到子节点, 将当前节点加入路径头部 + path.add(industry.getLabel()); + path.addAll(childPath); + return true; + } + } + // 匹配当前节点 + if (Strings.CS.equals(industry.getValue(), code)) { + path.add(industry.getLabel()); + return true; + } + } + return false; + } + + /** + * 递归查找叶子节点编码 + */ + private static void findLeafCode(List industryTree, Queue findQueue, String[] result) { + if (CollectionUtils.isEmpty(industryTree) || findQueue.isEmpty()) { + return; + } + String current = findQueue.peek(); + for (IndustryDict industry : industryTree) { + if (Strings.CS.equals(industry.getLabel(), current)) { + findQueue.poll(); + if (findQueue.isEmpty()) { + // 找到目标, 记录编码 + result[0] = industry.getValue(); + return; + } + // 继续查找子节点 + if (industry.getChildren() != null) { + findLeafCode(industry.getChildren(), findQueue, result); + } + return; + } + } + } + + /** + * 递归查找行业分类路径 + * @param industryTree 行业分类树 + * @param findQueue 查找队列 + * @param path 路径 + * @return 是否找到 + */ + private static boolean findRecursive(List industryTree, Queue findQueue, List path, boolean nameToCode) { + if (CollectionUtils.isEmpty(industryTree) || findQueue.isEmpty()) { + return false; + } + String current = findQueue.peek(); + for (IndustryDict industry : industryTree) { + boolean match = nameToCode ? Strings.CS.equals(industry.getLabel(), current) + : Strings.CS.equals(industry.getValue(), current); + if (match) { + // 层级匹配, 记录编码, 弹出当前层级 + path.add(nameToCode ? industry.getValue() : industry.getLabel()); + findQueue.poll(); + // 队列为空, 直接返回成功 + if (findQueue.isEmpty()) { + return true; + } + } + // 递归查找下一级 + boolean childResult = findRecursive(industry.getChildren(), findQueue, path, nameToCode); + if (childResult) { + return true; + } + } + return false; + } + + + public static List getValues(List tree, List targetValues) { + List result = new ArrayList<>(); + + for (String target : targetValues) { + List values = getValuesForSingle(tree, target); + if (values != null) { + result.addAll(values); + } + } + return result; + } + + /** + * 查找单个 value,并返回其所有子节点 value + */ + private static List getValuesForSingle(List tree, String targetValue) { + for (IndustryDict node : tree) { + if (node.getValue().equals(targetValue)) { + List result = new ArrayList<>(); + collectValues(node, result); + return result; + } + + if (node.getChildren() != null) { + List res = getValuesForSingle(node.getChildren(), targetValue); + if (res != null) return res; + } + } + return null; + } + + /** + * 收集节点自身和所有子节点的 value + */ + private static void collectValues(IndustryDict node, List result) { + result.add(node.getValue()); + + if (node.getChildren() != null) { + for (IndustryDict child : node.getChildren()) { + collectValues(child, result); + } + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/utils/IpUtils.java b/backend/crm/src/main/java/cn/cordys/common/utils/IpUtils.java new file mode 100644 index 0000000..bc76cf4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/utils/IpUtils.java @@ -0,0 +1,38 @@ +package cn.cordys.common.utils; + +import jakarta.servlet.http.HttpServletRequest; + +import java.util.Arrays; +import java.util.List; + +public class IpUtils { + private static final List IP_HEADER_CANDIDATES = Arrays.asList( + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_X_FORWARDED_FOR", + "HTTP_X_FORWARDED", + "HTTP_X_CLUSTER_CLIENT_IP", + "HTTP_CLIENT_IP", + "HTTP_FORWARDED_FOR", + "HTTP_FORWARDED", + "HTTP_VIA", + "REMOTE_ADDR" + ); + + private static final String UNKNOWN = "unknown"; + + public static String getClientIpAddress(HttpServletRequest request) { + return IP_HEADER_CANDIDATES.stream() + .map(request::getHeader) + .filter(ip -> ip != null && !ip.isEmpty() && !UNKNOWN.equalsIgnoreCase(ip)) + .map(IpUtils::getFirstIp) + .findFirst() + .orElse(request.getRemoteAddr()); + } + + private static String getFirstIp(String ip) { + int index = ip.indexOf(','); + return index != -1 ? ip.substring(0, index) : ip; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/common/utils/OpportunityFieldUtils.java b/backend/crm/src/main/java/cn/cordys/common/utils/OpportunityFieldUtils.java new file mode 100644 index 0000000..c6e5af6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/utils/OpportunityFieldUtils.java @@ -0,0 +1,72 @@ +package cn.cordys.common.utils; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.common.resolver.field.ModuleFieldResolverFactory; +import cn.cordys.common.util.TimeUtils; +import cn.cordys.crm.opportunity.dto.response.OpportunityListResponse; +import cn.cordys.crm.system.dto.field.base.BaseField; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class OpportunityFieldUtils { + + + public static LinkedHashMap getSystemFieldMap(OpportunityListResponse data, Map> optionMap, Map stageConfigMap, Map fieldConfigMap) { + LinkedHashMap systemFieldMap = new LinkedHashMap<>(); + systemFieldMap.put("name", data.getName()); + systemFieldMap.put("customerId", data.getCustomerName()); + systemFieldMap.put("amount", data.getAmount()); + systemFieldMap.put("expectedEndTime", TimeUtils.getDateStr(data.getExpectedEndTime())); + systemFieldMap.put("actualEndTime", TimeUtils.getDateStr(data.getActualEndTime())); + systemFieldMap.put("failureReason", data.getFailureReason()); + + BaseField possible = fieldConfigMap.values().stream().filter(field -> Strings.CI.equals(field.getBusinessKey(), "possible")).findFirst().orElse(null); + if (possible != null && data.getPossible() != null) { + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(possible.getType()); + systemFieldMap.put("possible", customFieldResolver.transformToValue(possible, data.getPossible().stripTrailingZeros().toPlainString())); + } + systemFieldMap.put("products", getProducts(optionMap, data.getProducts())); + systemFieldMap.put("contactId", data.getContactName()); + systemFieldMap.put("owner", data.getOwnerName()); + + systemFieldMap.put("stage", stageConfigMap.get(data.getStage())); + systemFieldMap.put("followerName", data.getFollowerName()); + systemFieldMap.put("followTime", TimeUtils.getDateTimeStr(data.getFollowTime())); + systemFieldMap.put("reservedDays", data.getReservedDays()); + systemFieldMap.put("departmentId", data.getDepartmentName()); + + systemFieldMap.put("createUser", data.getCreateUserName()); + systemFieldMap.put("createTime", TimeUtils.getDateTimeStr(data.getCreateTime())); + systemFieldMap.put("updateUser", data.getUpdateUserName()); + systemFieldMap.put("updateTime", TimeUtils.getDateTimeStr(data.getUpdateTime())); + + + return systemFieldMap; + } + + + private static Object getProducts(Map> optionMap, List products) { + List productNames = new ArrayList<>(); + if (optionMap.containsKey(BusinessModuleField.OPPORTUNITY_PRODUCTS.getBusinessKey()) && CollectionUtils.isNotEmpty(products)) { + Map productsMap = optionMap.get(BusinessModuleField.OPPORTUNITY_PRODUCTS.getBusinessKey()).stream().collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + products.forEach(product -> { + if (productsMap.containsKey(product)) { + productNames.add(productsMap.get(product)); + } + }); + } + if (CollectionUtils.isEmpty(productNames)) { + return StringUtils.EMPTY; + } + return StringUtils.join(productNames, ","); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/utils/RecycleConditionUtils.java b/backend/crm/src/main/java/cn/cordys/common/utils/RecycleConditionUtils.java new file mode 100644 index 0000000..1bbdec0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/utils/RecycleConditionUtils.java @@ -0,0 +1,108 @@ +package cn.cordys.common.utils; + +import cn.cordys.crm.system.constants.RecycleConditionColumnKey; +import cn.cordys.crm.system.constants.RecycleConditionOperator; +import cn.cordys.crm.system.dto.RuleConditionDTO; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.List; + +/** + * 回收规则工具类 + */ +public class RecycleConditionUtils { + + private static final int FIX_TIME_LEN = 2; + private static final int DYNAMIC_TIME_LEN = 1; + private static final int CUSTOM_TIME_LEN = 3; + + /** + * 计算回收天数 + * + * @param conditions 回收条件 + * @param reserveTime 回收时间 + * + * @return 回收天数 + */ + public static Integer calcRecycleDays(List conditions, Long reserveTime) { + List reservedConditions = conditions.stream().filter(condition -> RecycleConditionColumnKey.matchReserved(condition.getColumn())).toList(); + if (CollectionUtils.isEmpty(reservedConditions)) { + return null; + } + RuleConditionDTO condition = reservedConditions.getFirst(); + if (Strings.CS.equals(condition.getOperator(), RecycleConditionOperator.FIXED.name())) { + return null; + } + + //动态非自定义时间也按照区间处理 + if (StringUtils.isNotBlank(condition.getValue()) && StringUtils.split(condition.getValue(), ",").length == DYNAMIC_TIME_LEN) { + return null; + } + LocalDateTime dynamicTime = condition.getDynamicTime(); + if (dynamicTime == null) { + return null; + } + LocalDateTime pickedTime = Instant.ofEpochMilli(reserveTime).atZone(ZoneId.systemDefault()).toLocalDateTime(); + long betweenDays = ChronoUnit.DAYS.between(dynamicTime, pickedTime); + if (!pickedTime.toLocalTime().equals(LocalDateTime.MIN.toLocalTime())) { + betweenDays++; + } + return betweenDays < 0 ? 0 : (int) betweenDays; + + } + + /** + * 匹配回收时间 + * + * @param condition 回收条件 + * @param matchTime 匹配时间 + * + * @return 是否匹配 + */ + public static boolean matchTime(RuleConditionDTO condition, Long matchTime) { + if (matchTime == null) { + // 无时间匹配, 也满足回收条件 + return true; + } + boolean match = false; + if (Strings.CS.equals(condition.getOperator(), RecycleConditionOperator.FIXED.name()) && StringUtils.isNotBlank(condition.getValue())) { + // 固定时间 + String[] split = StringUtils.split(condition.getValue(), ","); + if (split.length == FIX_TIME_LEN) { + match = matchTime >= Long.parseLong(split[0]) && matchTime <= Long.parseLong(split[1]); + } + } else { + //动态非自定义时间也按照区间处理 + String[] split = StringUtils.split(condition.getValue(), ","); + // 动态自定义时间 + if (split.length == DYNAMIC_TIME_LEN) { + List dynamicTimeList = condition.getDynamicTimeList(); + match = matchTime >= dynamicTimeList.get(0) && matchTime <= dynamicTimeList.get(1); + } else { + LocalDateTime dynamicTime = condition.getDynamicTime(); + if (dynamicTime != null) { + //这里区分日期前,日期后 + LocalDateTime pickedTime = Instant.ofEpochMilli(matchTime).atZone(ZoneId.systemDefault()).toLocalDateTime(); + if (split.length == FIX_TIME_LEN) { + match = pickedTime.isBefore(dynamicTime); + } + if (split.length == CUSTOM_TIME_LEN) { + String dateUnit = split[2]; + if (dateUnit.contains("BEFORE")) { + match = pickedTime.isBefore(dynamicTime); + } else { + match = pickedTime.isAfter(dynamicTime); + } + } + } + } + } + return match; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/utils/RegionUtils.java b/backend/crm/src/main/java/cn/cordys/common/utils/RegionUtils.java new file mode 100644 index 0000000..78070e9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/utils/RegionUtils.java @@ -0,0 +1,201 @@ +package cn.cordys.common.utils; + +import cn.cordys.common.resolver.field.LocationResolver; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.dto.field.LocationField; +import cn.cordys.crm.system.dto.regioncode.RegionCode; +import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.io.InputStream; +import java.lang.ref.SoftReference; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; + +/** + * @author song-cc-rock + */ +public class RegionUtils { + + private static final String SPILT_STR = "-"; + private static SoftReference> regionCodeRef; + + /** + * get all regions + * + * @return regions + */ + public static List getRegionCodes() { + List regions; + + if (regionCodeRef == null || (regions = regionCodeRef.get()) == null) { + synchronized (LocationResolver.class) { + if (regionCodeRef == null || (regions = regionCodeRef.get()) == null) { + regions = loadRegionData(); + regionCodeRef = new SoftReference<>(regions); + } + } + } + return regions; + } + + /** + * load region + * + * @return region list + */ + private static List loadRegionData() { + try (InputStream is = LocationResolver.class.getClassLoader() + .getResourceAsStream("region/region.json")) { + return JSON.parseObject(is, new TypeReference<>() { + }); + } catch (Exception e) { + throw new RuntimeException("加载行政区划数据失败", e); + } + } + + /** + * 映射地址与编码 + * + * @param str 映射字符串 + * @param nameToCode 是否名称转编码 + * @return 映射结果 + */ + public static String mapping(String str, boolean nameToCode) { + if (StringUtils.isBlank(str)) { + return StringUtils.EMPTY; + } + + Queue queue = new LinkedList<>(); + CollectionUtils.addAll(queue, str.split(SPILT_STR)); + List regionCodes = getRegionCodes(); + StringBuilder result = new StringBuilder(); + + RegionCode matched = findMatch(regionCodes, queue.peek(), nameToCode); + // 根级别未匹配时, 递归在子树中查找 + if (matched == null) { + matched = findMatchInTree(regionCodes, queue.peek(), nameToCode); + } + while (matched != null && !queue.isEmpty()) { + result = new StringBuilder(nameToCode ? matched.getCode() : matched.getName()); + queue.poll(); + matched = matched.getChildren() == null ? null : findMatch(matched.getChildren(), queue.peek(), nameToCode); + } + + // 保留分隔符, 后续取详细地址 + result.append(SPILT_STR); + queue.forEach(result::append); + + return result.toString(); + } + + /** + * 递归在整棵树中查找匹配的区域节点 + */ + private static RegionCode findMatchInTree(List list, String value, boolean nameToCode) { + if (StringUtils.isBlank(value) || CollectionUtils.isEmpty(list)) { + return null; + } + for (RegionCode item : list) { + boolean matched = nameToCode ? Strings.CS.equals(item.getName(), value) + : Strings.CS.equals(item.getCode(), value); + if (matched) { + return item; + } + if (CollectionUtils.isNotEmpty(item.getChildren())) { + RegionCode found = findMatchInTree(item.getChildren(), value, nameToCode); + if (found != null) { + return found; + } + } + } + return null; + } + + /** + * find match region code + */ + private static RegionCode findMatch(List list, String value, boolean nameToCode) { + if (StringUtils.isBlank(value) || CollectionUtils.isEmpty(list)) { + return null; + } + for (RegionCode item : list) { + boolean matched = nameToCode ? Strings.CS.equals(item.getName(), value) + : Strings.CS.equals(item.getCode(), value); + if (matched) { + return item; + } + } + return null; + } + + /** + * code => address + * + * @param codeStr 地址编码 + * @return address + */ + public static String codeToName(String codeStr, LocationField locationField) { + String code = getCode(codeStr); + if (code == null) { + return null; + } + String regionName = ""; + List regionCodes = getRegionCodes(); + if (Strings.CS.equals("CN", locationField.getScope())) { + RegionCode chn = regionCodes.stream().filter(regionCode -> Strings.CS.equals(regionCode.getCode(), "CHN")).findFirst().get(); + regionName = getRegionFullName(code, chn.getChildren()); + } else { + regionName = getRegionFullName(code, regionCodes); + } + + String detail = getDetail(codeStr); + if (StringUtils.isNotBlank(detail)) { + // 补充地址详情 + return regionName + SPILT_STR + detail; + } + return regionName; + } + + public static String getCode(String codeStr) { + if (StringUtils.isBlank(codeStr)) { + return null; + } + String code = codeStr.split(SPILT_STR)[0]; + return StringUtils.isBlank(code) ? null : code; + } + + public static String getDetail(String codeStr) { + if (StringUtils.isBlank(codeStr)) { + return null; + } + String[] split = codeStr.split(SPILT_STR); + if (split.length > 1) { + return split[1]; + } + return null; + } + + private static String getRegionFullName(String code, List regionCodes) { + if (StringUtils.isBlank(code)) { + return null; + } + for (RegionCode regionCode : regionCodes) { + if (Strings.CS.equals(regionCode.getCode(), code)) { + return regionCode.getName(); + } + + List children = regionCode.getChildren(); + if (CollectionUtils.isNotEmpty(children)) { + String regionFullName = getRegionFullName(code, children); + if (regionFullName != null && !Strings.CS.equals(regionFullName, code)) { + return regionCode.getName() + SPILT_STR + regionFullName; + } + } + } + return code; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/common/utils/SqlInjectionChecker.java b/backend/crm/src/main/java/cn/cordys/common/utils/SqlInjectionChecker.java new file mode 100644 index 0000000..c34a2aa --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/common/utils/SqlInjectionChecker.java @@ -0,0 +1,52 @@ +package cn.cordys.common.utils; + +import org.apache.commons.lang3.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; + +public class SqlInjectionChecker { + + private static final Pattern DANGEROUS_PATTERN = Pattern.compile( + "--[^\\n]*|#|/\\*!?|\\*/" + // SQL 注释 + "|'|\"|\\\\|;" + // 危险分隔符 + "|\\b(?:xp_\\w+|sp_\\w+|dbms_\\w+|utl_\\w+)\\b" + // 危险存储过程 + "|\\b(?:select|insert|update|delete|drop|alter|truncate|exec|execute|merge|replace|load_file|outfile|dumpfile)\\b" + + "|\\b(?:union\\s+(?:all\\s+)?select|information_schema|table_name|column_name)\\b" + + "|\\b(?:sleep|benchmark|pg_sleep|dbms_lock\\.sleep|dbms_pipe\\.receive_message|waitfor\\s+delay)\\b" + + "|\\b(?:and|or)\\b\\s*(?:'|[0-9]|\\()", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL + ); + + /** + * 检测输入字符串是否包含可疑的 SQL 注入特征。 + * + * @param input 待检测的用户输入 + * + * @return true 表示可能存在注入风险 + */ + public static boolean containsSqlInjectionRisk(String input) { + if (StringUtils.isEmpty(input)) { + return false; + } + // 归一化处理:尝试解码常见编码绕过(如双重URL编码、Unicode编码等) + String normalized = normalize(input); + + return DANGEROUS_PATTERN.matcher(normalized).find(); + } + + // 简单归一化:解码URL编码、反引号等,防止编码绕过 + private static String normalize(String input) { + try { + // 解码URL编码 (%27 → ', %23 → #, %2D%2D → --) + String decoded = java.net.URLDecoder.decode(input, StandardCharsets.UTF_8); + // 如果解码后与原文不同,可能进行了编码绕过,进一步递归检测 + if (!decoded.equals(input)) { + return normalize(decoded); + } + return decoded; + } catch (Exception e) { + return input; + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/config/AsyncConfig.java b/backend/crm/src/main/java/cn/cordys/config/AsyncConfig.java new file mode 100644 index 0000000..a405665 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/config/AsyncConfig.java @@ -0,0 +1,50 @@ +package cn.cordys.config; + + +import lombok.extern.slf4j.Slf4j; +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.ThreadPoolExecutor; + +@EnableAsync +@Configuration +@Slf4j +public class AsyncConfig implements AsyncConfigurer { + + // 核心线程数 + private static final int CORE_POOL_SIZE = 20; + // 最大线程数 + private static final int MAX_POOL_SIZE = 20; + // 空闲线程最大存活秒数 + private static final int KEEP_ALIVE_SECONDS = 60; + // 关闭时最大等待秒数 + private static final int AWAIT_TERMINATION_SECONDS = 60; + + // 同时暴露默认名称,便于 @Async 自动装配 + @Bean(name = {"threadPoolTaskExecutor", "applicationTaskExecutor"}) + public ThreadPoolTaskExecutor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(CORE_POOL_SIZE); + executor.setMaxPoolSize(MAX_POOL_SIZE); + executor.setKeepAliveSeconds(KEEP_ALIVE_SECONDS); + executor.setAllowCoreThreadTimeOut(true); + executor.setThreadNamePrefix("cs-async-task-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(AWAIT_TERMINATION_SECONDS); + return executor; + } + + /** + * 捕获 @Async void 方法未处理的异常 + */ + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return (ex, method, params) -> log.error("异步任务异常: {}", method.getName() + " - " + ex.getMessage()); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/config/BundleConfig.java b/backend/crm/src/main/java/cn/cordys/config/BundleConfig.java new file mode 100644 index 0000000..2fb6545 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/config/BundleConfig.java @@ -0,0 +1,28 @@ +package cn.cordys.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ResourceBundleMessageSource; + +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +@Configuration +public class BundleConfig { + @Value("${spring.messages.default-locale:zh-CN}") + private String defaultLocale; + @Value("${spring.messages.basename}") + private String[] basements; + + @Bean + public ResourceBundleMessageSource messageSource() { + ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource(); + Locale defaultLocale = Locale.forLanguageTag(this.defaultLocale); + // 设置消息资源文件的基名 + messageSource.setBasenames(basements); + messageSource.setDefaultEncoding(StandardCharsets.UTF_8.name()); // 设置编码 + messageSource.setDefaultLocale(defaultLocale); // 设置默认语言 + return messageSource; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/config/MybatisConfig.java b/backend/crm/src/main/java/cn/cordys/config/MybatisConfig.java new file mode 100644 index 0000000..efba400 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/config/MybatisConfig.java @@ -0,0 +1,181 @@ +package cn.cordys.config; + +import cn.cordys.common.handler.ListTypeHandler; +import cn.cordys.common.interceptor.UserDesensitizationInterceptor; +import cn.cordys.mybatis.interceptor.MybatisInterceptor; +import cn.cordys.quartz.anno.QuartzDataSource; +import com.github.pagehelper.PageInterceptor; +import com.zaxxer.hikari.HikariDataSource; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import javax.sql.DataSource; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +/** + * 配置类,用于配置 MyBatis 和数据源相关的设置。 + *

+ * 本类负责配置 MyBatis 的分页拦截器、用户信息脱敏拦截器、以及数据库源的配置, + * 其中包括主数据源与 Quartz 相关的数据源配置。 + *

+ * + * @version 1.0 + */ +@Configuration +@MapperScan(basePackages = {"cn.cordys.**.mapper"}, sqlSessionFactoryRef = "sqlSessionFactory") +@EnableTransactionManagement +public class MybatisConfig { + + /** + * 配置 MyBatis 的分页拦截器。 + *

+ * 该方法创建并返回一个 {@link PageInterceptor} 实例,用于启用 MyBatis 分页功能。 + *

+ * + * @return 配置好的分页拦截器实例 + */ + @Bean + public PageInterceptor pageInterceptor() { + PageInterceptor pageInterceptor = new PageInterceptor(); + Properties properties = new Properties(); + properties.setProperty("helperDialect", "mysql"); + properties.setProperty("rowBoundsWithCount", "true"); + properties.setProperty("reasonable", "true"); + properties.setProperty("offsetAsPageNum", "true"); + properties.setProperty("pageSizeZero", "true"); + pageInterceptor.setProperties(properties); + return pageInterceptor; + } + + /** + * 配置 MyBatis 的自定义拦截器。 + *

+ * 该方法创建并返回一个 {@link MybatisInterceptor} 实例,将多个拦截器配置合并到一个列表中。 + *

+ * + * @param interceptorConfigs 配置的拦截器列表 + * + * @return 配置好的自定义拦截器实例 + */ + @Bean + public MybatisInterceptor dbInterceptor(List[] interceptorConfigs) { + List mybatisInterceptorConfigs = new ArrayList<>(); + for (List configList : interceptorConfigs) { + mybatisInterceptorConfigs.addAll(configList); + } + // 统一配置 + MybatisInterceptor interceptor = new MybatisInterceptor(); + interceptor.setInterceptorConfigList(mybatisInterceptorConfigs); + return interceptor; + } + + /** + * 配置用户信息脱敏的拦截器。 + *

+ * 该方法创建并返回一个 {@link UserDesensitizationInterceptor} 实例, + * 用于对用户信息进行脱敏处理。 + *

+ * + * @return 配置好的用户脱敏拦截器实例 + */ + @Bean + public UserDesensitizationInterceptor userDesensitizationInterceptor() { + return new UserDesensitizationInterceptor(); + } + + /** + * 配置主数据源。 + *

+ * 该方法根据配置文件中的属性创建一个主数据源,使用 {@link HikariDataSource} 作为数据源类型。 + *

+ * + * @param properties 数据源的基础配置 + * + * @return 配置好的主数据源实例 + */ + @Bean + @Primary + @ConfigurationProperties(prefix = "spring.datasource.hikari") + public DataSource dataSource(@Qualifier("dataSourceProperties") DataSourceProperties properties) { + return DataSourceBuilder.create(properties.getClassLoader()).type(HikariDataSource.class) + .driverClassName(properties.determineDriverClassName()) + .url(properties.determineUrl()) + .username(properties.determineUsername()) + .password(properties.determinePassword()) + .build(); + } + + /** + * 配置 Quartz 数据源。 + *

+ * 该方法根据配置文件中的属性创建一个 Quartz 数据源,使用 {@link HikariDataSource} 作为数据源类型。 + *

+ * + * @param properties Quartz 数据源的基础配置 + * + * @return 配置好的 Quartz 数据源实例 + */ + @Bean + @ConfigurationProperties(prefix = "spring.datasource.quartz.hikari") + @QuartzDataSource + public DataSource quartzDataSource(@Qualifier("quartzDataSourceProperties") DataSourceProperties properties) { + return DataSourceBuilder.create(properties.getClassLoader()).type(HikariDataSource.class) + .driverClassName(properties.determineDriverClassName()) + .url(properties.determineUrl()) + .username(properties.determineUsername()) + .password(properties.determinePassword()) + .build(); + } + + /** + * 配置主数据源的基础属性。 + *

+ * 该方法创建并返回一个 {@link DataSourceProperties} 实例, + * 用于配置 Spring Boot 中的数据源属性。 + *

+ * + * @return 配置好的数据源属性实例 + */ + @Bean("dataSourceProperties") + @Primary + @ConfigurationProperties(prefix = "spring.datasource") + public DataSourceProperties dataSourceProperties() { + return new DataSourceProperties(); + } + + /** + * 配置 Quartz 数据源的基础属性。 + *

+ * 该方法创建并返回一个 {@link DataSourceProperties} 实例, + * 用于配置 Quartz 数据源的相关属性。 + *

+ * + * @return 配置好的 Quartz 数据源属性实例 + */ + @Bean("quartzDataSourceProperties") + @ConfigurationProperties(prefix = "spring.datasource.quartz") + public DataSourceProperties quartzDataSourceProperties() { + return new DataSourceProperties(); + } + + + /** + * baseMapper 映射 tags 字段 + * + * @return + */ + @Bean + public ListTypeHandler listTypeHandler() { + return new ListTypeHandler(); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/config/OrganizationContextConfig.java b/backend/crm/src/main/java/cn/cordys/config/OrganizationContextConfig.java new file mode 100644 index 0000000..a3a15a1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/config/OrganizationContextConfig.java @@ -0,0 +1,26 @@ +package cn.cordys.config; + +import cn.cordys.common.context.OrganizationContextWebFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @Author: jianxing + * @CreateTime: 2025-01-08 17:40 + */ +@Configuration +public class OrganizationContextConfig { + + /** + * 注册 OrganizationContextWebFilter 过滤器 + * + * @return + */ + @Bean + public FilterRegistrationBean tenantContextWebFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new OrganizationContextWebFilter()); + return registrationBean; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/config/RedisConfig.java b/backend/crm/src/main/java/cn/cordys/config/RedisConfig.java new file mode 100644 index 0000000..ae8b18e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/config/RedisConfig.java @@ -0,0 +1,129 @@ +package cn.cordys.config; + +import cn.cordys.common.constants.TopicConstants; +import cn.cordys.common.redis.MessageSubscriber; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +@Configuration +@EnableCaching +public class RedisConfig { + + /** + * 配置 RedisCacheManager,提供全局的缓存配置。 + * + * @param redisConnectionFactory Redis 连接工厂 + * + * @return RedisCacheManager 实例 + */ + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) { + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + // 设置缓存有效时间 + .entryTtl(Duration.ofHours(1)) + // 设置 key 和 value 的序列化方式 + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) + // 禁止缓存 null 值 + .disableCachingNullValues(); + + return RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(config) + .build(); + } + + /** + * 配置 RedisTemplate,提供更灵活的 Redis 操作方式。 + * + * @param redisConnectionFactory Redis 连接工厂 + * + * @return RedisTemplate 实例 + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(redisConnectionFactory); + + // 使用 String 序列化器序列化 key + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + + // 使用 JSON 序列化器序列化 value + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + + template.afterPropertiesSet(); + return template; + } + + /** + * 配置所有 Redis 频道主题集合 + * 用于统一管理订阅和发布的主题 + * + * @return List Redis主题集合 + */ + @Bean + public List channelTopics() { + List topics = new ArrayList<>(); + // 添加所有需要的主题到集合中 + TopicConstants.ALL_TOPICS.forEach(topicName -> topics.add(new ChannelTopic(topicName))); + return topics; + } + + /** + * 配置Redis消息监听容器 + * 支持并发消息处理和自动重连 + * + * @param factory Redis连接工厂 + * @param listenerAdapter 消息监听适配器 + * @param channelTopics Redis主题集合 + * + * @return Redis消息监听容器 + */ + @Bean + public RedisMessageListenerContainer redisContainer( + RedisConnectionFactory factory, + MessageListenerAdapter listenerAdapter, + List channelTopics) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(factory); + + // 动态添加所有主题的监听器 + for (ChannelTopic topic : channelTopics) { + container.addMessageListener(listenerAdapter, topic); + } + + return container; + } + + /** + * 配置消息监听适配器 + * + * @param subscriber 消息订阅处理器 + * + * @return 消息监听适配器 + */ + @Bean + public MessageListenerAdapter listenerAdapter(MessageSubscriber subscriber) { + MessageListenerAdapter adapter = new MessageListenerAdapter(subscriber, "onMessage"); + // 使用JSON序列化器处理消息 + adapter.setSerializer(new GenericJackson2JsonRedisSerializer()); + return adapter; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/config/ScheduleConfig.java b/backend/crm/src/main/java/cn/cordys/config/ScheduleConfig.java new file mode 100644 index 0000000..cb33cd3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/config/ScheduleConfig.java @@ -0,0 +1,50 @@ +package cn.cordys.config; + +import cn.cordys.QuartzFilter; +import cn.cordys.common.schedule.ScheduleManager; +import cn.cordys.common.schedule.ScheduleService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 定时任务调度配置类。 + * 配置 Quartz 调度器和定时任务管理服务,启用条件为配置文件中 quartz.enabled 属性为 true。 + */ +@Configuration +@EnableScheduling +public class ScheduleConfig { + + /** + * 配置 ScheduleManager Bean。 + * 只有在 quartz.enabled 配置为 true 时,ScheduleManager 才会被加载到容器中。 + * + * @return ScheduleManager 实例 + */ + @Bean + @ConditionalOnProperty(prefix = "quartz", value = "enabled", havingValue = "true") + public ScheduleManager scheduleManager() { + return new ScheduleManager(); + } + + /** + * 配置 ScheduleService Bean。 + * 只有在 quartz.enabled 配置为 true 时,ScheduleService 才会被加载到容器中。 + * + * @return ScheduleService 实例 + */ + @Bean + @ConditionalOnProperty(prefix = "quartz", value = "enabled", havingValue = "true") + public ScheduleService scheduleService() { + return new ScheduleService(); + } + + @Bean + public FilterRegistrationBean quartzMvcConfigFilterRegistrationBean() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new QuartzFilter()); + return registrationBean; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/config/SessionConfig.java b/backend/crm/src/main/java/cn/cordys/config/SessionConfig.java new file mode 100644 index 0000000..eab21ac --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/config/SessionConfig.java @@ -0,0 +1,30 @@ +package cn.cordys.config; + +import cn.cordys.security.SessionConstants; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.session.web.http.HeaderHttpSessionIdResolver; + +/** + * 配置类,用于管理与会话相关的配置和清理操作。 + *

+ * 本类主要用于配置会话的 ID 解析器,并通过定时任务清理没有绑定用户的会话。 + *

+ * + * @version 1.0 + */ +@Configuration +public class SessionConfig { + /** + * 创建 {@link HeaderHttpSessionIdResolver} Bean。 + *

+ * 该方法配置了会话 ID 的解析方式,使用请求头中的 {@link SessionConstants#HEADER_TOKEN} 字段作为会话 ID。 + *

+ * + * @return 配置好的 {@link HeaderHttpSessionIdResolver} 实例 + */ + @Bean + public HeaderHttpSessionIdResolver sessionIdResolver() { + return new HeaderHttpSessionIdResolver(SessionConstants.HEADER_TOKEN); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/config/ShiroConfig.java b/backend/crm/src/main/java/cn/cordys/config/ShiroConfig.java new file mode 100644 index 0000000..56dff66 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/config/ShiroConfig.java @@ -0,0 +1,229 @@ +package cn.cordys.config; + +import cn.cordys.common.security.ApiKeyFilter; +import cn.cordys.common.security.AuthFilter; +import cn.cordys.common.security.CsrfFilter; +import cn.cordys.common.security.realm.LocalRealm; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.security.ShiroFilter; +import jakarta.servlet.Filter; +import org.apache.shiro.aop.AnnotationResolver; +import org.apache.shiro.authz.aop.*; +import org.apache.shiro.cache.CacheManager; +import org.apache.shiro.cache.MemoryConstrainedCacheManager; +import org.apache.shiro.realm.Realm; +import org.apache.shiro.session.mgt.SessionManager; +import org.apache.shiro.spring.LifecycleBeanPostProcessor; +import org.apache.shiro.spring.aop.SpringAnnotationResolver; +import org.apache.shiro.spring.security.interceptor.AopAllianceAnnotationsAuthorizingMethodInterceptor; +import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor; +import org.apache.shiro.spring.web.ShiroFilterFactoryBean; +import org.apache.shiro.web.mgt.DefaultWebSecurityManager; +import org.apache.shiro.web.session.mgt.ServletContainerSessionManager; +import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.web.filter.DelegatingFilterProxy; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static jakarta.servlet.DispatcherType.ASYNC; + +/** + * Shiro 配置类,用于配置 Shiro 的安全管理器、会话管理器、过滤器等。 + *

+ * 本类负责配置 Shiro 相关的 Bean,包括会话管理、缓存管理、安全过滤器链等。 + * 它还定义了授权和认证的处理逻辑,以及注解支持。 + *

+ * + * @version 1.0 + */ +@Configuration +public class ShiroConfig { + + /** + * 配置 Shiro 的过滤器工厂。 + *

+ * 设置登录页、未授权页面、过滤器链等配置。还配置了 API Key 和 CSRF 防护的过滤器。 + *

+ * + * @param sessionManager 默认的 Web 安全管理器 + * + * @return 配置好的 {@link ShiroFilterFactoryBean} 实例 + */ + @Bean + public ShiroFilterFactoryBean shiroFilterFactoryBean(final DefaultWebSecurityManager sessionManager) { + final var bean = new ShiroFilterFactoryBean(); + bean.setLoginUrl("/"); + bean.setSecurityManager(sessionManager); + bean.setUnauthorizedUrl("/403"); + bean.setSuccessUrl("/"); + + final Map chain = bean.getFilterChainDefinitionMap(); + final Map filters = bean.getFilters(); + + filters.put("apikey", new ApiKeyFilter()); + filters.put("csrf", new CsrfFilter()); + filters.put("authc", new AuthFilter()); + + chain.putAll(ShiroFilter.loadBaseFilterChain()); + chain.putAll(ShiroFilter.ignoreCsrfFilter()); + + // 配置自定义的过滤器链 + configureXFilter(chain, filters); + + return bean; + } + + private void configureXFilter(Map chain, Map filters) { + String pattern = "apikey, csrf, authc"; + final Filter preApiKey = CommonBeanFactory.getFilter(); + if (preApiKey != null) { + filters.put("preApikey", preApiKey); + pattern = "preApikey, " + pattern; + } + chain.put("/**", pattern); + } + + + /** + * 配置 Shiro 的缓存管理器,使用内存缓存管理。 + * + * @return 配置好的 {@link MemoryConstrainedCacheManager} 实例 + */ + @Bean + public MemoryConstrainedCacheManager memoryConstrainedCacheManager() { + return new MemoryConstrainedCacheManager(); + } + + /** + * 配置 Shiro 的会话管理器。 + *

+ * 使用 {@link ServletContainerSessionManager} 来管理 Web 会话。 + *

+ * + * @return 配置好的 {@link SessionManager} 实例 + */ + @Bean + public SessionManager sessionManager() { + return new ServletContainerSessionManager(); + } + + /** + * 配置 Shiro 的安全管理器。 + *

+ * 在安全管理器中设置会话管理器、缓存管理器和自定义的 Realm。 + *

+ * + * @param sessionManager 会话管理器 + * @param cacheManager 缓存管理器 + * @param localRealm 自定义 Realm 实例 + * + * @return 配置好的 {@link DefaultWebSecurityManager} 实例 + */ + @Bean(name = "securityManager") + public DefaultWebSecurityManager securityManager(SessionManager sessionManager, CacheManager cacheManager, Realm localRealm) { + DefaultWebSecurityManager defaultManager = new DefaultWebSecurityManager(); + defaultManager.setSessionManager(sessionManager); + defaultManager.setCacheManager(cacheManager); + defaultManager.setRealm(localRealm); + return defaultManager; + } + + /** + * 配置 Shiro 的自定义 Realm,用于认证和授权逻辑。 + *

+ * 自定义的 {@link LocalRealm} 实现了 Shiro 的 Realm 接口,用于处理用户的认证和授权。 + *

+ * + * @return 配置好的 {@link LocalRealm} 实例 + */ + @Bean + @DependsOn("lifecycleBeanPostProcessor") + public LocalRealm localRealm() { + return new LocalRealm(); + } + + /** + * 配置 Shiro 的生命周期 Bean 后处理器,用于管理 Shiro 的生命周期。 + * + * @return 配置好的 {@link LifecycleBeanPostProcessor} 实例 + */ + @Bean(name = "lifecycleBeanPostProcessor") + public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() { + return new LifecycleBeanPostProcessor(); + } + + /** + * 配置 Shiro 的默认代理自动创建器,用于支持方法级的注解授权。 + * + * @return 配置好的 {@link DefaultAdvisorAutoProxyCreator} 实例 + */ + @Bean + @DependsOn({"lifecycleBeanPostProcessor"}) + public DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() { + DefaultAdvisorAutoProxyCreator daap = new DefaultAdvisorAutoProxyCreator(); + daap.setProxyTargetClass(true); + return daap; + } + + /** + * 配置 Shiro 的授权注解支持。 + *

+ * 使用 {@link AuthorizationAttributeSourceAdvisor} 和 {@link AopAllianceAnnotationsAuthorizingMethodInterceptor} + * 配置 Shiro 的角色和认证注解。 + *

+ * + * @param sessionManager 默认的 Web 安全管理器 + * + * @return 配置好的 {@link AuthorizationAttributeSourceAdvisor} 实例 + */ + @Bean + public AuthorizationAttributeSourceAdvisor getAuthorizationAttributeSourceAdvisor(DefaultWebSecurityManager sessionManager) { + AuthorizationAttributeSourceAdvisor aasa = new AuthorizationAttributeSourceAdvisor(); + aasa.setSecurityManager(sessionManager); + + // 配置注解拦截器 + AopAllianceAnnotationsAuthorizingMethodInterceptor advice = new AopAllianceAnnotationsAuthorizingMethodInterceptor(); + List interceptors = new ArrayList<>(5); + + AnnotationResolver resolver = new SpringAnnotationResolver(); + interceptors.add(new RoleAnnotationMethodInterceptor(resolver)); + interceptors.add(new AuthenticatedAnnotationMethodInterceptor(resolver)); + interceptors.add(new UserAnnotationMethodInterceptor(resolver)); + interceptors.add(new GuestAnnotationMethodInterceptor(resolver)); + interceptors.add(new PermissionAnnotationMethodInterceptor(resolver)); + + advice.setMethodInterceptors(interceptors); + aasa.setAdvice(advice); + + return aasa; + } + + /** + * 配置 Shiro 的过滤器注册 Bean。 + *

+ * 使用 {@link DelegatingFilterProxy} 将 Shiro 过滤器集成到 Spring Boot 的过滤器链中。 + *

+ * + * @return + */ + @Bean + public FilterRegistrationBean filterRegistrationBean() { + // TODO 不知道会不会有问题,先验证一下 + DelegatingFilterProxy proxy = new DelegatingFilterProxy(); + proxy.setTargetBeanName("shiroFilterFactoryBean"); + proxy.setTargetFilterLifecycle(true); + + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(proxy); + registration.setAsyncSupported(true); + registration.setDispatcherTypes(ASYNC); + return registration; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/annotation/HitApproval.java b/backend/crm/src/main/java/cn/cordys/crm/approval/annotation/HitApproval.java new file mode 100644 index 0000000..535b3f1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/annotation/HitApproval.java @@ -0,0 +1,53 @@ +package cn.cordys.crm.approval.annotation; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.crm.approval.constants.ExecuteTimingEnum; +import org.apache.logging.log4j.util.Strings; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 作用范围: 可配置审批流的业务模块执行方法上 (比如合同模块可配置审批流, 审批流执行时机支持创建, 编辑, 则在对应创建, 编辑业务方法上添加对应注解和参数即可) + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface HitApproval { + + /** + * 业务表单类型 + */ + FormKey formKey(); + + /** + * 执行时机 + */ + ExecuteTimingEnum executeType(); + + /** + * 资源ID表达式 (支持SpEL表达式,从返回值或者方法参数中获取资源ID) + */ + String resourceId() default ""; + + /** + * TODO: 是否触发自动提审 + */ + boolean autoSubmit() default false; + + /** + * 更新类型 (支持SpEL表达式,从返回值或者方法参数中获取资源ID) normal-正常更新 approval-评审更新 + */ + String updateType() default "normal"; + + /** + * 操作人 (审批流触发的用户ID) + */ + String operatorId(); + + /** + * 变更说明 + */ + String comment() default Strings.EMPTY; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/aspect/HitApprovalAspect.java b/backend/crm/src/main/java/cn/cordys/crm/approval/aspect/HitApprovalAspect.java new file mode 100644 index 0000000..cff17f8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/aspect/HitApprovalAspect.java @@ -0,0 +1,328 @@ +package cn.cordys.crm.approval.aspect; + +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.approval.annotation.HitApproval; +import cn.cordys.crm.approval.constants.ApprovalStatus; +import cn.cordys.crm.approval.constants.ExecuteTimingEnum; +import cn.cordys.crm.approval.domain.ApprovalFlow; +import cn.cordys.crm.approval.dto.ApprovalPushParam; +import cn.cordys.crm.approval.service.ApprovalFlowService; +import cn.cordys.crm.approval.service.ApprovalResourceService; +import cn.cordys.crm.system.service.SysOperationLogService; +import cn.cordys.security.SessionUtils; +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.Ordered; +import org.springframework.core.StandardReflectionParameterNameDiscoverer; +import org.springframework.core.annotation.Order; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 切面后置操作: 当命中表单配置的审批流和执行时机时 + */ +@Aspect +@Component +@Slf4j +@RequiredArgsConstructor +@Order(Ordered.HIGHEST_PRECEDENCE + 5) +public class HitApprovalAspect { + + private final ExpressionParser parser = new SpelExpressionParser(); + private final StandardReflectionParameterNameDiscoverer discoverer = new StandardReflectionParameterNameDiscoverer(); + + /** + * 跳过审批检查的标记,用于审批通过后调用实际删除逻辑时避免再次触发审批 + */ + private static final ThreadLocal SKIP_APPROVAL = new ThreadLocal<>(); + + @Resource + private ApprovalFlowService approvalFlowService; + @Resource + private ApprovalResourceService approvalResourceService; + @Resource + private SysOperationLogService sysOperationLogService; + + + @Pointcut("@annotation(cn.cordys.crm.approval.annotation.HitApproval)") + public void pointcut() { + } + + @Around(value = "pointcut()") + public Object handleHitApproval(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + HitApproval annotation = method.getAnnotation(HitApproval.class); + + if (annotation == null || Boolean.TRUE.equals(SKIP_APPROVAL.get())) { + return joinPoint.proceed(); + } + + // DELETE 时机:先检查是否命中审批流,命中则不执行删除逻辑 + if (annotation.executeType() == ExecuteTimingEnum.DELETE) { + return handleDeleteApproval(joinPoint, annotation, method); + } + + // CREATE/UPDATE 时机:先执行方法,再检查审批 + Object retValue = joinPoint.proceed(); + + try { + String resourceId = resolveResourceId(method, joinPoint.getArgs(), annotation.resourceId(), retValue, annotation.executeType()); + String updateType = resolveResourceId(method, joinPoint.getArgs(), annotation.updateType(), retValue, annotation.executeType()); + String operator = resolveParamFromArgs(method, joinPoint.getArgs(), annotation.operatorId()); + String comment = resolveParamFromArgs(method, joinPoint.getArgs(), annotation.comment()); + if (StringUtils.isBlank(operator)) { + operator = SessionUtils.getUserId(); + } + if (StringUtils.isBlank(resourceId)) { + return retValue; + } + + if (Strings.CI.equals(updateType, "approval")) { + return retValue; + } + + // 获取组织ID + String organizationId = OrganizationContext.getOrganizationId(); + if (StringUtils.isBlank(organizationId)) { + return retValue; + } + + // 检查是否命中审批流 + boolean hit = checkHitApprovalFlow(annotation.formKey(), annotation.executeType(), organizationId); + + if (hit) { + boolean isCreateExecuteTime = annotation.executeType() == ExecuteTimingEnum.CREATE; + if (annotation.executeType() == ExecuteTimingEnum.UPDATE) { + // UPDATE 时机:检查资源是否历史上审批通过过,如果没有审批通过过则视为CREATE时机 + isCreateExecuteTime = !approvalResourceService.isResourceApproved(annotation.formKey(), resourceId); + } + + if (isCreateExecuteTime) { + approvalResourceService.updateResourceApprovalStatus(annotation.formKey(), resourceId, ApprovalStatus.PENDING.name(), operator, OrganizationContext.getOrganizationId()); + } else { + // 命中审批流,直接提审(跳过待提审状态) + String updateFields = resolveUpdateFields(); + ApprovalPushParam pushParam = ApprovalPushParam.builder() + .orgId(organizationId) + .userId(operator) + .resourceId(resourceId) + .formKey(annotation.formKey().getKey()) + .executeTimingEnum(ExecuteTimingEnum.UPDATE) + .updateFields(updateFields) + .comment(comment) + .build(); + approvalResourceService.push(pushParam); + } + } + } catch (Exception e) { + log.error("审批流执行时机匹配失败,error:{}", e.getMessage(), e); + } + + return retValue; + } + + /** + * 处理删除时机的审批逻辑:先检查审批,命中则不执行删除,未命中则正常删除 + */ + private Object handleDeleteApproval(ProceedingJoinPoint joinPoint, HitApproval annotation, Method method) throws Throwable { + // 从参数中解析资源ID(DELETE时资源ID从参数获取) + String resourceId = resolveParamFromArgs(method, joinPoint.getArgs(), annotation.resourceId()); + String operator = resolveParamFromArgs(method, joinPoint.getArgs(), annotation.operatorId()); + + if (StringUtils.isBlank(resourceId)) { + return joinPoint.proceed(); + } + + if (StringUtils.isBlank(operator)) { + operator = SessionUtils.getUserId(); + } + + // 获取组织ID + String organizationId = OrganizationContext.getOrganizationId(); + if (StringUtils.isBlank(organizationId)) { + return joinPoint.proceed(); + } + + // 检查是否命中审批流 + boolean hit = checkHitApprovalFlow(annotation.formKey(), annotation.executeType(), organizationId); + + if (!hit) { + // 未命中审批流,直接执行删除 + return joinPoint.proceed(); + } + + ApprovalPushParam pushParam = ApprovalPushParam.builder() + .orgId(organizationId) + .userId(operator) + .resourceId(resourceId) + .formKey(annotation.formKey().getKey()) + .executeTimingEnum(ExecuteTimingEnum.DELETE) + .comment(Translator.getWithArgs("approval.delete.resource", approvalResourceService.getFormKeyDisplayName(annotation.formKey()), + approvalResourceService.getInstanceResourceName(annotation.formKey(), resourceId))) + .build(); + approvalResourceService.push(pushParam); + return null; + } + + /** + * 解析修改的字段列表 + */ + private String resolveUpdateFields() { + try { + LogContextInfo logContext = OperationLogContext.getContext(); + Object originalValue = logContext.getOriginalValue(); + Object modifiedValue = logContext.getModifiedValue(); + if (originalValue != null && modifiedValue != null) { + List jsonDifferences = sysOperationLogService.getJsonDifferences(JSON.toJSONString(originalValue), JSON.toJSONString(modifiedValue)); + List fieldIds = jsonDifferences.stream() + .map(JsonDifferenceDTO::getColumn) + .map(col -> { + if (col.contains("-")) { + String[] split = col.split("-"); + return split[split.length - 1]; + } + return col; + }) + .collect(Collectors.toList()); + return JSON.toJSONString(fieldIds); + } + } catch (Exception e) { + log.error("解析修改字段列表失败,error:{}", e.getMessage(), e); + } + return null; + } + + /** + * 解析资源ID + *

+ * 新增时从返回值获取,编辑时从参数获取 + * + * @param method 方法 + * @param args 方法参数 + * @param expression 资源ID表达式 + * @param retValue 方法返回值 + * @param executeType 执行时机 + * @return 资源ID + */ + private String resolveResourceId(Method method, Object[] args, String expression, Object retValue, ExecuteTimingEnum executeType) { + if (executeType == ExecuteTimingEnum.CREATE) { + // 新增从返回结果中获取资源ID + return extractIdFromResult(retValue); + } + + // 编辑从参数中解析 + return resolveParamFromArgs(method, args, expression); + } + + /** + * 从返回值中提取ID + */ + private String extractIdFromResult(Object retValue) { + if (retValue == null) { + return null; + } + // 如果返回值是字符串,直接返回 + if (retValue instanceof String) { + return (String) retValue; + } + try { + Method method = retValue.getClass().getMethod("getId"); + Object id = method.invoke(retValue); + return id != null ? id.toString() : null; + } catch (Exception e) { + return null; + } + } + + /** + * 从方法参数中解析资源ID(支持SpEL表达式) + */ + private String resolveParamFromArgs(Method method, Object[] args, String expression) { + String[] params = discoverer.getParameterNames(method); + if (params == null || args == null) { + return null; + } + + EvaluationContext context = new StandardEvaluationContext(); + for (int i = 0; i < params.length; i++) { + context.setVariable(params[i], args[i]); + } + + try { + Expression exp = parser.parseExpression(expression); + Object value = exp.getValue(context, Object.class); + if (value == null) { + return null; + } + return value instanceof List ? ((List) value).getFirst().toString() : value.toString(); + } catch (Exception e) { + return null; + } + } + + /** + * 检查是否命中审批流 + * + * @param formKey 表单类型 + * @param executeTiming 执行时机 + * @param organizationId 组织ID + * @return 是否命中审批流 + */ + private boolean checkHitApprovalFlow(FormKey formKey, ExecuteTimingEnum executeTiming, String organizationId) { + try { + // 查询当前组织表单审批流配置 + ApprovalFlow flow = approvalFlowService.getEnabledFlow(formKey.getKey(), organizationId); + + if (flow == null) { + return false; + } + + // 判断是否匹配执行时机 + return switch (executeTiming) { + case CREATE -> Boolean.TRUE.equals(flow.getCreateExecute()); + case UPDATE -> Boolean.TRUE.equals(flow.getUpdateExecute()); + case DELETE -> Boolean.TRUE.equals(flow.getDeleteExecute()); + }; + } catch (Exception e) { + return false; + } + } + + /** + * 在跳过审批检查的情况下执行删除操作 + * 用于DELETE审批通过后,执行实际的删除逻辑 + * + * @param deleteAction 删除操作 + */ + public static void executeDeleteSkipApproval(Runnable deleteAction) { + SKIP_APPROVAL.set(true); + try { + deleteAction.run(); + } finally { + SKIP_APPROVAL.remove(); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalAction.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalAction.java new file mode 100644 index 0000000..4e4b0aa --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalAction.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.approval.constants; + +public enum ApprovalAction { + + /** 同意 **/ + APPROVE, + /** 驳回 **/ + REJECT, + /** 加签 **/ + SIGN, + /** 退回 **/ + BACK, + /** 撤回 **/ + REVOKE +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalAddSignType.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalAddSignType.java new file mode 100644 index 0000000..27c6c8e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalAddSignType.java @@ -0,0 +1,9 @@ +package cn.cordys.crm.approval.constants; + +public enum ApprovalAddSignType { + + /** 在我之前 **/ + BEFORE, + /** 在我之后 **/ + AFTER +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalFormTypeEnum.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalFormTypeEnum.java new file mode 100644 index 0000000..ff4dd9b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalFormTypeEnum.java @@ -0,0 +1,49 @@ +package cn.cordys.crm.approval.constants; + +import cn.cordys.common.constants.ValueEnum; + +/** + * 表单类型枚举 + */ +public enum ApprovalFormTypeEnum implements ValueEnum { + + /** 报价 */ + QUOTATION("QTE-APV", "OPPORTUNITY_MANAGEMENT_QUOTATION", "quotation"), + /** 合同 */ + CONTRACT("CTR-APV", "CONTRACT", "contract"), + /** 发票 */ + INVOICE("INV-APV", "CONTRACT_INVOICE", "invoice"), + /** 订单 */ + ORDER("ORD-APV", "ORDER", "order"); + + private final String prefix; + private final String permissionId; + private final String value; + + ApprovalFormTypeEnum(String prefix, String permissionId, String value) { + this.prefix = prefix; + this.permissionId = permissionId; + this.value = value; + } + + public String getPrefix() { + return prefix; + } + + public String getPermissionId() { + return permissionId; + } + + public static ApprovalFormTypeEnum getByValue(String value) { + for (ApprovalFormTypeEnum type : values()) { + if (type.value.equals(value)) { + return type; + } + } + return null; + } + + public String getValue() { + return value; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalNodeTypeEnum.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalNodeTypeEnum.java new file mode 100644 index 0000000..d63cdfc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalNodeTypeEnum.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.approval.constants; + +/** + * 审批节点类型枚举 + */ +public enum ApprovalNodeTypeEnum { + + /** 开始节点 */ + START, + /** 审批人节点 */ + APPROVER, + /** 触发条件节点 */ + CONDITION, + /** 默认节点,条件不满足时的节点 */ + DEFAULT, + /** 结束节点 */ + END, + /** 异常节点 */ + EXCEPTION; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalState.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalState.java new file mode 100644 index 0000000..e75dd4a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalState.java @@ -0,0 +1,57 @@ +package cn.cordys.crm.approval.constants; + +import cn.cordys.common.util.Translator; +import lombok.Getter; + +import static cn.cordys.crm.opportunity.constants.OpportunityStageType.AFOOT; +import static cn.cordys.crm.opportunity.constants.OpportunityStageType.END; + +@Getter +public enum ApprovalState { + + /** + * 通过 + */ + APPROVED("APPROVED", Translator.get("log.approvalStatus.VOIDED"), END.toString()), + + /** + * 不通过 + */ + UNAPPROVED("UNAPPROVED", Translator.get("log.approvalStatus.UNAPPROVED"), END.toString()), + + /** + * 提审 + */ + APPROVING("APPROVING", Translator.get("log.approvalStatus.APPROVING"), AFOOT.toString()), + + /** + * 待审批 + */ + PENDING("PENDING", Translator.get("log.approvalStatus.PENDING"), AFOOT.toString()), + + /** + * 撤销 + */ + REVOKED("REVOKED", Translator.get("log.approvalStatus.REVOKED"), AFOOT.toString()); + + /** + * 固定审批节点 ID + */ + private final String id; + /** + * 审批节点名称 + */ + private final String name; + + /** + * 审批节点类型 + */ + private final String type; + + ApprovalState(String id, String name, String type) { + this.id = id; + this.name = name; + this.type = type; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalStatus.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalStatus.java new file mode 100644 index 0000000..d9c02f9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalStatus.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.approval.constants; + +/** + * 统一的审批状态 + */ +public enum ApprovalStatus { + + /** 无 **/ + NONE, + /** 待提审, 待审批 */ + PENDING, + /** 审批中 */ + APPROVING, + /** 已通过 */ + APPROVED, + /** 已驳回 */ + UNAPPROVED, + /** 已撤销 */ + REVOKED, + + // 扩展项 + /** 自动通过 */ + AUTO_APPROVED, + /** 自动拒绝 */ + AUTO_UNAPPROVED +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalTaskType.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalTaskType.java new file mode 100644 index 0000000..bc4baa6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalTaskType.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.approval.constants; + +public enum ApprovalTaskType { + + /** 抄送任务 **/ + CC, + /** 加签任务 **/ + SN, + /** 退回任务 **/ + BK, + /** 普通任务 **/ + NL +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalTypeEnum.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalTypeEnum.java new file mode 100644 index 0000000..560f424 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApprovalTypeEnum.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.approval.constants; + +/** + * 审批类型枚举 + */ +public enum ApprovalTypeEnum { + + /** 人工审批 */ + MANUAL, + /** 自动通过 */ + AUTO_PASS, + /** 自动拒绝 */ + AUTO_REJECT +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApproverDirectionEnum.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApproverDirectionEnum.java new file mode 100644 index 0000000..ae06e37 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApproverDirectionEnum.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.approval.constants; + +/** + * 审批人方向枚举(上级/部门负责人) + */ +public enum ApproverDirectionEnum { + + /** 从下往上:从提交人所在层级往上逐级查找 */ + BOTTOM_UP, + /** 从上往下:从最高层级往下逐级查找到提交人所在层级 */ + TOP_DOWN +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApproverLevelEnum.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApproverLevelEnum.java new file mode 100644 index 0000000..79463db --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApproverLevelEnum.java @@ -0,0 +1,28 @@ +package cn.cordys.crm.approval.constants; + +/** + * @Author: jianxing + * @CreateTime: 2026-05-09 17:04 + */ +public enum ApproverLevelEnum { + FIRST_LEVEL("1"), + SECOND_LEVEL("2"), + THIRD_LEVEL("3"), + FOURTH_LEVEL("4"), + FIFTH_LEVEL("5"), + SIXTH_LEVEL("6"), + SEVENTH_LEVEL("7"), + EIGHTH_LEVEL("8"), + NINTH_LEVEL("9"), + TENTH_LEVEL("10"); + + private final String value; + + ApproverLevelEnum(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApproverTypeEnum.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApproverTypeEnum.java new file mode 100644 index 0000000..69744ee --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ApproverTypeEnum.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.approval.constants; + +/** + * 审批人类型枚举 + */ +public enum ApproverTypeEnum { + + /** 指定成员 */ + MEMBER, + /** 指定上级 */ + SUPERIOR, + /** 多级上级 */ + MULTIPLE_SUPERIOR, + /** 部门负责人 */ + DEPT_HEAD, + /** 多级部门负责人 */ + MULTIPLE_DEPT_HEAD, + /** 指定角色 */ + ROLE +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/DuplicateApproverRuleEnum.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/DuplicateApproverRuleEnum.java new file mode 100644 index 0000000..cc31a43 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/DuplicateApproverRuleEnum.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.approval.constants; + +/** + * 重复审批人规则枚举 + */ +public enum DuplicateApproverRuleEnum { + + /** 仅首个节点需审批,后续自动同意 */ + FIRST_ONLY, + /** 仅连续审批时自动同意 */ + SEQUENTIAL_ALL, + /** 每个节点都需要审批 */ + EACH +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/EmptyApproverActionEnum.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/EmptyApproverActionEnum.java new file mode 100644 index 0000000..c8aca40 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/EmptyApproverActionEnum.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.approval.constants; + +/** + * 审批人为空时动作枚举 + */ +public enum EmptyApproverActionEnum { + + /** 自动通过 */ + AUTO_PASS, + /** 指定人员审批 */ + ASSIGN_SPECIFIC, + /** 交给审批管理员 */ + ASSIGN_ADMIN +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ExecuteTimingEnum.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ExecuteTimingEnum.java new file mode 100644 index 0000000..a32f6ff --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/ExecuteTimingEnum.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.approval.constants; + +/** + * 执行时机枚举 + */ +public enum ExecuteTimingEnum { + + /** 创建 */ + CREATE, + /** 编辑 */ + UPDATE, + /** 删除 */ + DELETE +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/FieldPermissionTypeEnum.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/FieldPermissionTypeEnum.java new file mode 100644 index 0000000..bd5a3a9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/FieldPermissionTypeEnum.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.approval.constants; + +/** + * 字段权限类型枚举 + */ +public enum FieldPermissionTypeEnum { + + /** 隐藏 */ + HIDDEN, + /** 查看 */ + VIEW, + /** 编辑 */ + EDIT +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/MultiApproverModeEnum.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/MultiApproverModeEnum.java new file mode 100644 index 0000000..dcc4d9c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/MultiApproverModeEnum.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.approval.constants; + +/** + * 多人审批方式枚举 + */ +public enum MultiApproverModeEnum { + + /** 会签(需所有审批人同意) */ + ALL, + /** 或签(仅需一名审批人同意) */ + ANY, + /** 依次审批 */ + SEQUENTIAL +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/constants/SameSubmitterActionEnum.java b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/SameSubmitterActionEnum.java new file mode 100644 index 0000000..9976bc7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/constants/SameSubmitterActionEnum.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.approval.constants; + +/** + * 审批人与提交人相同时动作枚举 + */ +public enum SameSubmitterActionEnum { + + /** 自动跳过 */ + SKIP, + /** 由提交人审批 */ + ALLOW, + /** 交给直属上级审批 */ + ASSIGN_SUPERIOR +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/controller/ApprovalActionController.java b/backend/crm/src/main/java/cn/cordys/crm/approval/controller/ApprovalActionController.java new file mode 100644 index 0000000..1bf342a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/controller/ApprovalActionController.java @@ -0,0 +1,65 @@ +package cn.cordys.crm.approval.controller; + +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.approval.dto.request.*; +import cn.cordys.crm.approval.service.ApprovalActionService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "审批操作") +@RestController +@RequestMapping("/approval-action") +public class ApprovalActionController { + + @Resource + private ApprovalActionService approvalActionService; + + @PostMapping("/sign") + @Operation(summary = "加签") + public void add(@Validated @RequestBody ApprovalAddSignRequest request) { + approvalActionService.sign(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/back") + @Operation(summary = "退回") + public void back(@Validated @RequestBody ApprovalReturnBackRequest request) { + approvalActionService.back(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/revoke") + @Operation(summary = "撤回") + public void revoke(@Validated @RequestBody ApprovalRevokeRequest request) { + approvalActionService.revoke(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/approve") + @Operation(summary = "同意") + public void approve(@Validated @RequestBody ApprovalActionRequest request) { + approvalActionService.approve(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/reject") + @Operation(summary = "驳回") + public void reject(@Validated @RequestBody ApprovalActionRequest request) { + approvalActionService.reject(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch-approve") + @Operation(summary = "批量同意") + public void batchApprove(@Validated @RequestBody ApprovalActionBatchRequest request) { + approvalActionService.batchApprove(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch-reject") + @Operation(summary = "批量驳回") + public void batchReject(@Validated @RequestBody ApprovalActionBatchRequest request) { + approvalActionService.batchReject(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/controller/ApprovalFlowController.java b/backend/crm/src/main/java/cn/cordys/crm/approval/controller/ApprovalFlowController.java new file mode 100644 index 0000000..80b44fb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/controller/ApprovalFlowController.java @@ -0,0 +1,98 @@ +package cn.cordys.crm.approval.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.approval.dto.WebHookConfig; +import cn.cordys.crm.approval.dto.request.ApprovalFlowAddRequest; +import cn.cordys.crm.approval.dto.request.ApprovalFlowPageRequest; +import cn.cordys.crm.approval.dto.request.ApprovalFlowUpdateRequest; +import cn.cordys.crm.approval.dto.response.ApprovalFlowByFormTypeResponse; +import cn.cordys.crm.approval.dto.response.ApprovalFlowDetailResponse; +import cn.cordys.crm.approval.dto.response.ApprovalFlowListResponse; +import cn.cordys.crm.approval.dto.response.StatusPermissionSettingResponse; +import cn.cordys.crm.approval.service.ApprovalFlowService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * @author jianxing ai + */ +@Tag(name = "审批流设置") +@RestController +@RequestMapping("/approval-flow") +public class ApprovalFlowController { + + @Resource + private ApprovalFlowService approvalFlowService; + + @PostMapping("/page") + @RequiresPermissions(PermissionConstants.PROCESS_SETTING_READ) + @Operation(summary = "审批流列表") + public Pager> list(@Validated @RequestBody ApprovalFlowPageRequest request) { + ConditionFilterUtils.parseCondition(request); + return approvalFlowService.list(request, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.PROCESS_SETTING_ADD) + @Operation(summary = "新建审批流") + public ApprovalFlowDetailResponse add(@Validated @RequestBody ApprovalFlowAddRequest request) { + return approvalFlowService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.PROCESS_SETTING_UPDATE) + @Operation(summary = "更新审批流") + public ApprovalFlowDetailResponse update(@Validated @RequestBody ApprovalFlowUpdateRequest request) { + return approvalFlowService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.PROCESS_SETTING_DELETE) + @Operation(summary = "删除审批流") + public void delete(@PathVariable("id") String id) { + approvalFlowService.delete(id, OrganizationContext.getOrganizationId()); + } + + @GetMapping("/get/{id}") + @RequiresPermissions(PermissionConstants.PROCESS_SETTING_READ) + @Operation(summary = "获取审批流详情") + public ApprovalFlowDetailResponse get(@PathVariable("id") String id) { + return approvalFlowService.getDetail(id, OrganizationContext.getOrganizationId()); + } + + @GetMapping("/enable/{id}") + @RequiresPermissions(PermissionConstants.PROCESS_SETTING_UPDATE) + @Operation(summary = "启用/禁用审批流") + public void updateEnable(@PathVariable("id") String id, @RequestParam("enable") Boolean enable) { + approvalFlowService.updateEnable(id, enable, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/status-permission/setting/{formType}") + @Operation(summary = "根据表单类型获取审批流状态权限配置") + public StatusPermissionSettingResponse getStatusPermissionSetting(@PathVariable("formType") String formType) { + return approvalFlowService.getStatusPermissionsByFormType(formType, OrganizationContext.getOrganizationId()); + } + + @GetMapping("/get-by-form-type/{formType}") + @Operation(summary = "根据表单类型获取审批流信息") + public ApprovalFlowByFormTypeResponse getByFormType(@PathVariable("formType") String formType) { + return approvalFlowService.getByFormType(formType, OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/webhook/test") + @Operation(summary = "webhook-测试连接") + public void verifyEmailConnection(@Validated @RequestBody WebHookConfig webHookConfig) { + approvalFlowService.testConnection(webHookConfig); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/controller/ApprovalResourceController.java b/backend/crm/src/main/java/cn/cordys/crm/approval/controller/ApprovalResourceController.java new file mode 100644 index 0000000..b1bc1a2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/controller/ApprovalResourceController.java @@ -0,0 +1,60 @@ +package cn.cordys.crm.approval.controller; + +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.approval.constants.ExecuteTimingEnum; +import cn.cordys.crm.approval.dto.ApprovalInstanceDetail; +import cn.cordys.crm.approval.dto.ApprovalPushParam; +import cn.cordys.crm.approval.dto.ApprovalResourceBaseParam; +import cn.cordys.crm.approval.dto.response.ResourceApprovalResponse; +import cn.cordys.crm.approval.service.ApprovalInstanceService; +import cn.cordys.crm.approval.service.ApprovalResourceService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.*; + +/** + * 资源审批相关 + */ +@RestController +@RequestMapping("/approval-resource") +@Tag(name = "审批资源") +public class ApprovalResourceController { + + @Resource + private ApprovalResourceService approvalResourceService; + @Resource + private ApprovalInstanceService approvalInstanceService; + + @PostMapping("/push") + @Operation(summary = "提审") + public void push(@RequestBody ApprovalResourceBaseParam param) { + ApprovalPushParam approvalPushParam = ApprovalPushParam.builder() + .orgId(OrganizationContext.getOrganizationId()) + .userId(SessionUtils.getUserId()) + .resourceId(param.getResourceId()) + .formKey(param.getFormKey()) + .executeTimingEnum(ExecuteTimingEnum.CREATE) + .build(); + approvalResourceService.push(approvalPushParam); + } + + @PostMapping("/revoke") + @Operation(summary = "撤销") + public void revoke(@RequestBody ApprovalResourceBaseParam param) { + approvalResourceService.revoke(param, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/simple-detail/{resourceId}") + @Operation(summary = "列表详情") + public ResourceApprovalResponse resourceDetail(@PathVariable String resourceId) { + return approvalResourceService.resourceDetail(resourceId); + } + + @GetMapping("/detail/{resourceId}") + @Operation(summary = "记录详情") + public ApprovalInstanceDetail getRecordDetail(@PathVariable String resourceId) { + return approvalInstanceService.getLatestApprovalInstanceDetail(resourceId, OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/controller/ApprovalTodoController.java b/backend/crm/src/main/java/cn/cordys/crm/approval/controller/ApprovalTodoController.java new file mode 100644 index 0000000..ef648bd --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/controller/ApprovalTodoController.java @@ -0,0 +1,54 @@ +package cn.cordys.crm.approval.controller; + +import cn.cordys.common.pager.Pager; +import cn.cordys.crm.approval.dto.request.ApprovalTodoPageRequest; +import cn.cordys.crm.approval.dto.response.ApprovalTodoCountResponse; +import cn.cordys.crm.approval.dto.response.ApprovalTodoItemResponse; +import cn.cordys.crm.approval.service.ApprovalTodoService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/approval-todo") +@Tag(name = "审核代办") +public class ApprovalTodoController { + + @Resource + private ApprovalTodoService approvalTodoService; + + @PostMapping("/pending/page") + @Operation(summary = "审核代办-当前用户待审核资源分页", description = "返回值:Pager>") + public Pager> todo(@Validated @RequestBody ApprovalTodoPageRequest request) { + return approvalTodoService.getTodoPage(request, SessionUtils.getUserId()); + } + + @PostMapping("/processed/page") + @Operation(summary = "审核代办-当前用户已处理审批分页", description = "返回值:Pager>") + public Pager> processedPage(@Validated @RequestBody ApprovalTodoPageRequest request) { + return approvalTodoService.getProcessedPage(request, SessionUtils.getUserId()); + } + + @PostMapping("/initiated/page") + @Operation(summary = "审核代办-我发起的审批分页", description = "返回值:Pager>") + public Pager> initiatedPage(@Validated @RequestBody ApprovalTodoPageRequest request) { + return approvalTodoService.getInitiatedPage(request, SessionUtils.getUserId()); + } + + @PostMapping("/cc/page") + @Operation(summary = "审核代办-抄送我的审批分页", description = "返回值:Pager>") + public Pager> ccPage(@Validated @RequestBody ApprovalTodoPageRequest request) { + return approvalTodoService.getCcPage(request, SessionUtils.getUserId()); + } + + @GetMapping("/pending/count") + @Operation(summary = "审核代办-待我审批统计") + public ApprovalTodoCountResponse pendingCount() { + return approvalTodoService.getPendingCount(SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalAddSignTask.java b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalAddSignTask.java new file mode 100644 index 0000000..fb65b3b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalAddSignTask.java @@ -0,0 +1,34 @@ +package cn.cordys.crm.approval.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * 加签任务 + */ +@Data +@Table(name = "approval_add_sign_task") +public class ApprovalAddSignTask { + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "加签任务ID") + private String taskId; + + @Schema(description = "加签的节点ID") + private String signTaskId; + + @Schema(description = "加签方式") + private String type; + + @Schema(description = "根任务ID(同一加签链的根节点)") + private String rootTaskId; + + @Schema(description = "顺序") + private Long sort; + + @Schema(description = "加签意见") + private String comment; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalFlow.java b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalFlow.java new file mode 100644 index 0000000..e0ce993 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalFlow.java @@ -0,0 +1,67 @@ +package cn.cordys.crm.approval.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@Table(name = "approval_flow") +public class ApprovalFlow extends BaseModel { + + @Schema(description = "当前版本ID") + private String currentVersionId; + + @Schema(description = "流程编码") + private String number; + + @Schema(description = "流程名称") + private String name; + + @Schema(description = "表单类型:quotation/contract/invoice/order") + private String formType; + + @Schema(description = "新建时执行") + private Boolean createExecute; + + @Schema(description = "编辑时执行") + private Boolean updateExecute; + + @Schema(description = "删除时执行") + private Boolean deleteExecute; + + @Schema(description = "允许提交人撤销") + private Boolean submitterCanRevoke; + + @Schema(description = "允许批量处理") + private Boolean allowBatchProcess; + + @Schema(description = "允许撤回") + private Boolean allowWithdraw; + + @Schema(description = "允许加签") + private Boolean allowAddSign; + + @Schema(description = "重复审批人规则:FIRST_ONLY/SEQUENTIAL_ALL/EACH") + private String duplicateApproverRule; + + @Schema(description = "是否必须填写审批意见") + private Boolean requireComment; + + @Schema(description = "启用状态") + private Boolean enable; + + @Schema(description = "是否删除") + private Boolean deleted; + + @Schema(description = "流程描述") + private String description; + + @Schema(description = "状态权限配置(JSON格式)") + private String statusPermissions; + + @Schema(description = "组织id") + private String organizationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalFlowVersion.java b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalFlowVersion.java new file mode 100644 index 0000000..8bcb256 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalFlowVersion.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.approval.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +@Data +@Table(name = "approval_flow_version") +public class ApprovalFlowVersion implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "ID") + private String id; + + @Schema(description = "审批流ID") + private String flowId; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "创建时间") + private Long createTime; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalInstance.java b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalInstance.java new file mode 100644 index 0000000..9f2e340 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalInstance.java @@ -0,0 +1,49 @@ +package cn.cordys.crm.approval.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 审批实例 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Table(name = "approval_instance") +public class ApprovalInstance extends BaseModel { + + @Schema(description = "审批流版本ID") + private String flowVersionId; + + @Schema(description = "表单类型 quote(报价表单)、contract(合同表单)、invoice(发票表单)") + private String type; + + @Schema(description = "审批的数据ID") + private String resourceId; + + @Schema(description = "提交人ID") + private String submitterId; + + @Schema(description = "当前节点ID") + private String currentNodeId; + + @Schema(description = "审批状态") + private String approvalStatus; + + @Schema(description = "执行时机:CREATE/UPDATE/DELETE") + private String executeTime; + + @Schema(description = "提审时间") + private Long submitTime; + + @Schema(description = "审批完成时间") + private Long approvalTime; + + @Schema(description = "变更说明") + private String comment; + + @Schema(description = "编辑时修改的字段列表") + private String updateFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalInstanceAttachment.java b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalInstanceAttachment.java new file mode 100644 index 0000000..f4077b8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalInstanceAttachment.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.approval.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "approval_instance_attachment") +public class ApprovalInstanceAttachment { + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "审批实例ID") + private String instanceId; + + @Schema(description = "审批节点ID") + private String elementId; + + @Schema(description = "附件ID") + private String attachmentId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalNode.java b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalNode.java new file mode 100644 index 0000000..ee295e7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalNode.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.approval.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "approval_node") +public class ApprovalNode { + + @Schema(description = "ID") + private String id; + + @Schema(description = "审批流版本ID") + private String flowVersionId; + + @Schema(description = "节点编码") + private String number; + + @Schema(description = "节点名称") + private String name; + + @Schema(description = "节点类型:START/APPROVER/CONDITION/DEFAULT/END") + private String nodeType; + + @Schema(description = "排序序号") + private Integer sort; + + @Schema(description = "执行时机:CREATE/UPDATE/DELETE") + private String executeTime; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalNodeApprover.java b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalNodeApprover.java new file mode 100644 index 0000000..dd6ecca --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalNodeApprover.java @@ -0,0 +1,63 @@ +package cn.cordys.crm.approval.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Table(name = "approval_node_approver") +public class ApprovalNodeApprover { + + @Schema(description = "ID") + private String id; + + @Schema(description = "审批流版本ID") + private String flowVersionId; + + @NotBlank + @Schema(description = "审批类型:MANUAL/AUTO_PASS/AUTO_REJECT") + private String approvalType; + + @NotBlank + @Schema(description = "多人审批方式:ALL/ANY/SEQUENTIAL") + private String multiApproverMode; + + @NotBlank + @Schema(description = "审批人为空时动作") + private String emptyApproverAction; + + @Schema(description = "审批人为空时,兜底审批人") + private String fallbackApprover; + + @NotBlank + @Schema(description = "审批人与提交人相同时动作") + private String sameSubmitterAction; + + @Schema(description = "审批人类型:MEMBER/SUPERIOR/MULTIPLE_SUPERIOR/DEPT_HEAD/MULTIPLE_DEPT_HEAD/ROLE") + private String approverType; + + @Schema(description = "审批人方向:BOTTOM_UP(从下往上)/TOP_DOWN(从上往下),适用于SUPERIOR/MULTIPLE_SUPERIOR/DEPT_HEAD/MULTIPLE_DEPT_HEAD") + private String approverDirection; + + @Schema(description = "审批人列表(JSON数组)") + private String approverList; + + @Schema(description = "抄送人类型:MEMBER/SUPERIOR/MULTIPLE_SUPERIOR/DEPT_HEAD/MULTIPLE_DEPT_HEAD/ROLE") + private String ccType; + + @Schema(description = "抄送人方向:BOTTOM_UP(从下往上)/TOP_DOWN(从上往下),适用于SUPERIOR/MULTIPLE_SUPERIOR/DEPT_HEAD/MULTIPLE_DEPT_HEAD") + private String ccDirection; + + @Schema(description = "抄送人列表(JSON数组)") + private String ccList; + + @Schema(description = "审批通过后配置(JSON格式)") + private String passPostConfig; + + @Schema(description = "审批驳回后配置(JSON格式)") + private String rejectPostConfig; + + @Schema(description = "字段权限配置(JSON格式)") + private String fieldPermissions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalNodeCondition.java b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalNodeCondition.java new file mode 100644 index 0000000..0113263 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalNodeCondition.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.approval.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "approval_node_condition") +public class ApprovalNodeCondition { + + @Schema(description = "ID") + private String id; + + @Schema(description = "审批流版本ID") + private String flowVersionId; + + @Schema(description = "条件配置JSON格式") + private String conditionConfig; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalNodeLink.java b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalNodeLink.java new file mode 100644 index 0000000..c3ea910 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalNodeLink.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.approval.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "approval_node_link") +public class ApprovalNodeLink { + + @Schema(description = "ID") + private String id; + + @Schema(description = "审批流版本ID") + private String flowVersionId; + + @Schema(description = "源节点ID") + private String fromNodeId; + + @Schema(description = "目标节点ID") + private String toNodeId; + + @Schema(description = "分支评估顺序") + private Integer sort; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalRecord.java b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalRecord.java new file mode 100644 index 0000000..8af32fe --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalRecord.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.approval.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * 审批记录 + */ +@Data +@Table(name = "approval_record") +public class ApprovalRecord extends BaseModel { + + @Schema(description = "审批实例ID") + private String instanceId; + + @Schema(description = "任务ID") + private String taskId; + + @Schema(description = "节点轮次") + private Integer nodeRound; + + @Schema(description = "节点ID") + private String nodeId; + + @Schema(description = "审批结果") + private String result; + + @Schema(description = "审批意见") + private String comment; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalReturnBackRecord.java b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalReturnBackRecord.java new file mode 100644 index 0000000..bcb69f5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalReturnBackRecord.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.approval.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * 审批退回记录 + */ +@Data +@Table(name = "approval_return_back_record") +public class ApprovalReturnBackRecord { + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "实例ID") + private String instanceId; + + @Schema(description = "当前任务ID") + private String taskId; + + @Schema(description = "退回至节点ID") + private String returnToNodeId; + + @Schema(description = "退回原因") + private String returnReason; + + @Schema(description = "退回操作人") + private String returnUserId; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalTask.java b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalTask.java new file mode 100644 index 0000000..f6bd990 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/domain/ApprovalTask.java @@ -0,0 +1,35 @@ +package cn.cordys.crm.approval.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * 审批任务 + */ +@Data +@Table(name = "approval_task") +public class ApprovalTask extends BaseModel { + + @Schema(description = "节点ID") + private String nodeId; + + @Schema(description = "节点轮次") + private Integer nodeRound; + + @Schema(description = "审批实例ID") + private String instanceId; + + @Schema(description = "审批人ID") + private String approverId; + + @Schema(description = "任务状态") + private String status; + + @Schema(description = "任务类型") + private String type; + + @Schema(description = "执行操作") + private String action; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/AddSignSortInfo.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/AddSignSortInfo.java new file mode 100644 index 0000000..8e5dc3e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/AddSignSortInfo.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.approval.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AddSignSortInfo { + + /** 根任务ID(同一加签链的根节点) */ + private String rootTaskId; + + /** 排序(越小越靠前) */ + private Long sort; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalCcNode.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalCcNode.java new file mode 100644 index 0000000..13fefc3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalCcNode.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.approval.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApprovalCcNode { + + @Schema(description = "抄送人ID") + private String ccUserId; + @Schema(description = "抄送人名称") + private String ccUserName; + @Schema(description = "抄送人头像") + private String ccUserAvatar; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalExecuteParam.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalExecuteParam.java new file mode 100644 index 0000000..f1ba998 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalExecuteParam.java @@ -0,0 +1,34 @@ +package cn.cordys.crm.approval.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 审批执行参数 + * @author song-cc-rock + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApprovalExecuteParam { + + @NotBlank + @Schema(description = "当前审批执行任务ID") + private String currentTaskId; + + @Schema(description = "审批动作", allowableValues = {"APPROVE", "REJECT", "SIGN", "BACK"}) + private String action; + + @Schema(description = "审批意见") + private String comment; + + @Schema(description = "审批意见的附件集合") + private List attachmentIds; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalInstanceDetail.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalInstanceDetail.java new file mode 100644 index 0000000..b4cc31f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalInstanceDetail.java @@ -0,0 +1,43 @@ +package cn.cordys.crm.approval.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class ApprovalInstanceDetail { + + @Schema(description = "审批实例ID") + private String id; + + @Schema(description = "提交人ID") + private String submitterId; + + @Schema(description = "提交人头像") + private String submitAvatar; + + @Schema(description = "提交人") + private String submitter; + + @Schema(description = "提交时间") + private Long submitTime; + + @Schema(description = "变更说明") + private String comment; + + @Schema(description = "审批状态") + private String approvalStatus; + + @Schema(description = "当前节点ID") + private String currentNodeId; + + @Schema(description = "执行时机:CREATE/UPDATE/DELETE") + private String executeTime; + + @Schema(description = "审批节点") + private List nodes; + + @Schema(description = "当前节点字段权限") + private String currentNodeFieldPermissions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalPostConfigDTO.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalPostConfigDTO.java new file mode 100644 index 0000000..79665af --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalPostConfigDTO.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.approval.dto; + +import cn.cordys.common.dto.EnableFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 审批通过后配置 + */ +@Data +public class ApprovalPostConfigDTO { + + @Schema(description = "字段更新配置列表") + private List fieldUpdateConfigs; + + @Schema(description = "webhook配置") + private WebHookConfig webHookConfig; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalPushParam.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalPushParam.java new file mode 100644 index 0000000..9a9ce23 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalPushParam.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.approval.dto; + +import cn.cordys.crm.approval.constants.ExecuteTimingEnum; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApprovalPushParam extends ApprovalResourceBaseParam{ + private String resourceId; + private String formKey; + private String comment; + private String orgId; + private String userId; + private ExecuteTimingEnum executeTimingEnum; + private String updateFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalRecordNode.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalRecordNode.java new file mode 100644 index 0000000..de48a39 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalRecordNode.java @@ -0,0 +1,54 @@ +package cn.cordys.crm.approval.dto; + +import cn.cordys.crm.approval.constants.MultiApproverModeEnum; +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApprovalRecordNode { + + @Schema(description = "节点ID") + private String nodeId; + + @Schema(description = "节点名称") + private String nodeName; + + @Schema(description = "节点轮次") + private Integer nodeRound; + + @Schema(description = "序号") + private Integer sort; + + @Schema(description = "审批状态") + private String approvalStatus; + + @Schema(description = "审批任务") + private List taskNodes; + + @Schema(description = "多人审批方式", allowableValues = {"ALL: 会签", "ANY: 或签", "SEQUENTIAL: 依次审批"}) + private MultiApproverModeEnum multiApproverMode; + + @Schema(description = "是否结束节点") + private boolean endNode; + + @Schema(description = "是否退回节点") + private boolean backNode; + + @Schema(description = "退回原因") + private String backReason; + + @Schema(description = "退回附件") + private List backAttachments; + + @Schema(description = "抄送的节点集合") + private List ccNodes; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalResourceBaseParam.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalResourceBaseParam.java new file mode 100644 index 0000000..9c03a61 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalResourceBaseParam.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.approval.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ApprovalResourceBaseParam { + + @NotBlank + @Schema(description = "资源ID") + private String resourceId; + + @NotBlank + @Schema(description = "资源表单") + private String formKey; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalTaskNode.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalTaskNode.java new file mode 100644 index 0000000..381a032 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApprovalTaskNode.java @@ -0,0 +1,56 @@ +package cn.cordys.crm.approval.dto; + +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApprovalTaskNode { + + @Schema(description = "审批任务ID") + private String taskId; + + @Schema(description = "审批人ID") + private String approverId; + + @Schema(description = "审批人名称") + private String approver; + + @Schema(description = "抄送人头像") + private String approverAvatar; + + @Schema(description = "审批状态") + private String approvalStatus; + + @Schema(description = "是否加签任务") + private boolean sign; + + @Schema(description = "加签意见") + private String signComment; + + @Schema(description = "加签附件") + private List signAttachments; + + @Schema(description = "是否进行加签操作") + private boolean signAction; + + @Schema(description = "执行记录ID") + private String recordId; + + @Schema(description = "审批意见") + private String comment; + + @Schema(description = "审批附件") + private List attachments; + + @Schema(description = "审批完成时间") + private Long approvalTime; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApproverConfigDTO.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApproverConfigDTO.java new file mode 100644 index 0000000..476b8ca --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ApproverConfigDTO.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.approval.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 审批人配置DTO + */ +@Data +public class ApproverConfigDTO { + + @Schema(description = "审批人类型:MEMBER/SUPERIOR/DEPT_HEAD/ROLE/FORM_FIELD") + private String type; + + @Schema(description = "审批人值") + private String value; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/FieldPermissionDTO.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/FieldPermissionDTO.java new file mode 100644 index 0000000..dde035e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/FieldPermissionDTO.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.approval.dto; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.crm.approval.constants.FieldPermissionTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 字段权限配置DTO + */ +@Data +public class FieldPermissionDTO { + + @Schema(description = "字段ID") + private String fieldId; + + @EnumValue(enumClass = FieldPermissionTypeEnum.class) + @Schema(description = "权限类型:HIDDEN/VIEW/EDIT") + private String permissionType; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ResourceApprovalFieldUpdateParam.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ResourceApprovalFieldUpdateParam.java new file mode 100644 index 0000000..a723ecc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ResourceApprovalFieldUpdateParam.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.approval.dto; + +import lombok.Data; + +@Data +public class ResourceApprovalFieldUpdateParam { + /** + * 字段ID + */ + private String fieldId; + /** + * 字段更新值 + */ + private Object fieldValue; + /** + * 是否开启 + */ + private boolean enable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ResourceApprovalPostUpdateParam.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ResourceApprovalPostUpdateParam.java new file mode 100644 index 0000000..cc5fd90 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ResourceApprovalPostUpdateParam.java @@ -0,0 +1,33 @@ +package cn.cordys.crm.approval.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 资源审批后置更新参数 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ResourceApprovalPostUpdateParam { + + /** + * 更新字段参数 + */ + private List fields; + + /** + * 资源ID + */ + private String resourceId; + + /** + * 操作人 + */ + private String operator; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ResourceSnapshotApprovalParam.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ResourceSnapshotApprovalParam.java new file mode 100644 index 0000000..15d5588 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/ResourceSnapshotApprovalParam.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.approval.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class ResourceSnapshotApprovalParam { + + private String resourceId; + private String approvalStatus; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/StatusPermissionDTO.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/StatusPermissionDTO.java new file mode 100644 index 0000000..d3aec91 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/StatusPermissionDTO.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.approval.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 状态权限配置DTO + */ +@Data +public class StatusPermissionDTO { + @Schema(description = "审批状态") + private String approvalStatus; + + @Schema(description = "权限") + private String permission; + + @Schema(description = "是否启用") + private Boolean enabled; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/WebHookConfig.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/WebHookConfig.java new file mode 100644 index 0000000..868c3bd --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/WebHookConfig.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.approval.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class WebHookConfig { + + + @Schema(description = "是否启用") + private Boolean webHookEnable; + + @Schema(description = "webhook地址") + private String webHookUrl; + + @Schema(description = "请求方式") + private String webHookMethod; + + @Schema(description = "请求头") + private String webHookHeader; + + @Schema(description = "请求体") + private String webHookBody; + + @Schema(description = "说明") + private String webHookDescribe; + + + public WebHookConfig() { + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalActionBatchRequest.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalActionBatchRequest.java new file mode 100644 index 0000000..c2f8cc2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalActionBatchRequest.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.approval.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class ApprovalActionBatchRequest { + + @NotEmpty(message = "当前任务节点ID集合不能为空") + @Schema(description = "ids", requiredMode = Schema.RequiredMode.REQUIRED) + private List ids; + + @Schema(description = "意见, 评论") + private String comment; + + @Schema(description = "附件ID集合") + private List attachmentIds; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalActionRequest.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalActionRequest.java new file mode 100644 index 0000000..44e5da5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalActionRequest.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.approval.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ApprovalActionRequest { + + @NotBlank(message = "当前任务节点ID不能为空") + @Schema(description = "当前任务节点ID") + private String id; + + @NotBlank(message = "当前节点ID不能为空") + @Schema(description = "节点ID") + private String nodeId; + + @NotBlank(message = "审批实例ID不能为空") + @Schema(description = "审批实例ID") + private String instanceId; + + @NotBlank(message = "审批人ID不能为空") + @Schema(description = "审批人ID") + private String approverId; + + @Schema(description = "意见, 评论") + private String comment; + + @Schema(description = "附件ID集合") + private List attachmentIds; + + public String getNodeId() { + if (nodeId.contains("-SN")) { + return nodeId.split("-SN")[0]; + } + return nodeId; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalAddSignRequest.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalAddSignRequest.java new file mode 100644 index 0000000..2b2d080 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalAddSignRequest.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.approval.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +@Data +public class ApprovalAddSignRequest extends ApprovalActionRequest { + + @Schema(description = "加签方式 BEFORE: 在我之前,AFTER: 在我之后") + private String type; + + @NotEmpty + @Schema(description = "加签审批人") + private String signApprover; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalFlowAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalFlowAddRequest.java new file mode 100644 index 0000000..c8b3eb3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalFlowAddRequest.java @@ -0,0 +1,75 @@ +package cn.cordys.crm.approval.dto.request; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.crm.approval.constants.ApprovalFormTypeEnum; +import cn.cordys.crm.approval.constants.DuplicateApproverRuleEnum; +import cn.cordys.crm.approval.dto.StatusPermissionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.List; + +@Data +public class ApprovalFlowAddRequest { + + @NotBlank(message = "流程名称不能为空") + @Schema(description = "流程名称") + private String name; + + @EnumValue(enumClass = ApprovalFormTypeEnum.class) + @NotBlank(message = "表单类型不能为空") + @Schema(description = "表单类型:quotation/contract/invoice/order") + private String formType; + + @Schema(description = "新建时执行") + private Boolean createExecute = true; + + @Schema(description = "编辑时执行") + private Boolean updateExecute = true; + + @Schema(description = "删除时执行") + private Boolean deleteExecute = false; + + @Schema(description = "启用状态") + private Boolean enable; + + @Schema(description = "流程描述") + private String description; + + @Schema(description = "允许提交人撤销") + private Boolean submitterCanRevoke; + + @Schema(description = "允许批量处理") + private Boolean allowBatchProcess; + + @Schema(description = "允许撤回") + private Boolean allowWithdraw; + + @Schema(description = "允许加签") + private Boolean allowAddSign; + + @EnumValue(enumClass = DuplicateApproverRuleEnum.class) + @Schema(description = "重复审批人规则") + private String duplicateApproverRule; + + @Schema(description = "是否必须填写审批意见") + private Boolean requireComment; + + @Schema(description = "状态权限配置") + @Valid + private List statusPermissions; + + @Schema(description = "新建时节点配置") + @Valid + private ApprovalFlowNodeConfigRequest createNodeConfig; + + @Schema(description = "编辑时节点配置") + @Valid + private ApprovalFlowNodeConfigRequest updateNodeConfig; + + @Schema(description = "删除时节点配置") + @Valid + private ApprovalFlowNodeConfigRequest deleteNodeConfig; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalFlowNodeConfigRequest.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalFlowNodeConfigRequest.java new file mode 100644 index 0000000..962e437 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalFlowNodeConfigRequest.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.approval.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import lombok.Data; + +import java.util.List; + +@Data +public class ApprovalFlowNodeConfigRequest { + + @Schema(description = "节点配置列表") + @Valid + private List nodes; + + @Schema(description = "节点连接配置列表") + @Valid + private List links; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalFlowPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalFlowPageRequest.java new file mode 100644 index 0000000..3388e5a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalFlowPageRequest.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.approval.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class ApprovalFlowPageRequest extends BasePageRequest { + + @Schema(description = "流程名称") + private String name; + + @Schema(description = "表单类型") + private String formType; + + @Schema(description = "启用状态") + private Boolean enable; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalFlowUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalFlowUpdateRequest.java new file mode 100644 index 0000000..31a2e82 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalFlowUpdateRequest.java @@ -0,0 +1,71 @@ +package cn.cordys.crm.approval.dto.request; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.crm.approval.constants.DuplicateApproverRuleEnum; +import cn.cordys.crm.approval.dto.StatusPermissionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.List; + +@Data +public class ApprovalFlowUpdateRequest { + + @NotBlank(message = "ID不能为空") + @Schema(description = "审批流ID") + private String id; + + @Schema(description = "流程名称") + private String name; + + @Schema(description = "新建时执行") + private Boolean createExecute; + + @Schema(description = "编辑时执行") + private Boolean updateExecute; + + @Schema(description = "删除时执行") + private Boolean deleteExecute; + + @Schema(description = "启用状态") + private Boolean enable; + + @Schema(description = "流程描述") + private String description; + + @Schema(description = "允许提交人撤销") + private Boolean submitterCanRevoke; + + @Schema(description = "允许批量处理") + private Boolean allowBatchProcess; + + @Schema(description = "允许撤回") + private Boolean allowWithdraw; + + @Schema(description = "允许加签") + private Boolean allowAddSign; + + @EnumValue(enumClass = DuplicateApproverRuleEnum.class) + @Schema(description = "重复审批人规则") + private String duplicateApproverRule; + + @Schema(description = "是否必须填写审批意见") + private Boolean requireComment; + + @Schema(description = "状态权限配置") + private List statusPermissions; + + @Schema(description = "新建时节点配置") + @Valid + private ApprovalFlowNodeConfigRequest createNodeConfig; + + @Schema(description = "编辑时节点配置") + @Valid + private ApprovalFlowNodeConfigRequest updateNodeConfig; + + @Schema(description = "删除时节点配置") + @Valid + private ApprovalFlowNodeConfigRequest deleteNodeConfig; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalNodeApproverRequest.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalNodeApproverRequest.java new file mode 100644 index 0000000..c11a2f1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalNodeApproverRequest.java @@ -0,0 +1,72 @@ +package cn.cordys.crm.approval.dto.request; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.crm.approval.constants.*; +import cn.cordys.crm.approval.dto.ApprovalPostConfigDTO; +import cn.cordys.crm.approval.dto.FieldPermissionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "审批人节点请求") +public class ApprovalNodeApproverRequest extends ApprovalNodeRequest { + + @NotBlank + @EnumValue(enumClass = ApprovalTypeEnum.class) + @Schema(description = "审批类型:MANUAL/AUTO_PASS/AUTO_REJECT") + private String approvalType = EmptyApproverActionEnum.AUTO_PASS.name(); + + @NotBlank + @EnumValue(enumClass = MultiApproverModeEnum.class) + @Schema(description = "多人审批方式:ALL/ANY/SEQUENTIAL") + private String multiApproverMode = MultiApproverModeEnum.ALL.name(); + + @NotBlank + @EnumValue(enumClass = EmptyApproverActionEnum.class) + @Schema(description = "审批人为空时动作") + private String emptyApproverAction = EmptyApproverActionEnum.AUTO_PASS.name(); + + @Schema(description = "审批人为空时,兜底审批人") + private String fallbackApprover; + + @NotBlank + @EnumValue(enumClass = SameSubmitterActionEnum.class) + @Schema(description = "审批人与提交人相同时动作") + private String sameSubmitterAction = SameSubmitterActionEnum.SKIP.name(); + + @EnumValue(enumClass = ApproverTypeEnum.class) + @Schema(description = "审批人类型:MEMBER/SUPERIOR/MULTIPLE_SUPERIOR/DEPT_HEAD/MULTIPLE_DEPT_HEAD/ROLE") + private String approverType; + + @EnumValue(enumClass = ApproverDirectionEnum.class) + @Schema(description = "审批人方向:BOTTOM_UP(从下往上)/TOP_DOWN(从上往下),适用于SUPERIOR/MULTIPLE_SUPERIOR/DEPT_HEAD/MULTIPLE_DEPT_HEAD") + private String approverDirection; + + @Schema(description = "审批人列表") + private List approverList; + + @EnumValue(enumClass = ApproverTypeEnum.class) + @Schema(description = "抄送人类型:MEMBER/SUPERIOR/MULTIPLE_SUPERIOR/DEPT_HEAD/MULTIPLE_DEPT_HEAD/ROLE") + private String ccType; + + @EnumValue(enumClass = ApproverDirectionEnum.class) + @Schema(description = "抄送人方向:BOTTOM_UP(从下往上)/TOP_DOWN(从上往下),适用于SUPERIOR/MULTIPLE_SUPERIOR/DEPT_HEAD/MULTIPLE_DEPT_HEAD") + private String ccDirection; + + @Schema(description = "抄送人列表") + private List ccList; + + @Schema(description = "审批通过后配置") + private ApprovalPostConfigDTO passPostConfig; + + @Schema(description = "审批驳回后配置") + private ApprovalPostConfigDTO rejectPostConfig; + + @Schema(description = "字段权限配置列表") + private List fieldPermissions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalNodeConditionRequest.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalNodeConditionRequest.java new file mode 100644 index 0000000..80f1c04 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalNodeConditionRequest.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.approval.dto.request; + +import cn.cordys.common.dto.condition.CombineSearch; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "条件节点请求") +public class ApprovalNodeConditionRequest extends ApprovalNodeRequest { + + @Schema(description = "条件配置") + private CombineSearch conditionConfig; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalNodeLinkRequest.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalNodeLinkRequest.java new file mode 100644 index 0000000..3d9feba --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalNodeLinkRequest.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.approval.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "审批节点连接请求") +public class ApprovalNodeLinkRequest { + + @Schema(description = "源节点ID") + @NotBlank + private String fromNodeId; + + @Schema(description = "目标节点ID") + @NotBlank + private String toNodeId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalNodeRequest.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalNodeRequest.java new file mode 100644 index 0000000..45f809d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalNodeRequest.java @@ -0,0 +1,36 @@ +package cn.cordys.crm.approval.dto.request; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.crm.approval.constants.ApprovalNodeTypeEnum; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "nodeType", visible = true) +@JsonSubTypes({ + @JsonSubTypes.Type(value = ApprovalNodeApproverRequest.class, name = "APPROVER"), + @JsonSubTypes.Type(value = ApprovalNodeConditionRequest.class, name = "CONDITION"), + @JsonSubTypes.Type(value = ApprovalNodeRequest.class, name = "START"), + @JsonSubTypes.Type(value = ApprovalNodeRequest.class, name = "END"), + @JsonSubTypes.Type(value = ApprovalNodeRequest.class, name = "DEFAULT"), +}) +public class ApprovalNodeRequest { + + @Schema(description = "节点ID") + @NotBlank + private String id; + + @Schema(description = "节点编码") + private String number; + + @Schema(description = "节点名称") + private String name; + + @EnumValue(enumClass = ApprovalNodeTypeEnum.class) + @NotBlank(message = "节点类型不能为空") + @Schema(description = "节点类型:START/APPROVER/CONDITION/DEFAULT/END") + private String nodeType; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalReturnBackRequest.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalReturnBackRequest.java new file mode 100644 index 0000000..0c81b95 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalReturnBackRequest.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.approval.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ApprovalReturnBackRequest extends ApprovalActionRequest { + + @NotBlank(message = "退回至任务ID不能为空") + @Schema(description = "退回至节点ID") + private String returnToNodeId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalRevokeRequest.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalRevokeRequest.java new file mode 100644 index 0000000..96b3221 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalRevokeRequest.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.approval.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ApprovalRevokeRequest { + + @NotBlank(message = "撤回的节点任务不能为空") + @Schema(description = "撤回的节点任务ID") + private String id; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalTodoPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalTodoPageRequest.java new file mode 100644 index 0000000..cb942b9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/request/ApprovalTodoPageRequest.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.approval.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class ApprovalTodoPageRequest extends BasePageRequest { + + @Schema(description = "资源类型过滤:ALL/QUOTATION/CONTRACT/ORDER/INVOICE") + private String resourceType = "ALL"; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalFlowByFormTypeResponse.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalFlowByFormTypeResponse.java new file mode 100644 index 0000000..3e70d5e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalFlowByFormTypeResponse.java @@ -0,0 +1,66 @@ +package cn.cordys.crm.approval.dto.response; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.approval.dto.StatusPermissionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class ApprovalFlowByFormTypeResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "当前版本ID") + private String currentVersionId; + + @Schema(description = "流程编码") + private String number; + + @Schema(description = "流程名称") + private String name; + + @Schema(description = "表单类型") + private String formType; + + @Schema(description = "新建时执行") + private Boolean createExecute; + + @Schema(description = "编辑时执行") + private Boolean updateExecute; + + @Schema(description = "删除时执行") + private Boolean deleteExecute; + + @Schema(description = "启用状态") + private Boolean enable; + + @Schema(description = "流程描述") + private String description; + + @Schema(description = "允许提交人撤销") + private Boolean submitterCanRevoke; + + @Schema(description = "允许批量处理") + private Boolean allowBatchProcess; + + @Schema(description = "允许撤回") + private Boolean allowWithdraw; + + @Schema(description = "允许加签") + private Boolean allowAddSign; + + @Schema(description = "重复审批人规则") + private String duplicateApproverRule; + + @Schema(description = "是否必须填写审批意见") + private Boolean requireComment; + + @Schema(description = "对应资源表单的权限列表") + private List permissions; + + @Schema(description = "状态权限配置") + private List statusPermissions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalFlowDetailResponse.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalFlowDetailResponse.java new file mode 100644 index 0000000..2e0abb8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalFlowDetailResponse.java @@ -0,0 +1,97 @@ +package cn.cordys.crm.approval.dto.response; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.approval.dto.StatusPermissionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class ApprovalFlowDetailResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "当前版本ID") + private String currentVersionId; + + @Schema(description = "流程编码") + private String number; + + @Schema(description = "流程名称") + private String name; + + @Schema(description = "表单类型") + private String formType; + + @Schema(description = "新建时执行") + private Boolean createExecute; + + @Schema(description = "编辑时执行") + private Boolean updateExecute; + + @Schema(description = "删除时执行") + private Boolean deleteExecute; + + @Schema(description = "启用状态") + private Boolean enable; + + @Schema(description = "流程描述") + private String description; + + @Schema(description = "允许提交人撤销") + private Boolean submitterCanRevoke; + + @Schema(description = "允许批量处理") + private Boolean allowBatchProcess; + + @Schema(description = "允许撤回") + private Boolean allowWithdraw; + + @Schema(description = "允许加签") + private Boolean allowAddSign; + + @Schema(description = "重复审批人规则") + private String duplicateApproverRule; + + @Schema(description = "是否必须填写审批意见") + private Boolean requireComment; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "更新人") + private String updateUser; + + @Schema(description = "对应资源表单的权限列表") + private List permissions; + + @Schema(description = "状态权限配置") + private List statusPermissions; + + @Schema(description = "新建时节点配置") + private ApprovalFlowNodeConfigResponse createNodeConfig; + + @Schema(description = "编辑时节点配置") + private ApprovalFlowNodeConfigResponse updateNodeConfig; + + @Schema(description = "删除时节点配置") + private ApprovalFlowNodeConfigResponse deleteNodeConfig; + + @Schema(description = "条件节点字段选项映射") + private Map> optionMap; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalFlowListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalFlowListResponse.java new file mode 100644 index 0000000..57830cc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalFlowListResponse.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.approval.dto.response; + +import cn.cordys.crm.approval.domain.ApprovalFlow; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class ApprovalFlowListResponse extends ApprovalFlow { + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalFlowNodeConfigResponse.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalFlowNodeConfigResponse.java new file mode 100644 index 0000000..c10e25a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalFlowNodeConfigResponse.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.approval.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class ApprovalFlowNodeConfigResponse { + + @Schema(description = "节点配置列表") + private List nodes; + + @Schema(description = "节点连接配置列表") + private List links; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalNodeApproverResponse.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalNodeApproverResponse.java new file mode 100644 index 0000000..86441a3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalNodeApproverResponse.java @@ -0,0 +1,73 @@ +package cn.cordys.crm.approval.dto.response; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.approval.dto.ApprovalPostConfigDTO; +import cn.cordys.crm.approval.dto.FieldPermissionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "审批人节点响应") +public class ApprovalNodeApproverResponse extends ApprovalNodeResponse { + + @Schema(description = "审批类型") + private String approvalType; + + @Schema(description = "审批类型名称") + private String approvalTypeName; + + @Schema(description = "多人审批方式") + private String multiApproverMode; + + @Schema(description = "多人审批方式名称") + private String multiApproverModeName; + + @Schema(description = "审批人为空时动作") + private String emptyApproverAction; + + @Schema(description = "兜底审批人ID") + private String fallbackApprover; + + @Schema(description = "兜底审批人名称") + private String fallbackApproverName; + + @Schema(description = "审批人与提交人相同时动作") + private String sameSubmitterAction; + + @Schema(description = "审批人类型:MEMBER/SUPERIOR/MULTIPLE_SUPERIOR/DEPT_HEAD/MULTIPLE_DEPT_HEAD/ROLE") + private String approverType; + + @Schema(description = "审批人方向:BOTTOM_UP(从下往上)/TOP_DOWN(从上往下),适用于SUPERIOR/MULTIPLE_SUPERIOR/DEPT_HEAD/MULTIPLE_DEPT_HEAD") + private String approverDirection; + + @Schema(description = "审批人ID列表") + private List approverList; + + @Schema(description = "审批人选择项(用于前端回显)") + private List approverSelectOptions; + + @Schema(description = "抄送人类型:MEMBER/SUPERIOR/MULTIPLE_SUPERIOR/DEPT_HEAD/MULTIPLE_DEPT_HEAD/ROLE") + private String ccType; + + @Schema(description = "抄送人方向:BOTTOM_UP(从下往上)/TOP_DOWN(从上往下),适用于SUPERIOR/MULTIPLE_SUPERIOR/DEPT_HEAD/MULTIPLE_DEPT_HEAD") + private String ccDirection; + + @Schema(description = "抄送人ID列表") + private List ccList; + + @Schema(description = "抄送人选择项(用于前端回显)") + private List ccSelectOptions; + + @Schema(description = "审批通过后配置") + private ApprovalPostConfigDTO passPostConfig; + + @Schema(description = "审批驳回后配置") + private ApprovalPostConfigDTO rejectPostConfig; + + @Schema(description = "字段权限配置列表") + private List fieldPermissions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalNodeConditionResponse.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalNodeConditionResponse.java new file mode 100644 index 0000000..3f00833 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalNodeConditionResponse.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.approval.dto.response; + +import cn.cordys.common.dto.condition.CombineSearch; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "条件节点响应") +public class ApprovalNodeConditionResponse extends ApprovalNodeResponse { + + @Schema(description = "条件配置") + private CombineSearch conditionConfig; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalNodeExceptionResponse.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalNodeExceptionResponse.java new file mode 100644 index 0000000..1807a27 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalNodeExceptionResponse.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.approval.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "审批异常节点") +public class ApprovalNodeExceptionResponse extends ApprovalNodeResponse { + +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalNodeLinkResponse.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalNodeLinkResponse.java new file mode 100644 index 0000000..6d8c062 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalNodeLinkResponse.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.approval.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "审批节点连接响应") +public class ApprovalNodeLinkResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "源节点ID") + private String fromNodeId; + + @Schema(description = "目标节点ID") + private String toNodeId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalNodeResponse.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalNodeResponse.java new file mode 100644 index 0000000..7fb18dc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalNodeResponse.java @@ -0,0 +1,37 @@ +package cn.cordys.crm.approval.dto.response; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "nodeType", visible = true) +@JsonSubTypes({ + @JsonSubTypes.Type(value = ApprovalNodeApproverResponse.class, name = "APPROVER"), + @JsonSubTypes.Type(value = ApprovalNodeConditionResponse.class, name = "CONDITION"), + @JsonSubTypes.Type(value = ApprovalNodeExceptionResponse.class, name = "EXCEPTION"), + @JsonSubTypes.Type(value = ApprovalNodeResponse.class, name = "START"), + @JsonSubTypes.Type(value = ApprovalNodeResponse.class, name = "END"), + @JsonSubTypes.Type(value = ApprovalNodeResponse.class, name = "DEFAULT"), +}) +public class ApprovalNodeResponse { + + @Schema(description = "节点ID") + private String id; + + @Schema(description = "节点编码") + private String number; + + @Schema(description = "节点名称") + private String name; + + @Schema(description = "节点类型") + private String nodeType; + + @Schema(description = "排序序号") + private Integer sort; + + @Schema(description = "执行时机:CREATE/UPDATE/DELETE") + private String executeTime; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalTodoCountResponse.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalTodoCountResponse.java new file mode 100644 index 0000000..f2b11de --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalTodoCountResponse.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.approval.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class ApprovalTodoCountResponse { + + @Schema(description = "待我审批总数") + private Integer total; + + @Schema(description = "报价待审批数量") + private Integer quotation; + + @Schema(description = "合同待审批数量") + private Integer contract; + + @Schema(description = "订单待审批数量") + private Integer order; + + @Schema(description = "发票待审批数量") + private Integer invoice; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalTodoItemResponse.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalTodoItemResponse.java new file mode 100644 index 0000000..24a45c7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalTodoItemResponse.java @@ -0,0 +1,47 @@ +package cn.cordys.crm.approval.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class ApprovalTodoItemResponse { + + @Schema(description = "资源ID") + private String resourceId; + + @Schema(description = "资源名称") + private String resourceName; + + @Schema(description = "资源类型") + private String resourceType; + + @Schema(description = "申请人") + private String applicant; + + @Schema(description = "提交时间") + private Long submitTime; + + @Schema(description = "审批操作") + private String approvalOperation; + + @Schema(description = "数据结果") + private String dataResult; + + @Schema(description = "审批任务ID") + private String approvalTaskId; + + @Schema(description = "审批节点ID") + private String approvalNodeId; + + @Schema(description = "审批实例ID") + private String approvalInstanceId; + + @Schema(description = "审批人ID") + private String approvalId; + + @Schema(description = "审批流ID") + private String approvalFlowId; + + @Schema(description = "审批流版本ID") + private String approvalFlowVersionId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalTodoResponse.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalTodoResponse.java new file mode 100644 index 0000000..0d70d02 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ApprovalTodoResponse.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.approval.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class ApprovalTodoResponse { + + @Schema(description = "报价待办") + private List quotation; + + @Schema(description = "合同待办") + private List contract; + + @Schema(description = "订单待办") + private List order; + + @Schema(description = "发票待办") + private List invoice; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ResourceApprovalResponse.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ResourceApprovalResponse.java new file mode 100644 index 0000000..2b8e5f9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/ResourceApprovalResponse.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.approval.dto.response; + +import cn.cordys.security.UserApprovalDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class ResourceApprovalResponse { + + @Schema(description = "资源ID") + private String resourceId; + + @Schema(description = "最终审核状态") + private String approveStatus; + + @Schema(description = "用户审核结果") + private List approveUserList; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/StatusPermissionSettingResponse.java b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/StatusPermissionSettingResponse.java new file mode 100644 index 0000000..0452ba3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/dto/response/StatusPermissionSettingResponse.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.approval.dto.response; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.approval.dto.StatusPermissionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** + * 审批流状态权限配置响应 + */ +@Data +@Schema(description = "审批流状态权限配置") +public class StatusPermissionSettingResponse implements Serializable { + + @Schema(description = "权限列表") + private List permissions; + + @Schema(description = "状态权限配置") + private List statusPermissions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/handler/ApprovalResourceHandler.java b/backend/crm/src/main/java/cn/cordys/crm/approval/handler/ApprovalResourceHandler.java new file mode 100644 index 0000000..09772ff --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/handler/ApprovalResourceHandler.java @@ -0,0 +1,40 @@ +package cn.cordys.crm.approval.handler; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.crm.approval.dto.ResourceApprovalPostUpdateParam; +import cn.cordys.crm.approval.dto.ResourceSnapshotApprovalParam; + +/** + * 审批资源处理接口 + * 各需要审批的业务资源服务实现此接口,替代反射调用 + */ +public interface ApprovalResourceHandler { + + /** + * 获取该处理器对应的表单类型 + */ + FormKey getFormKey(); + + /** + * DELETE审批通过后执行删除操作 + * + * @param resourceId 资源ID + * @param userId 操作人ID + * @param organizationId 组织ID + */ + void deleteForResource(String resourceId, String userId, String organizationId); + + /** + * 更新业务快照审批状态 + * + * @param param 参数 + */ + void updateSnapshotApprovalStatus(ResourceSnapshotApprovalParam param); + + /** + * 审批后置字段更新 + * + * @param param 参数 + */ + void updateApprovalPostField(ResourceApprovalPostUpdateParam param); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/log/ApprovalFlowLogDTO.java b/backend/crm/src/main/java/cn/cordys/crm/approval/log/ApprovalFlowLogDTO.java new file mode 100644 index 0000000..8ddace8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/log/ApprovalFlowLogDTO.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.approval.log; + +import cn.cordys.crm.approval.domain.ApprovalFlow; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +public class ApprovalFlowLogDTO extends ApprovalFlow { + + /** + * 流程描述 + */ + private String description; + + /** + * 节点配置 + */ + private List nodes; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalFlowMapper.java b/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalFlowMapper.java new file mode 100644 index 0000000..d2a0a66 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalFlowMapper.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.approval.mapper; + +import cn.cordys.crm.approval.dto.request.ApprovalFlowPageRequest; +import cn.cordys.crm.approval.dto.response.ApprovalFlowListResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 审批流扩展Mapper + */ +public interface ExtApprovalFlowMapper { + + /** + * 分页查询审批流列表(带用户名称) + */ + List list( + @Param("request") ApprovalFlowPageRequest request, + @Param("organizationId") String organizationId); +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalFlowMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalFlowMapper.xml new file mode 100644 index 0000000..f71728b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalFlowMapper.xml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + ORDER BY af.${sortName} ${sortType}, + af.create_time DESC + + + ORDER BY af.create_time DESC + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalInstanceMapper.java b/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalInstanceMapper.java new file mode 100644 index 0000000..1b4f049 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalInstanceMapper.java @@ -0,0 +1,104 @@ +package cn.cordys.crm.approval.mapper; + +import org.apache.ibatis.annotations.Param; + +public interface ExtApprovalInstanceMapper { + + /** + * 更新业务表的审批状态 + * + * @param sourceTable 主业务表 + * @param resourceId 资源ID + * @param approvalStatus 审批状态 + */ + void updateApprovalStatus(@Param("sourceTable") String sourceTable, @Param("id") String resourceId, + @Param("approvalStatus") String approvalStatus); + /** + * 更新业务表的 approved 状态 + * + * @param sourceTable 主业务表 + * @param resourceId 资源ID + */ + void setApproved(@Param("sourceTable") String sourceTable, @Param("id") String resourceId); + + /** + * 查询业务表业务名称 + * @param sourceTable + * @param id + */ + String selectBusinessName(@Param("sourceTable") String sourceTable, @Param("id") String id); + + /** + * 查询业务表的审批状态 + * + * @param sourceTable 主业务表 + * @param id 资源ID + * @return 审批状态 + */ + String selectApprovalStatus(@Param("sourceTable") String sourceTable, @Param("id") String id); + + String getResourceOwner(@Param("sourceTable")String sourceTable, @Param("id")String id); + + /** + * 查询业务资源是否审批通过过 + * + * @param sourceTable 主业务表 + * @param id 资源ID + * @return 是否审批通过过 + */ + Boolean selectApproved(@Param("sourceTable") String sourceTable, @Param("id") String id); + + /** + * 获取节点下一个执行轮次 + * 从 approval_task 和 approval_record 中一起获取,取最大轮次 +1 + * + * @param instanceId 审批实例ID + * @param nodeId 节点ID + * @return 下一个轮次 + */ + Integer getNextNodeRound(@Param("instanceId") String instanceId, @Param("nodeId") String nodeId); + + /** + * 获取节点下一个执行轮次 + * 从 approval_task 和 approval_record 中一起获取,取最大轮次 +1 + * + * @param instanceId 审批实例ID + * @param nodeId 节点ID + * @return 下一个轮次 + */ + Integer getNodeRound(@Param("instanceId") String instanceId, @Param("nodeId") String nodeId); + + /** + * 假删除当前节点轮次已处理的待办任务 + * + * @param instanceId 审批实例ID + * @param nodeId 节点ID + * @param nodeRound 轮次 + */ + void batchClearNotApprovingTask(@Param("instanceId") String instanceId, @Param("nodeId") String nodeId, @Param("nodeRound") Integer nodeRound); + + /** + * 撤销中止审批中的节点轮次任务 + * @param instanceId 审批实例ID + * @param nodeId 节点ID + * @param nodeRound 轮次 + */ + void loseApprovingTask(@Param("instanceId") String instanceId, @Param("nodeId") String nodeId, @Param("nodeRound") Integer nodeRound); + + /** + * 撤销中止审批中的节点轮次任务 + * @param instanceId 审批实例ID + * @param nodeId 节点ID + * @param nodeRound 轮次 + */ + void batchClearApprovingTask(@Param("instanceId") String instanceId, @Param("nodeId") String nodeId, @Param("nodeRound") Integer nodeRound); + + /** + * 假删除当前节点的执行记录 + * + * @param instanceId 审批实例ID + * @param nodeId 节点ID + * @param nodeRound 轮次 + */ + void batchClearRecord(@Param("instanceId") String instanceId, @Param("nodeId") String nodeId, @Param("nodeRound") Integer nodeRound); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalInstanceMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalInstanceMapper.xml new file mode 100644 index 0000000..c2aa687 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalInstanceMapper.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + update approval_task set node_round = -1 where instance_id = #{instanceId} AND node_id = #{nodeId} and node_round = #{nodeRound} and status != 'APPROVING' + + + + update approval_task set status = 'NONE' where instance_id = #{instanceId} AND node_id = #{nodeId} and node_round = #{nodeRound} and status = 'APPROVING' + + + + update approval_task set status = 'NONE', node_round = -1 where instance_id = #{instanceId} AND node_id = #{nodeId} and node_round = #{nodeRound} and status = 'APPROVING' + + + + update approval_record set node_round = -1 where instance_id = #{instanceId} AND node_id = #{nodeId} and node_round = #{nodeRound} + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalTaskMapper.java b/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalTaskMapper.java new file mode 100644 index 0000000..22e6529 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalTaskMapper.java @@ -0,0 +1,62 @@ +package cn.cordys.crm.approval.mapper; + +import cn.cordys.crm.approval.domain.ApprovalInstance; +import cn.cordys.crm.approval.domain.ApprovalTask; +import cn.cordys.crm.approval.dto.response.ApprovalTodoCountResponse; +import cn.cordys.crm.approval.dto.response.ApprovalTodoItemResponse; +import org.apache.ibatis.annotations.Param; + + +/** + * 审批任务扩展Mapper + */ +public interface ExtApprovalTaskMapper { + /** + * 查询资源最近一次审批实例(按提交时间和ID倒序)。 + */ + ApprovalInstance selectLatestInstanceByResourceId(@Param("resourceId") String resourceId); + + /** + * 分页查询待我审批任务,按创建时间和ID倒序。 + */ + java.util.List selectPendingTasks(@Param("approverId") String approverId, + @Param("pendingStatus") String pendingStatus, + @Param("resourceType") String resourceType, + @Param("resourceName") String resourceName); + + java.util.List selectProcessedTasks(@Param("approverId") String approverId, + @Param("resourceType") String resourceType, + @Param("keyword") String keyword); + + java.util.List selectInitiatedTasks(@Param("submitterId") String submitterId, + @Param("resourceType") String resourceType, + @Param("keyword") String keyword); + + java.util.List selectCcTasks(@Param("approverId") String approverId, + @Param("resourceType") String resourceType, + @Param("keyword") String keyword); + + /** + * 统计待我审批数量(总数 + 各资源类型)。 + */ + ApprovalTodoCountResponse countPendingByApprover(@Param("approverId") String approverId, + @Param("pendingStatus") String pendingStatus); + + void updateTaskById(@Param("approvalTask") ApprovalTask approvalTask); + + /** + * 移动加签的根节点 + * @param oldRootId 旧的根节点ID + * @param newRootId 新的根节点ID + */ + void moveAddSignRoot(@Param("oldRootId") String oldRootId, @Param("newRootId") String newRootId); + + /** + * 更新加签根节点的下一个指向 + * @param oldRootId 旧的根节点ID + * @param newRootId 新的根节点ID + */ + void updateRootNext(@Param("oldRootId") String oldRootId, @Param("newRootId") String newRootId); + + void updateApprover(@Param("approverId")String approverId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalTaskMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalTaskMapper.xml new file mode 100644 index 0000000..7bd162a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/mapper/ExtApprovalTaskMapper.xml @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + update approval_task + + + node_id = #{approvalTask.nodeId}, + + + instance_id = #{approvalTask.instanceId}, + + + approver_id = #{approvalTask.approverId}, + + + status = #{approvalTask.status}, + + + type = #{approvalTask.type}, + + + action = #{approvalTask.action}, + + + create_time = #{approvalTask.createTime}, + + + update_time = #{approvalTask.updateTime}, + + + create_user = #{approvalTask.createUser}, + + + update_user = #{approvalTask.updateUser}, + + + + id = #{approvalTask.id} + + + + + update approval_add_sign_task + set root_task_id = #{newRootId} + where root_task_id = #{oldRootId} + + + + update approval_add_sign_task + set sign_task_id = #{newRootId} + where sign_task_id = #{oldRootId} + + + + update approval_task + set approver_id = #{approverId}, update_time = UNIX_TIMESTAMP() * 1000 + + diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalActionService.java b/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalActionService.java new file mode 100644 index 0000000..f2b6d2b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalActionService.java @@ -0,0 +1,1328 @@ +package cn.cordys.crm.approval.service; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.approval.constants.*; +import cn.cordys.crm.approval.domain.*; +import cn.cordys.crm.approval.dto.AddSignSortInfo; +import cn.cordys.crm.approval.dto.request.*; +import cn.cordys.crm.approval.dto.response.ApprovalNodeApproverResponse; +import cn.cordys.crm.approval.dto.response.ApprovalNodeResponse; +import cn.cordys.crm.approval.mapper.ExtApprovalInstanceMapper; +import cn.cordys.crm.approval.mapper.ExtApprovalTaskMapper; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.domain.OrganizationUser; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.request.UploadTransferRequest; +import cn.cordys.crm.system.notice.CommonNoticeSendService; +import cn.cordys.crm.system.service.AttachmentService; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.ibatis.session.ExecutorType; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.mybatis.spring.SqlSessionUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class ApprovalActionService { + + @Resource + private ApprovalFlowService approvalFlowService; + @Resource + private ApprovalInstanceService approvalInstanceService; + @Resource + private BaseMapper approvalInstanceMapper; + @Resource + private BaseMapper approvalTaskMapper; + @Resource + private BaseMapper approvalAddSignTasMapper; + @Resource + private BaseMapper approvalReturnBackRecordMapper; + @Resource + private BaseMapper approvalAddSignTaskMapper; + @Resource + private BaseMapper approvalNodeApproverMapper; + @Resource + private BaseMapper approvalRecordMapper; + @Resource + private AttachmentService attachmentService; + @Resource + private BaseMapper approvalInstanceAttachmentMapper; + @Resource + private ExtApprovalInstanceMapper extApprovalInstanceMapper; + @Resource + private LogService logService; + @Resource + private ExtApprovalTaskMapper extApprovalTaskMapper; + @Resource + private CommonNoticeSendService commonNoticeSendService; + @Resource + private BaseMapper organizationUserMapper; + + public static final Long DEFAULT_SIGN_SORT_STEP = 100L; + @Resource + private SqlSessionFactory sqlSessionFactory; + + /** + * 加签 + * + * @param request 加签参数 + * @param userId 当前用户 + * @param orgId 当前组织 + */ + public void sign(ApprovalAddSignRequest request, String userId, String orgId) { + ApprovalInstance instance = approvalInstanceMapper.selectByPrimaryKey(request.getInstanceId()); + // 审批流是否允许加签 + ApprovalFlow approvalFlow = approvalFlowService.selectApprovalFlowByFormType(instance.getType(), orgId); + if (approvalFlow == null || !approvalFlow.getAllowAddSign()) { + throw new GenericException(Translator.get("no.operation.permission")); + } + // 刷新被加签任务状态 && 插入审批记录 + ApprovalTask currentTask = saveActionTask(request, ApprovalAction.SIGN, userId, orgId, ApprovalAddSignType.valueOf(request.getType())); + // 加签操作的待办任务 + ApprovalTask appendActionTask = appendSignTask(request, userId, currentTask.getNodeRound()); + // 发送待办消息通知 + sendApprovalTaskNotice(List.of(appendActionTask), instance, orgId); + ApprovalAddSignTask addSignTask = saveAddSignTask(request, appendActionTask.getId()); + // 之后加签(多人或签), 需要刷新实例当前审批节点 + if (ApprovalAddSignType.valueOf(request.getType()) == ApprovalAddSignType.AFTER && isMultiAnyMode(appendActionTask.getNodeId(), userId, orgId)) { + handlePreCcTasks(currentTask.getNodeId(), instance, userId, orgId); + approvalFlowService.updateApprovalPostField(instance, currentTask.getNodeId(), ApprovalAction.APPROVE, userId); + ApprovalNodeResponse nextNode = approvalFlowService.getTaskNextNode(appendActionTask, instance, orgId); + handleNextApprovalNode(nextNode, instance, currentTask.getApproverId(), userId, orgId); + } + // 保存加签附件 + if (CollectionUtils.isNotEmpty(request.getAttachmentIds())) { + saveInstanceAttachment(request.getAttachmentIds(), request.getInstanceId(), addSignTask.getId(), userId, orgId); + } + saveLogAndNotice(instance, userId, orgId, ApprovalAction.SIGN); + } + + /** + * 回退 + * + * @param request 回退参数 + * @param userId 当前用户 + * @param orgId 当前组织 + */ + public void back(ApprovalReturnBackRequest request, String userId, String orgId) { + ApprovalInstance instance = approvalInstanceMapper.selectByPrimaryKey(request.getInstanceId()); + // 追加退回操作的待办任务 && 保存退回记录 + backProcess(instance, request, orgId); + ApprovalReturnBackRecord backRecord = saveBackRecord(request, instance.getId(), userId); + // 保存执行任务 + saveActionTask(request, ApprovalAction.BACK, userId, orgId, null); + instance.setCurrentNodeId(request.getReturnToNodeId()); + approvalInstanceMapper.updateById(instance); + // 保存退回附件 + if (CollectionUtils.isNotEmpty(request.getAttachmentIds())) { + saveInstanceAttachment(request.getAttachmentIds(), request.getInstanceId(), backRecord.getId(), userId, orgId); + } + saveLogAndNotice(instance, userId, orgId, ApprovalAction.BACK); + } + + /** + * 撤回 + * + * @param request 撤回参数 + * @param currentUserId 当前用户 + * @param orgId 当前组织 + */ + public void revoke(ApprovalRevokeRequest request, String currentUserId, String orgId) { + ApprovalTask currentTask = getTaskById(request.getId()); + ApprovalInstance instance = approvalInstanceMapper.selectByPrimaryKey(currentTask.getInstanceId()); + // 审批流是否允许撤回 + ApprovalFlow approvalFlow = approvalFlowService.selectApprovalFlowByFormType(instance.getType(), orgId); + if (approvalFlow == null || !approvalFlow.getAllowWithdraw()) { + throw new GenericException(Translator.get("no.operation.permission")); + } + revokeProcess(currentTask, instance, orgId); + refreshRevokeTask(currentTask, instance, currentUserId); + saveLogAndNotice(instance, currentUserId, orgId, ApprovalAction.REVOKE); + } + + /** + * 同意 + * + * @param request 撤回参数 + * @param currentUserId 当前用户 + * @param currentOrgId 当前组织 + */ + public void approve(ApprovalActionRequest request, String currentUserId, String currentOrgId) { + ApprovalTask currentTask = saveActionTask(request, ApprovalAction.APPROVE, currentUserId, currentOrgId, null); + ApprovalInstance instance = approvalInstanceMapper.selectByPrimaryKey(currentTask.getInstanceId()); + approvedProcess(instance, currentTask, currentUserId, currentOrgId); + saveLogAndNotice(instance, currentUserId, currentOrgId, ApprovalAction.APPROVE); + } + + /** + * 驳回 + * + * @param request 驳回参数 + * @param currentUserId 当前用户 + * @param currentOrgId 当前组织 + */ + public void reject(ApprovalActionRequest request, String currentUserId, String currentOrgId) { + ApprovalTask currentTask = saveActionTask(request, ApprovalAction.REJECT, currentUserId, currentOrgId, null); + ApprovalInstance instance = approvalInstanceMapper.selectByPrimaryKey(currentTask.getInstanceId()); + // 任一审批任务驳回即驳回整个节点 + approvalInstanceService.rejectApprovalInstance(instance, currentUserId); + ApprovalResourceService resourceService = CommonBeanFactory.getBean(ApprovalResourceService.class); + if (resourceService != null) { + resourceService.updateResourceApprovalStatus(FormKey.ofKey(instance.getType()), instance.getResourceId(), instance.getApprovalStatus(), currentUserId, currentOrgId); + } + loseCurrentNode(instance.getId(), currentTask.getNodeId()); + approvalFlowService.updateApprovalPostField(instance, currentTask.getNodeId(), ApprovalAction.REJECT, currentUserId); + // 日志 && 通知 + saveLogAndNotice(instance, currentUserId, currentOrgId, ApprovalAction.REJECT); + } + + /** + * 批量同意 + * @param request 请求参数 + * @param userId 用户ID + * @param organizationId 组织ID + */ + public void batchApprove(ApprovalActionBatchRequest request, String userId, String organizationId) { + List approvalTasks = approvalTaskMapper.selectByIds(request.getIds()); + approvalTasks.forEach(approvalTask -> approve(ApprovalActionRequest.builder().id(approvalTask.getId()).instanceId(approvalTask.getInstanceId()) + .nodeId(approvalTask.getNodeId()).approverId(approvalTask.getApproverId()) + .comment(request.getComment()).attachmentIds(request.getAttachmentIds()) + .build(), userId, organizationId)); + } + + /** + * 批量驳回 + * @param request 请求参数 + * @param userId 当前用户ID + * @param orgId 当前组织ID + */ + public void batchReject(ApprovalActionBatchRequest request, String userId, String orgId) { + List approvalTasks = approvalTaskMapper.selectByIds(request.getIds()); + approvalTasks.forEach(approvalTask -> reject(ApprovalActionRequest.builder().id(approvalTask.getId()).nodeId(approvalTask.getNodeId()).instanceId(approvalTask.getInstanceId()) + .approverId(approvalTask.getApproverId()).comment(request.getComment()).attachmentIds(request.getAttachmentIds()).build(), + userId, orgId)); + } + + /** + * 保存加签任务的信息 + * + * @param request 加签参数 + */ + private ApprovalAddSignTask saveAddSignTask(ApprovalAddSignRequest request, String taskId) { + // 计算加签信息 + AddSignSortInfo signSortInfo = calculateAddSignSort(request.getId(), request.getType()); + ApprovalAddSignTask approvalAddSignTask = new ApprovalAddSignTask(); + approvalAddSignTask.setId(IDGenerator.nextStr()); + approvalAddSignTask.setTaskId(taskId); + approvalAddSignTask.setSignTaskId(request.getId()); + approvalAddSignTask.setType(request.getType()); + approvalAddSignTask.setComment(request.getComment()); + // 设置扩展字段 + approvalAddSignTask.setRootTaskId(signSortInfo.getRootTaskId()); + approvalAddSignTask.setSort(signSortInfo.getSort()); + approvalAddSignTasMapper.insert(approvalAddSignTask); + return approvalAddSignTask; + } + + /** + * 保存执行附件信息 + * + * @param attachmentIds 附件ID集合 + * @param instanceId 实例ID + * @param elementId 节点ID + * @param userId 当前用户 + * @param orgId 当前组织 + */ + private void saveInstanceAttachment(List attachmentIds, String instanceId, String elementId, String userId, String orgId) { + ApprovalInstance instance = approvalInstanceMapper.selectByPrimaryKey(instanceId); + List attachments = new ArrayList<>(); + attachmentIds.forEach(attachmentId -> { + ApprovalInstanceAttachment attachment = new ApprovalInstanceAttachment(); + attachment.setId(IDGenerator.nextStr()); + attachment.setInstanceId(instanceId); + attachment.setElementId(elementId); + attachment.setAttachmentId(attachmentId); + attachments.add(attachment); + }); + approvalInstanceAttachmentMapper.batchInsert(attachments); + // 转移临时文件, 保存附件信息 + UploadTransferRequest transferRequest = new UploadTransferRequest(orgId, instance.getResourceId(), userId, attachmentIds); + attachmentService.appendTemp(transferRequest); + } + + /** + * 追加加签操作的待办任务 + * + * @param request 加签参数 + * @param userId 当前用户 + */ + private ApprovalTask appendSignTask(ApprovalAddSignRequest request, String userId, int round) { + ApprovalTask approvalTask = new ApprovalTask(); + BeanUtils.copyBean(approvalTask, request); + approvalTask.setId(IDGenerator.nextStr()); + approvalTask.setNodeId(request.getNodeId()); + approvalTask.setApproverId(request.getSignApprover()); + approvalTask.setNodeRound(round); + approvalTask.setCreateTime(System.currentTimeMillis()); + approvalTask.setUpdateTime(System.currentTimeMillis()); + approvalTask.setCreateUser(userId); + approvalTask.setUpdateUser(userId); + approvalTask.setStatus(ApprovalStatus.APPROVING.name()); + approvalTask.setType(ApprovalTaskType.SN.name()); + approvalTaskMapper.insert(approvalTask); + return approvalTask; + } + + /** + * 追加退回操作的待办任务 + * + * @param backRequest 退回参数 + * @param userId 当前用户 + * @param currentOrgId 当前组织ID + */ + private void appendBackTasks(ApprovalReturnBackRequest backRequest, String userId, String currentOrgId) { + ApprovalInstance instance = approvalInstanceMapper.selectByPrimaryKey(backRequest.getInstanceId()); + List approvers = approvalFlowService.getCurrentNodeApproverList(instance, backRequest.getReturnToNodeId(), currentOrgId); + ApprovalNodeApprover approvalNodeApprover = approvalNodeApproverMapper.selectByPrimaryKey(backRequest.getReturnToNodeId()); + List approvalTasks = getNodeApproverTasks(backRequest.getReturnToNodeId(), approvers, null, + SameSubmitterActionEnum.valueOf(approvalNodeApprover.getSameSubmitterAction()), MultiApproverModeEnum.valueOf(approvalNodeApprover.getMultiApproverMode()), + instance, userId, ApprovalTaskType.NL.name()); + if (CollectionUtils.isNotEmpty(approvalTasks)) { + approvalTaskMapper.batchInsert(approvalTasks); + // 发送待办消息通知 + sendApprovalTaskNotice(approvalTasks, instance, currentOrgId); + } + } + + /** + * 保存退回节点信息 + * + * @param request 退回参数 + * @param userId 当前用户ID + * @return 退回节点信息 + */ + private ApprovalReturnBackRecord saveBackRecord(ApprovalReturnBackRequest request, String instanceId, String userId) { + ApprovalReturnBackRecord backRecord = new ApprovalReturnBackRecord(); + backRecord.setId(IDGenerator.nextStr()); + backRecord.setInstanceId(instanceId); + backRecord.setTaskId(request.getId()); + backRecord.setReturnToNodeId(request.getReturnToNodeId()); + backRecord.setReturnReason(request.getComment()); + backRecord.setReturnUserId(userId); + // 每个审批实例, 只保留最新的一条退回节点记录 + approvalReturnBackRecordMapper.deleteByLambda(new LambdaQueryWrapper().eq(ApprovalReturnBackRecord::getInstanceId, instanceId) + .eq(ApprovalReturnBackRecord::getReturnToNodeId, request.getReturnToNodeId())); + approvalReturnBackRecordMapper.insert(backRecord); + return backRecord; + } + + /** + * 保存审批记录信息 (附件) + * + * @param currentTask 当前执行任务 + * @param comment 评论意见 + * @param attachmentIds 附件ID集合 + * @param currentUserId 当前操作人 + */ + private void saveApprovalRecord(ApprovalTask currentTask, String comment, List attachmentIds, String currentUserId, String orgId) { + List records = approvalRecordMapper.selectListByLambda(new LambdaQueryWrapper().eq(ApprovalRecord::getInstanceId, currentTask.getInstanceId()) + .eq(ApprovalRecord::getTaskId, currentTask.getId()).eq(ApprovalRecord::getNodeRound, currentTask.getNodeRound()).eq(ApprovalRecord::getNodeId, currentTask.getNodeId())); + if (CollectionUtils.isNotEmpty(records)) { + // 撤回的任务已经产生执行记录 + if (StringUtils.isBlank(comment) && CollectionUtils.isEmpty(attachmentIds)) { + return; + } + // 如果产生了新的附件和意见, 清理掉 + approvalRecordMapper.deleteByIds(records.stream().map(ApprovalRecord::getId).toList()); + } + ApprovalRecord record = new ApprovalRecord(); + record.setId(IDGenerator.nextStr()); + record.setInstanceId(currentTask.getInstanceId()); + record.setTaskId(currentTask.getId()); + record.setNodeRound(currentTask.getNodeRound()); + record.setNodeId(currentTask.getNodeId()); + record.setComment(comment); + record.setCreateTime(System.currentTimeMillis()); + record.setCreateUser(currentUserId); + record.setUpdateTime(System.currentTimeMillis()); + record.setUpdateUser(currentUserId); + approvalRecordMapper.insert(record); + if (CollectionUtils.isNotEmpty(attachmentIds)) { + saveInstanceAttachment(attachmentIds, currentTask.getInstanceId(), record.getId(), currentUserId, orgId); + } + } + + /** + * 根据任务ID获取审批任务 + * + * @param taskId 任务ID + * @return 审批任务 + */ + private ApprovalTask getTaskById(String taskId) { + ApprovalTask currentTask = approvalTaskMapper.selectByPrimaryKey(taskId); + if (currentTask == null) { + throw new GenericException("审批任务不存在!"); + } + return currentTask; + } + + /** + * 同意操作执行 + * + * @param currentTask 当前任务 + * @param currentUserId 当前用户ID + * @param currentOrgId 当前组织ID + */ + private void approvedProcess(ApprovalInstance instance, ApprovalTask currentTask, String currentUserId, String currentOrgId) { + // 加签类型的待办任务 + appendProcessSignTask(instance, currentTask, currentOrgId); + // 多人依次审批类型的待办 + List multiSeqApprovalTasks = new ArrayList<>(); + ApprovalNodeApprover nodeApprover = getNodeApprover(currentTask.getNodeId()); + if (MultiApproverModeEnum.valueOf(nodeApprover.getMultiApproverMode()) == MultiApproverModeEnum.SEQUENTIAL) { + // 依次审批, 如果存在审批中任务(加签生成的)则跳过 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(ApprovalTask::getNodeId, currentTask.getNodeId()) + .eq(ApprovalTask::getInstanceId, currentTask.getInstanceId()) + .eq(ApprovalTask::getStatus, ApprovalStatus.APPROVING.name()).nq(ApprovalTask::getType, ApprovalTaskType.CC); + List approvingTasks = approvalTaskMapper.selectListByLambda(queryWrapper); + if (approvingTasks.isEmpty()) { + List autoSkipUserIds = approvalFlowService.getFlowAutoSkipUser(instance, currentTask.getNodeId(), List.of(currentTask.getApproverId())); + SameSubmitterActionEnum sameAction = SameSubmitterActionEnum.valueOf(nodeApprover.getSameSubmitterAction()); + // 如果依次审批的节点存在跳过的情况, 一直往后取 + int seq = 0; + String nextSeqUserId = getMultiSeqAfterOne(currentTask.getNodeId(), currentTask.getInstanceId(), currentOrgId, seq); + while (StringUtils.isNotBlank(nextSeqUserId)) { + ApprovalTask nextTask = buildTask(currentTask.getNodeId(), currentTask.getInstanceId(), nextSeqUserId, ApprovalTaskType.NL.name(), currentUserId, currentTask.getNodeRound(), seq); + boolean autoSkip = autoSkipUserIds.contains(nextSeqUserId); + boolean sameSubmitterSkip = sameAction == SameSubmitterActionEnum.SKIP && Strings.CI.equals(nextSeqUserId, instance.getSubmitterId()); + if (autoSkip || sameSubmitterSkip) { + // 自动同意 (重复审批跳过, 提审人相同跳过) + nextTask.setAction(ApprovalAction.APPROVE.name()); + nextTask.setStatus(ApprovalStatus.AUTO_APPROVED.name()); + approvalFlowService.saveAutoRecord(currentTask.getInstanceId(), currentTask.getNodeId(), ApprovalStatus.AUTO_APPROVED, + autoSkipUserIds.contains(nextSeqUserId) ? "审批人重复出现, 后续节点自动通过" : "审批人与提交人为同一人时, 自动同意跳过", nextTask.getId(), null, false, nextTask.getNodeRound()); + seq++; + nextSeqUserId = getMultiSeqAfterOne(currentTask.getNodeId(), currentTask.getInstanceId(), currentOrgId, seq); + } else { + nextSeqUserId = null; + } + multiSeqApprovalTasks.add(nextTask); + } + } + } + if (CollectionUtils.isNotEmpty(multiSeqApprovalTasks)) { + approvalTaskMapper.batchInsert(multiSeqApprovalTasks); + // 发送待办消息通知 + sendApprovalTaskNotice(multiSeqApprovalTasks, instance, currentOrgId); + } + + // 节点状态流转类型的待办 + if (isCurrentSingleNodeApproved(currentTask.getNodeId(), currentTask.getInstanceId(), instance.getSubmitterId(), currentOrgId) || isCurrentMultiNodeApproved(currentTask.getNodeId(), currentTask.getInstanceId())) { + // 流转之前需要发送当前节点的抄送 + handlePreCcTasks(currentTask.getNodeId(), instance, currentUserId, currentOrgId); + approvalFlowService.updateApprovalPostField(instance, currentTask.getNodeId(), ApprovalAction.APPROVE, currentUserId); + // 单人审批或者多人审批但节点流转通过 + ApprovalNodeResponse nextNode = approvalFlowService.getTaskNextNode(currentTask, instance, currentOrgId); + handleNextApprovalNode(nextNode, instance, currentTask.getApproverId(), currentUserId, currentOrgId); + // 多人或签, 移除审批中的任务 + loseCurrentNode(instance.getId(), currentTask.getNodeId()); + } + } + + /** + * 加签操作导致的后续待办任务 + * @param currentTask 当前节点任务 + */ + private void appendProcessSignTask(ApprovalInstance instance, ApprovalTask currentTask, String currentOrgId) { + if (Strings.CI.equals(currentTask.getType(), ApprovalTaskType.SN.name())) { + // 加签任务执行, 需要获取同一加签链路的下一个待办任务 + ApprovalTask nextTask = getNextAddSignTask(currentTask.getId()); + if (nextTask != null) { + nextTask.setCreateTime(nextTask.getCreateTime() + 1); + nextTask.setUpdateTime(System.currentTimeMillis()); + approvalTaskMapper.insert(nextTask); + sendApprovalTaskNotice(List.of(nextTask), instance, currentOrgId); + } + } + } + + /** + * 执行审批任务 + * + * @param request 执行参数 + * @param action 执行操作 + * @param currentUserId 当前用户ID + * @return 执行任务 + */ + private ApprovalTask saveActionTask(ApprovalActionRequest request, ApprovalAction action, String currentUserId, String currentOrgId, ApprovalAddSignType signType) { + // 保存执行的任务及记录 + ApprovalTask currentTask = getTaskById(request.getId()); + switch (action) { + case APPROVE: { + currentTask.setAction(ApprovalAction.APPROVE.name()); + currentTask.setStatus(ApprovalStatus.APPROVED.name()); + break; + } + case REJECT: { + currentTask.setAction(ApprovalAction.REJECT.name()); + currentTask.setStatus(ApprovalStatus.UNAPPROVED.name()); + break; + } + case BACK: { + currentTask.setStatus(ApprovalStatus.PENDING.name()); + currentTask.setAction(ApprovalAction.BACK.name()); + break; + } + case SIGN: { + if (signType == ApprovalAddSignType.BEFORE) { + currentTask.setStatus(ApprovalStatus.PENDING.name()); + currentTask.setAction(ApprovalAction.SIGN.name()); + } else { + currentTask.setAction(ApprovalAction.APPROVE.name()); + currentTask.setStatus(ApprovalStatus.APPROVED.name()); + } + break; + } + default: { + + } + } + currentTask.setUpdateUser(currentUserId); + currentTask.setUpdateTime(System.currentTimeMillis()); + approvalTaskMapper.updateById(currentTask); + + // 退回, 之前加签操作不产生执行记录 + if (action != ApprovalAction.BACK && signType != ApprovalAddSignType.BEFORE) { + saveApprovalRecord(currentTask, request.getComment(), request.getAttachmentIds(), currentUserId, currentOrgId); + } + return currentTask; + } + + /** + * 获取审批节点 + * + * @param nodeId 节点ID + * @return 审批节点 + */ + private ApprovalNodeApprover getNodeApprover(String nodeId) { + return approvalNodeApproverMapper.selectByPrimaryKey(nodeId); + } + + /** + * 判断当前节点是否为多人或签 + * @param nodeId 当前节点ID + * @return 是否为多人或签 + */ + private boolean isMultiAnyMode(String nodeId, String userId, String orgId) { + boolean multi = approvalFlowService.isCurrentNodeMultiApprover(nodeId, userId, orgId); + ApprovalNodeApprover nodeApprover = getNodeApprover(nodeId); + return multi && Strings.CI.equals(nodeApprover.getMultiApproverMode(), MultiApproverModeEnum.ANY.name()); + } + + /** + * 加签时计算排序和链路信息 + * + * @param sourceTaskId 被加签的任务ID(即当前用户的任务ID) + * @param addSignType 加签方式 BEFORE / AFTER + * @return 包含 rootTaskId, sort 的计算结果 + */ + private AddSignSortInfo calculateAddSignSort(String sourceTaskId, String addSignType) { + // 查询被加签任务是否有加签记录 + ApprovalAddSignTask signCriteria = new ApprovalAddSignTask(); + signCriteria.setTaskId(sourceTaskId); + ApprovalAddSignTask sourceAddSignTask = approvalAddSignTaskMapper.selectOne(signCriteria); + + AddSignSortInfo signInfo = new AddSignSortInfo(); + if (sourceAddSignTask == null) { + /* + * 普通任务上加签,rootTaskId就是被加签的任务ID + */ + signInfo.setRootTaskId(sourceTaskId); + signInfo.setSort(DEFAULT_SIGN_SORT_STEP); + } else { + /* + * 加签任务上再加签, rootTaskId继承父加签的rootTaskId + */ + signInfo.setRootTaskId(sourceAddSignTask.getRootTaskId()); + + // 继承父加签任务的sort + long parentSort = sourceAddSignTask.getSort() != null ? sourceAddSignTask.getSort() : 0L; + if (ApprovalAddSignType.BEFORE.name().equalsIgnoreCase(addSignType)) { + // BEFORE: 插在父节点之前 + signInfo.setSort(parentSort - 100); + } else { + // AFTER: 插在父节点之后, 需要比父节点大但比下一个任务小 + Long nextSort = getNextSortByRootTask(sourceAddSignTask.getRootTaskId(), parentSort); + if (nextSort != null) { + // 有下一个任务,插在中间:(parentSort + nextSort) / 2 + signInfo.setSort((parentSort + nextSort) / 2); + } else { + // 没有下一个任务,直接 +100 + signInfo.setSort(parentSort + 100); + } + } + } + + return signInfo; + } + + /** + * 获取指定根任务节点下,比当前排序值大的最小排序值(即下一个任务排序值) + */ + private Long getNextSortByRootTask(String rootTaskId, Long currentSort) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(ApprovalAddSignTask::getRootTaskId, rootTaskId) + .gt(ApprovalAddSignTask::getSort, currentSort) + .orderByAsc(ApprovalAddSignTask::getSort); + List approvalAddSignTasks = approvalAddSignTaskMapper.selectListByLambda(queryWrapper); + return CollectionUtils.isEmpty(approvalAddSignTasks) ? null : approvalAddSignTasks.getFirst().getSort(); + } + + /** + * 获取当前加签任务的下一个待办任务 (同一加签链路) + * @param currentTaskId 当前加签任务ID + * @return 下一个待办任务 + */ + private ApprovalTask getNextAddSignTask(String currentTaskId) { + // 1. 查询当前任务的加签记录 + ApprovalAddSignTask currentAddSign = new ApprovalAddSignTask(); + currentAddSign.setTaskId(currentTaskId); + currentAddSign = approvalAddSignTaskMapper.selectOne(currentAddSign); + + if (currentAddSign == null) { + return null; + } + + String rootTaskId = currentAddSign.getRootTaskId(); + Long currentSort = currentAddSign.getSort(); + // 2. 查询同一个根任务节点下,sort比当前任务大的记录 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(ApprovalAddSignTask::getRootTaskId, rootTaskId) + .gt(ApprovalAddSignTask::getSort, currentSort) + .orderByAsc(ApprovalAddSignTask::getSort); + List signTasks = approvalAddSignTaskMapper.selectListByLambda(queryWrapper); + + if (CollectionUtils.isNotEmpty(signTasks)) { + // 下一个加签节点 (断开, 指向新的任务节点) + ApprovalAddSignTask nextAddSignTask = signTasks.getFirst(); + ApprovalTask oldTask = approvalTaskMapper.selectByPrimaryKey(nextAddSignTask.getTaskId()); + ApprovalTask newTask = copyEmptyTask(oldTask); + nextAddSignTask.setTaskId(newTask.getId()); + approvalAddSignTaskMapper.updateById(nextAddSignTask); + return newTask; + } + + // 4. 没有下一个加签任务了,返回根任务(原始流程节点的任务) + ApprovalTask rootTask = approvalTaskMapper.selectByPrimaryKey(currentAddSign.getRootTaskId()); + if (ApprovalStatus.valueOf(rootTask.getStatus()) == ApprovalStatus.PENDING) { + ApprovalTask copyRoot = copyEmptyTask(rootTask); + extApprovalTaskMapper.moveAddSignRoot(rootTask.getId(), copyRoot.getId()); + extApprovalTaskMapper.updateRootNext(rootTask.getId(), copyRoot.getId()); + return copyRoot; + } + return null; + } + + /** + * 复制新加签待办任务 + * @param old 旧加签任务 + * @return 新待办任务 + */ + private ApprovalTask copyEmptyTask(ApprovalTask old) { + ApprovalTask newTask = BeanUtils.copyBean(new ApprovalTask(), old); + newTask.setId(IDGenerator.nextStr()); + newTask.setAction(null); + newTask.setStatus(ApprovalStatus.APPROVING.name()); + return newTask; + } + + /** + * 获取多人依次审批后续审批人 + * @param nodeId 节点ID + * @param instanceId 实例ID + * @param currentOrgId 组织ID + * @param next 后续位次 (从0开始, 0为下一个) + * @return 后续审批人 + */ + public String getMultiSeqAfterOne(String nodeId, String instanceId, String currentOrgId, Integer next) { + ApprovalInstance instance = approvalInstanceMapper.selectByPrimaryKey(instanceId); + List approvers = approvalFlowService.getCurrentNodeApproverList(instance, nodeId, currentOrgId); + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(ApprovalTask::getNodeId, nodeId) + .eq(ApprovalTask::getInstanceId, instanceId) + .eq(ApprovalTask::getType, ApprovalTaskType.NL.name()) + .eq(ApprovalTask::getStatus, ApprovalStatus.APPROVED.name()); + List approvedTask = approvalTaskMapper.selectListByLambda(queryWrapper); + if (approvedTask.size() < approvers.size()) { + // 依次审批, 返回下下一个审批人 + return approvers.get(approvedTask.size() + next); + } + return null; + } + + /** + * 获取多人依次审批当前用户下一个审批人 + * @param nodeId 节点ID + * @param instanceId 实例ID + * @param currentOrgId 组织ID + * @param currentApprover 当前审批人 + * @return 下一个审批人 + */ + private String getMultiSeqCurrentNextOne(String nodeId, String instanceId, String currentOrgId, String currentApprover) { + ApprovalInstance instance = approvalInstanceMapper.selectByPrimaryKey(instanceId); + List approvers = approvalFlowService.getCurrentNodeApproverList(instance, nodeId, currentOrgId); + for (int i = 0; i < approvers.size(); i++) { + if (Strings.CI.equals(approvers.get(i), currentApprover)) { + return approvers.get(i + 1); + } + } + return null; + } + + /** + * 是否当前节点为单人节点且审批通过 + * @param currentNodeId 当前节点ID + * @param instanceId 审批实例ID + * @return 单人节点且审批通过 + */ + private boolean isCurrentSingleNodeApproved(String currentNodeId, String instanceId, String submitter, String currentOrgId) { + boolean multiApprover = approvalFlowService.isCurrentNodeMultiApprover(currentNodeId, submitter, currentOrgId); + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(ApprovalTask::getNodeId, currentNodeId) + .eq(ApprovalTask::getInstanceId, instanceId) + .eq(ApprovalTask::getStatus, ApprovalStatus.APPROVING).nq(ApprovalTask::getType, ApprovalTaskType.CC); + List approvalTasks = approvalTaskMapper.selectListByLambda(queryWrapper); + return !multiApprover && approvalTasks.isEmpty(); + } + + /** + * 判断当前多人审批节点是否通过 + * @param currentNodeId 当前节点ID + * @return 是否通过 + */ + private boolean isCurrentMultiNodeApproved(String currentNodeId, String instanceId) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(ApprovalTask::getNodeId, currentNodeId) + .eq(ApprovalTask::getInstanceId, instanceId).nq(ApprovalTask::getType, ApprovalTaskType.CC); + List approvalTasks = approvalTaskMapper.selectListByLambda(queryWrapper); + ApprovalNodeApprover nodeApprover = getNodeApprover(currentNodeId); + if (MultiApproverModeEnum.valueOf(nodeApprover.getMultiApproverMode()) == MultiApproverModeEnum.ANY) { + // 或签, 只要有一个审批通过任务即可 + return approvalTasks.stream().anyMatch(task -> ApprovalStatus.APPROVED.name().equals(task.getStatus())); + } else { + // 会签或者依次审批, 不存在审批中的任务即可 + return approvalTasks.stream().noneMatch(task -> ApprovalStatus.APPROVING.name().equals(task.getStatus())); + } + } + + /** + * 当前多人节点是否审批中 + * @param currentNodeId 当前节点ID + * @param instanceId 实例ID + * @return 是否审批中 + */ + private boolean isCurrentMultiNodeApproving(String currentNodeId, String instanceId) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(ApprovalTask::getNodeId, currentNodeId) + .eq(ApprovalTask::getInstanceId, instanceId); + List approvalTasks = approvalTaskMapper.selectListByLambda(queryWrapper); + ApprovalNodeApprover nodeApprover = getNodeApprover(currentNodeId); + if (MultiApproverModeEnum.valueOf(nodeApprover.getMultiApproverMode()) == MultiApproverModeEnum.ANY) { + // 或签, 只要没有审批通过任务即可 + return approvalTasks.stream().noneMatch(task -> ApprovalStatus.APPROVED.name().equals(task.getStatus())); + } else { + // 会签或者依次审批, 只要存在审批中的任务即整个节点为审批中 + return approvalTasks.stream().anyMatch(task -> ApprovalStatus.APPROVING.name().equals(task.getStatus())); + } + } + + /** + * 是否当前节点不是审批中 + * @param currentNodeId 当前节点ID + * @param instance 实例 + * @param currentOrgId 当前组织ID + * @return 是否不是审批中 + */ + private boolean isCurrentNodeNotApproving(String currentNodeId, ApprovalInstance instance, String currentOrgId) { + boolean multiApprover = approvalFlowService.isCurrentNodeMultiApprover(currentNodeId, instance.getSubmitterId(), currentOrgId); + if (multiApprover) { + return !isCurrentMultiNodeApproving(currentNodeId, instance.getId()); + } else { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(ApprovalTask::getNodeId, currentNodeId) + .eq(ApprovalTask::getInstanceId, instance.getId()); + List approvalTasks = approvalTaskMapper.selectListByLambda(queryWrapper); + return approvalTasks.stream().noneMatch(task -> ApprovalStatus.APPROVING.name().equals(task.getStatus())); + } + } + + /** + * 退回执行 + * @param instance 审批实例 + * @param request 请求参数 + * @param orgId 组织ID + */ + private void backProcess(ApprovalInstance instance, ApprovalReturnBackRequest request, String orgId) { + if (ApprovalStatus.valueOf(instance.getApprovalStatus()) != ApprovalStatus.APPROVING) { + // 非审批中, 无法进行节点退回 + throw new GenericException(Translator.get("no.back.approval")); + } + // 追加退回节点的待办 + appendBackTasks(request, instance.getSubmitterId(), orgId); + // 清理后续所有执行过的节点待办轮次 + clearBackToCurrentNode(request.getReturnToNodeId(), request.getNodeId(), instance, orgId); + } + + /** + * 清理退回节点至当前节点的任务待办和记录 + * @param currentNodeId 当前节点ID + * @param endNodeId 结束节点ID + * @param instance 审批实例ID + * @param orgId 组织ID + */ + private void clearBackToCurrentNode(String currentNodeId, String endNodeId, ApprovalInstance instance, String orgId) { + if (Strings.CI.equals(currentNodeId, endNodeId)) { + return; + } + ApprovalNodeResponse next = approvalFlowService.getCurrentNextNode(currentNodeId, instance, orgId); + clearExpiredNode(instance.getId(), next.getId()); + clearBackToCurrentNode(next.getId(), endNodeId, instance, orgId); + } + + /** + * 清理过期节点 + * @param instanceId 审批实例ID + * @param nodeId 当前节点ID + */ + public void clearExpiredNode(String instanceId, String nodeId) { + Integer maxRound = extApprovalInstanceMapper.getNodeRound(instanceId, nodeId); + if (maxRound > 0) { + /* + * 当前节点执行过, 假删除, 保留历史待办 + */ + extApprovalInstanceMapper.batchClearNotApprovingTask(instanceId, nodeId, maxRound); + extApprovalInstanceMapper.batchClearRecord(instanceId, nodeId, maxRound); + extApprovalInstanceMapper.batchClearApprovingTask(instanceId, nodeId, maxRound); + } + } + + /** + * 当前节点撤回 + * @param instanceId 审批实例ID + * @param nodeId 节点ID + */ + public void loseCurrentNode(String instanceId, String nodeId) { + Integer maxRound = extApprovalInstanceMapper.getNodeRound(instanceId, nodeId); + if (maxRound > 0) { + /* + * 当前节点执行过 + * 审批中 => 空 + * 待审批 => 待审批 + * 已完成 => 已完成 (同意, 驳回) + */ + extApprovalInstanceMapper.loseApprovingTask(instanceId, nodeId, maxRound); + } + } + + /** + * 撤回操作执行 + * @param currentTask 当前任务 + * @param instance 当前实例 + * @param currentOrgId 当前组织ID + */ + private void revokeProcess(ApprovalTask currentTask, ApprovalInstance instance, String currentOrgId) { + boolean multiApprover = approvalFlowService.isCurrentNodeMultiApprover(currentTask.getNodeId(), instance.getSubmitterId(), currentOrgId); + // 撤回不涉及到流转操作, 获取下一个节点用于校验后续节点状态 + ApprovalNodeResponse nextNode = approvalFlowService.getTaskNextNode(currentTask, instance, currentOrgId); + if (multiApprover) { + ApprovalNodeApprover nodeApprover = getNodeApprover(currentTask.getNodeId()); + boolean nodeApproving = isCurrentMultiNodeApproving(currentTask.getNodeId(), currentTask.getInstanceId()); + if (!nodeApproving && MultiApproverModeEnum.valueOf(nodeApprover.getMultiApproverMode()) == MultiApproverModeEnum.ALL) { + // 多人会签, 但当前节点非审批中, 节点任务不允许撤回 + throw new GenericException(Translator.get("no.revoke.approval")); + } + if (MultiApproverModeEnum.valueOf(nodeApprover.getMultiApproverMode()) == MultiApproverModeEnum.ANY && isCurrentNodeNotApproving(nextNode.getId(), instance, currentOrgId)) { + // 多人或签, 但下一个节点非审批中, 节点任务不允许撤回 + throw new GenericException(Translator.get("no.revoke.approval")); + } + if (MultiApproverModeEnum.valueOf(nodeApprover.getMultiApproverMode()) == MultiApproverModeEnum.SEQUENTIAL) { + // 多人依次审批, 但当前节点非审批中, 节点任务不允许撤回 + if (!nodeApproving) { + throw new GenericException(Translator.get("no.revoke.approval")); + } + String nextUserId = getMultiSeqCurrentNextOne(currentTask.getNodeId(), currentTask.getInstanceId(), currentOrgId, currentTask.getApproverId()); + if (StringUtils.isBlank(nextUserId)) { + throw new GenericException(Translator.get("no.revoke.approval")); + } + ApprovalTask taskCriteria = new ApprovalTask(); + taskCriteria.setApproverId(nextUserId); + taskCriteria.setInstanceId(instance.getId()); + taskCriteria.setNodeId(currentTask.getNodeId()); + taskCriteria.setStatus(ApprovalStatus.APPROVING.name()); + ApprovalTask nextTask = approvalTaskMapper.selectOne(taskCriteria); + if (nextTask == null) { + // 多人依次审批, 撤销任务的下一个审批任务已经执行, 无法撤销 + throw new GenericException(Translator.get("no.revoke.approval")); + } + // 否则清理掉, 后续重新生成 + approvalTaskMapper.delete(nextTask); + } + } else { + // 单人审批 + if (ApprovalNodeTypeEnum.valueOf(nextNode.getNodeType()) == ApprovalNodeTypeEnum.END) { + // 后续节点已结束, 不允许撤回 + throw new GenericException(Translator.get("no.revoke.approval")); + } + if (ApprovalNodeTypeEnum.valueOf(nextNode.getNodeType()) == ApprovalNodeTypeEnum.APPROVER && isCurrentNodeNotApproving(nextNode.getId(), instance, currentOrgId)) { + // 后续审批节点不为审批中, 不允许撤回 + throw new GenericException(Translator.get("no.revoke.approval")); + } + } + // 清理后续审批节点的待办任务, 后续执行重新生成 + clearExpiredNode(instance.getId(), nextNode.getId()); + } + + /** + * 重置撤回的任务 + * @param approvalTask 任务 + * @param instance 实例 + * @param currentUserId 当前用户ID + */ + private void refreshRevokeTask(ApprovalTask approvalTask, ApprovalInstance instance, String currentUserId) { + approvalTask.setStatus(ApprovalStatus.APPROVING.name()); + approvalTask.setAction(null); + approvalTask.setUpdateTime(System.currentTimeMillis()); + approvalTask.setUpdateUser(currentUserId); + approvalTaskMapper.updateById(approvalTask); + instance.setCurrentNodeId(approvalTask.getNodeId()); + approvalInstanceMapper.updateById(instance); + } + + /** + * 处理上一个节点的抄送任务 + * @param currentNodeId 当前节点ID + * @param instance 当前实例ID + * @param currentUserId 当前用户ID + * @param currentOrgId 当前组织ID + */ + private void handlePreCcTasks(String currentNodeId, ApprovalInstance instance, String currentUserId, String currentOrgId) { + List ccList = approvalFlowService.getCurrentNodeCcList(currentNodeId, instance.getSubmitterId(), currentOrgId); + List ccTasks = getNodeCcTasks(currentNodeId, ccList.stream().map(User::getId).toList(), instance.getId(), currentUserId); + if (CollectionUtils.isNotEmpty(ccTasks)) { + approvalTaskMapper.batchInsert(ccTasks); + } + } + + /** + * 处理下一个节点 + * @param node 下一个节点 + * @param instance 审批实例 + * @param currentUserId 当前用户ID + */ + private void handleNextApprovalNode(ApprovalNodeResponse node, ApprovalInstance instance, String preApproverId, String currentUserId, String currentOrgId) { + // 更新审批实例当前节点, 插入待办和抄送任务 + instance.setCurrentNodeId(node.getId()); + instance.setApprovalStatus(ApprovalStatus.APPROVING.name()); + instance.setUpdateTime(System.currentTimeMillis()); + instance.setUpdateUser(currentUserId); + if (ApprovalNodeTypeEnum.valueOf(node.getNodeType()) == ApprovalNodeTypeEnum.END) { + instance.setApprovalStatus(ApprovalStatus.APPROVED.name()); + instance.setApprovalTime(System.currentTimeMillis()); + } + if (ApprovalNodeTypeEnum.valueOf(node.getNodeType()) == ApprovalNodeTypeEnum.EXCEPTION) { + instance.setApprovalStatus(ApprovalStatus.UNAPPROVED.name()); + instance.setApprovalTime(System.currentTimeMillis()); + } + approvalInstanceMapper.updateById(instance); + ApprovalResourceService resourceService = CommonBeanFactory.getBean(ApprovalResourceService.class); + if (resourceService != null) { + resourceService.updateResourceApprovalStatus(FormKey.ofKey(instance.getType()), instance.getResourceId(), instance.getApprovalStatus(), currentUserId, currentOrgId); + // 审批流程结束,删除中间数据 + if (ApprovalNodeTypeEnum.valueOf(node.getNodeType()) == ApprovalNodeTypeEnum.END || ApprovalNodeTypeEnum.valueOf(node.getNodeType()) == ApprovalNodeTypeEnum.EXCEPTION) { + // DELETE审批通过后,执行实际的删除操作 + if (ApprovalNodeTypeEnum.valueOf(node.getNodeType()) == ApprovalNodeTypeEnum.END + && Strings.CI.equals(instance.getExecuteTime(), ExecuteTimingEnum.DELETE.name())) { + resourceService.executeDeleteAction(instance.getType(), instance.getResourceId(), currentUserId, currentOrgId); + } + } + } + if (ApprovalNodeTypeEnum.valueOf(node.getNodeType()) == ApprovalNodeTypeEnum.APPROVER) { + handlerNextNodeApproverTasks((ApprovalNodeApproverResponse) node, instance, preApproverId, currentUserId, ApprovalTaskType.NL.name(), currentOrgId); + } + } + + /** + * 获取审批节点待办任务 + * @param approverNode 审批节点 + * @param instance 实例 + * @param userId 当前用户ID + * @param taskType 任务类型 + */ + public void handlerNextNodeApproverTasks(ApprovalNodeApproverResponse approverNode, ApprovalInstance instance, String preApproverId, String userId, String taskType, String currentOrgId) { + List approvalTasks = getNodeApproverTasks(approverNode.getId(), approverNode.getApproverList(), preApproverId, SameSubmitterActionEnum.valueOf(approverNode.getSameSubmitterAction()), + MultiApproverModeEnum.valueOf(approverNode.getMultiApproverMode()), instance, userId, taskType); + if (CollectionUtils.isNotEmpty(approvalTasks)) { + approvalTaskMapper.batchInsert(approvalTasks); + // 发送待办消息通知 + sendApprovalTaskNotice(approvalTasks, instance, currentOrgId); + } + } + + /** + * 获取节点上的待办任务 + * @param nodeId 节点ID + * @param approvers 审批人集合 + * @param sameAction 提审人相同配置项 + * @param multiMode 多人审批方式 + * @param instance 审批实例 + * @param userId 当前用户ID + * @param taskType 任务类型 + * @return 待办任务集合 + */ + public List getNodeApproverTasks(String nodeId, List approvers, String preApproverId, SameSubmitterActionEnum sameAction, MultiApproverModeEnum multiMode, ApprovalInstance instance, String userId, String taskType) { + List approvalTasks = new ArrayList<>(); + if (CollectionUtils.isEmpty(approvers)) { + return approvalTasks; + } + Integer nextRound = extApprovalInstanceMapper.getNextNodeRound(instance.getId(), nodeId); + List autoSkipUser = preApproverId == null ? new ArrayList<>() : approvalFlowService.getFlowAutoSkipUser(instance, nodeId, List.of(preApproverId)); + if (sameAction == SameSubmitterActionEnum.SKIP) { + // 如果配置了审批人和提审人相同, 自动跳过 (会签, 依次审批 生成的提审人待办需直接同意, 或签和单人审批已在流程执行的时候处理过) + switch (multiMode) { + case SEQUENTIAL -> { + // 依次审批需要循环发送待办, 直到该待办审批人没有跳过 + for (int i = 0; i < approvers.size(); i++) { + String seqUser = approvers.get(i); + ApprovalTask firstTask = buildTask(nodeId, instance.getId(), seqUser, taskType, userId, nextRound, i); + if (Strings.CI.equals(seqUser, instance.getSubmitterId()) || autoSkipUser.contains(seqUser)) { + // 触发了自动同意, 跳过 + firstTask.setAction(ApprovalAction.APPROVE.name()); + firstTask.setStatus(ApprovalStatus.AUTO_APPROVED.name()); + approvalFlowService.saveAutoRecord(instance.getId(), nodeId, ApprovalStatus.AUTO_APPROVED, + Strings.CI.equals(seqUser, instance.getSubmitterId()) ? "审批人与提交人为同一人时, 自动同意跳过" : "审批人重复出现, 后续节点自动通过", firstTask.getId(), null, false, firstTask.getNodeRound()); + approvalTasks.add(firstTask); + } else { + // 找到未自动跳过的审批人, 跳出循环 + approvalTasks.add(firstTask); + break; + } + } + } + case ALL -> { + // 如果是会签, 需要发送所有审批人的待办 + for (int i = 0; i < approvers.size(); i++) { + String approver = approvers.get(i); + ApprovalTask approvalTask = buildTask(nodeId, instance.getId(), approver, taskType, userId, nextRound, i); + if (Strings.CI.equals(approver, instance.getSubmitterId()) || autoSkipUser.contains(approver)) { + // 触发了自动同意 + approvalTask.setAction(ApprovalAction.APPROVE.name()); + approvalTask.setStatus(ApprovalStatus.AUTO_APPROVED.name()); + approvalFlowService.saveAutoRecord(instance.getId(), nodeId, ApprovalStatus.AUTO_APPROVED, + Strings.CI.equals(approver, instance.getSubmitterId()) ? "审批人与提交人为同一人时, 自动同意跳过" : "审批人重复出现, 后续节点自动通过", approvalTask.getId(), null, false, approvalTask.getNodeRound()); + } + approvalTasks.add(approvalTask); + } + } + default -> { + // 或签流程执行的时候, 已经处理了提审人相同, 和自动跳过的逻辑 + for (int i = 0; i < approvers.size(); i++) { + String approver = approvers.get(i); + approvalTasks.add(buildTask(nodeId, instance.getId(), approver, taskType, userId, nextRound, i)); + } + } + } + } else { + // 并未配置自动跳过的逻辑, 正常逻辑 + if (multiMode == MultiApproverModeEnum.SEQUENTIAL) { + // 依次审批需要循环发送待办, 直到该待办审批人没有跳过 + for (int i = 0; i < approvers.size(); i++) { + String seqUser = approvers.get(i); + ApprovalTask firstTask = buildTask(nodeId, instance.getId(), seqUser, taskType, userId, nextRound, i); + if (autoSkipUser.contains(seqUser)) { + // 触发了自动同意, 跳过 + firstTask.setAction(ApprovalAction.APPROVE.name()); + firstTask.setStatus(ApprovalStatus.AUTO_APPROVED.name()); + approvalFlowService.saveAutoRecord(instance.getId(), nodeId, ApprovalStatus.AUTO_APPROVED, "审批人重复出现, 后续节点自动通过", firstTask.getId(), null, false, firstTask.getNodeRound()); + approvalTasks.add(firstTask); + } else { + // 找到未自动跳过的审批人, 跳出循环 + approvalTasks.add(firstTask); + break; + } + } + } else { + // 会签, 或签需要发送所有待办 + for (int i = 0; i < approvers.size(); i++) { + String approver = approvers.get(i); + ApprovalTask approvalTask = buildTask(nodeId, instance.getId(), approver, taskType, userId, nextRound, i); + if (autoSkipUser.contains(approver)) { + // 触发了自动同意 + approvalTask.setAction(ApprovalAction.APPROVE.name()); + approvalTask.setStatus(ApprovalStatus.AUTO_APPROVED.name()); + approvalFlowService.saveAutoRecord(instance.getId(), nodeId, ApprovalStatus.AUTO_APPROVED, "审批人重复出现, 后续节点自动通过", approvalTask.getId(), null, false, approvalTask.getNodeRound()); + } + approvalTasks.add(approvalTask); + } + } + } + return approvalTasks; + } + + /** + * 获取节点抄送任务 + * @param nodeId 节点ID + * @param ccList 抄送人集合 + * @param instanceId 审批实例ID + * @param userId 当前用户ID + * @return 节点抄送任务集合 + */ + public List getNodeCcTasks(String nodeId, List ccList, String instanceId, String userId) { + List approvalTasks = new ArrayList<>(); + if (CollectionUtils.isNotEmpty(ccList)) { + Integer nextRound = extApprovalInstanceMapper.getNextNodeRound(instanceId, nodeId); + for (int i = 0; i < ccList.size(); i++) { + String cc = ccList.get(i); + ApprovalTask approvalTask = buildTask(nodeId, instanceId, cc, ApprovalTaskType.CC.name(), userId, nextRound - 1, i); + approvalTasks.add(approvalTask); + } + } + return approvalTasks; + } + + /** + * 生成待办任务 + * @param nodeId 节点ID + * @param instanceId 实例ID + * @param approverId 审批人ID + * @param taskType 任务类型 + * @param currentUserId 当前用户ID + * @param round 任务轮次 + * @return 待办任务 + */ + public ApprovalTask buildTask(String nodeId, String instanceId, String approverId, String taskType, String currentUserId, Integer round, Integer approverPos) { + ApprovalTask approvalTask = new ApprovalTask(); + approvalTask.setId(IDGenerator.nextStr()); + approvalTask.setNodeId(nodeId); + approvalTask.setNodeRound(round); + approvalTask.setInstanceId(instanceId); + approvalTask.setApproverId(approverId); + approvalTask.setStatus(ApprovalStatus.APPROVING.name()); + approvalTask.setType(StringUtils.isBlank(taskType) ? ApprovalTaskType.NL.name() : taskType); + approvalTask.setCreateTime(System.currentTimeMillis() + approverPos); + approvalTask.setUpdateTime(System.currentTimeMillis() + approverPos); + approvalTask.setCreateUser(currentUserId); + approvalTask.setUpdateUser(currentUserId); + return approvalTask; + } + + /** + * 保存审批执行的日志和消息通知 + * @param instance 审批实例 + * @param userId 当前用户ID + * @param orgId 当前组织ID + */ + private void saveLogAndNotice(ApprovalInstance instance, String userId, String orgId, ApprovalAction action) { + // 日志 + ApprovalResourceService resourceService = CommonBeanFactory.getBean(ApprovalResourceService.class); + if (resourceService != null) { + String resourceName = resourceService.getInstanceResourceName(FormKey.ofKey(instance.getType()), instance.getResourceId()); + if (StringUtils.isBlank(resourceName)) { + return; + } + LogDTO logDTO = new LogDTO(orgId, instance.getResourceId(), userId, LogType.APPROVAL, getLogModuleOfFormKey(FormKey.ofKey(instance.getType())), + resourceService.getInstanceResourceName(FormKey.ofKey(instance.getType()), instance.getResourceId())); + logDTO.setDetail(Translator.get(StringUtils.lowerCase(action.name()))); + logService.add(logDTO); + sendFinishNotice(instance, resourceName, userId, orgId); + } + } + + /** + * 发送执行完成的通知 + * @param instance 审批实例 + * @param resourceName 资源名称 + * @param userId 用户ID + * @param orgId 组织ID + */ + public void sendFinishNotice(ApprovalInstance instance, String resourceName, String userId, String orgId) { + // 结束状态的数据发送消息通知 + if (StringUtils.isBlank(instance.getApprovalStatus()) || StringUtils.isBlank(instance.getSubmitterId()) || StringUtils.isBlank(resourceName)) { + return; + } + FormKey formKey = FormKey.ofKey(instance.getType()); + if (formKey == null) { + return; + } + ApprovalStatus approvalStatus = ApprovalStatus.valueOf(instance.getApprovalStatus()); + if (approvalStatus != ApprovalStatus.APPROVED && approvalStatus != ApprovalStatus.UNAPPROVED) { + return; + } + String state = approvalStatus == ApprovalStatus.APPROVED + ? Translator.get("contract.approval_status.approved") + : Translator.get("contract.approval_status.unapproved"); + String module; + String event; + switch (formKey) { + case QUOTATION -> { + module = NotificationConstants.Module.OPPORTUNITY; + event = NotificationConstants.Event.BUSINESS_QUOTATION_APPROVAL; + } + case CONTRACT -> { + module = NotificationConstants.Module.CONTRACT; + event = NotificationConstants.Event.CONTRACT_APPROVAL; + } + case ORDER -> { + module = NotificationConstants.Module.ORDER; + event = NotificationConstants.Event.ORDER_APPROVAL; + } + case INVOICE -> { + module = NotificationConstants.Module.CONTRACT; + event = NotificationConstants.Event.INVOICE_APPROVAL; + } + default -> { + return; + } + } + + commonNoticeSendService.sendNotice( + module, + event, + Map.of("name", StringUtils.defaultString(resourceName), "state", state), + userId, + orgId, + List.of(instance.getSubmitterId()), + true + ); + } + + /** + * 表单类型 => 日志模块 + * @param formKey 表单Key + * @return 日志模块 + */ + private String getLogModuleOfFormKey(FormKey formKey) { + if (formKey == null) { + return null; + } + switch (formKey) { + case QUOTATION -> { + return LogModule.OPPORTUNITY_QUOTATION; + } + case CONTRACT -> { + return LogModule.CONTRACT_INDEX; + } + case INVOICE -> { + return LogModule.CONTRACT_INVOICE; + } + case ORDER -> { + return LogModule.ORDER_INDEX; + } + default -> { + return null; + } + } + } + + private void sendApprovalTaskNotice(List tasks, ApprovalInstance instance, String currentOrgId) { + // 发送待办消息通知 + ApprovalResourceService resourceService = CommonBeanFactory.getBean(ApprovalResourceService.class); + if (resourceService != null) { + List approvers = tasks.stream().map(ApprovalTask::getApproverId).toList(); + Map paramMap = new HashMap<>(2); + paramMap.put("type", Translator.get(instance.getType(), Locale.SIMPLIFIED_CHINESE)); + paramMap.put("name", resourceService.getInstanceResourceName(FormKey.ofKey(instance.getType()), instance.getResourceId())); + commonNoticeSendService.sendNotice(NotificationConstants.Module.APPROVAL, NotificationConstants.Event.APPROVAL_TODO, paramMap, instance.getSubmitterId(), currentOrgId, approvers, true); + } + } + + /** + * 刷新用户禁用/删除时的审批待办任务 + *

+ * 当用户被禁用或删除时,将该用户作为审批人的待办任务转移给上级,如果上级不存在则转移给admin + * + * @param userIds 被禁用/删除的用户ID 集合 + * @param organizationId 组织ID + */ + public void refreshApprovingTasksForDisabledUser(List userIds, String organizationId) { + if (CollectionUtils.isEmpty(userIds) || StringUtils.isBlank(organizationId)) { + return; + } + + try { + // 查询该用户作为审批人且状态为待处理的待办任务 + LambdaQueryWrapper taskWrapper = new LambdaQueryWrapper<>(); + taskWrapper.in(ApprovalTask::getApproverId, userIds) + .eq(ApprovalTask::getStatus, ApprovalStatus.APPROVING.name()); + List approvingTasks = approvalTaskMapper.selectListByLambda(taskWrapper); + if (CollectionUtils.isEmpty(approvingTasks)) { + return; + } + + Map> userTaskMaps = approvingTasks.stream().collect(Collectors.groupingBy(ApprovalTask::getApproverId)); + SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); + ExtApprovalTaskMapper mapper = sqlSession.getMapper(ExtApprovalTaskMapper.class); + + + // 批量更新待办任务的审批人 + userIds.forEach(userId ->{ + if (userTaskMaps.containsKey(userId)) { + String targetApprover = getTargetApproverId(userId, organizationId); + List userTasks = userTaskMaps.get(userId); + for (ApprovalTask userTask : userTasks) { + userTask.setApproverId(targetApprover); + userTask.setUpdateTime(System.currentTimeMillis()); + mapper.updateTaskById(userTask); + } + } + }); + sqlSession.flushStatements(); + SqlSessionUtils.closeSqlSession(sqlSession, sqlSessionFactory); + } catch (Exception e) { + log.error("转移待办任务失败, error:{}", e.getMessage(), e); + } + } + + /** + * 获取目标审批人ID + * 优先使用用户的上级,如果上级不存在或不可用,则使用admin用户 + * + * @param userId 用户ID + * @param organizationId 组织ID + * @return 目标审批人ID + */ + private String getTargetApproverId(String userId, String organizationId) { + // 尝试获取用户的上级 + OrganizationUser criteria = new OrganizationUser(); + criteria.setUserId(userId); + criteria.setOrganizationId(organizationId); + OrganizationUser orgUser = organizationUserMapper.selectOne(criteria); + + if (orgUser != null && StringUtils.isNotBlank(orgUser.getSupervisorId())) { + return orgUser.getSupervisorId(); + } + return InternalUser.ADMIN.getValue(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalFlowLogService.java b/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalFlowLogService.java new file mode 100644 index 0000000..f892615 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalFlowLogService.java @@ -0,0 +1,818 @@ +package cn.cordys.crm.approval.service; + +import cn.cordys.common.dto.EnableFieldValue; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.JsonDifferenceUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.approval.constants.ApproverTypeEnum; +import cn.cordys.crm.approval.dto.ApprovalPostConfigDTO; +import cn.cordys.crm.approval.dto.WebHookConfig; +import cn.cordys.crm.approval.dto.response.ApprovalFlowDetailResponse; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.mapper.ExtRoleMapper; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.crm.system.service.BaseModuleLogService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class ApprovalFlowLogService extends BaseModuleLogService { + + private static final String NODES_COLUMN = "nodes"; + private static final String NODE_COLUMN_PREFIX = "流程设置"; + private static final String NULL_STRING = "null"; + + private ApprovalFlowDetailResponse oldApprovalFlowDetail; + private List fields; + + @Resource + private ExtUserMapper extUserMapper; + @Resource + private ExtRoleMapper extRoleMapper; + @Resource + private ModuleFormCacheService moduleFormCacheService; + + @Override + public List handleLogField(List differences, String orgId) { + + JsonDifferenceDTO nodesDiff = getNodesJsonDifferenceDTO(differences, NODES_COLUMN); + + differences.removeIf(differ -> Strings.CS.equalsAny(differ.getColumn(), "currentVersionId", "links", "nodes", "optionMap", "createUserName", "updateUserName")); + + Map> handlers = Map.ofEntries( + Map.entry("formType", this::handleFormType), + Map.entry("enable", this::handleEnable), + Map.entry("createExecute", this::handleBooleanValue), + Map.entry("updateExecute", this::handleBooleanValue), + Map.entry("submitterCanRevoke", this::handleBooleanValue), + Map.entry("allowBatchProcess", this::handleBooleanValue), + Map.entry("allowWithdraw", this::handleBooleanValue), + Map.entry("allowAddSign", this::handleBooleanValue), + Map.entry("requireComment", this::handleBooleanValue), + Map.entry("duplicateApproverRule", this::handleDuplicateApproverRule), + Map.entry("description", this::handleDescription), + Map.entry("statusPermissions", this::handleStatusPermissions) + ); + + differences.forEach(differ -> { + Consumer handler = handlers.get(differ.getColumn()); + if (handler != null) { + handler.accept(differ); + } else { + translatorDifferInfo(differ); + } + }); + + if (isValidValue(oldValue)) { + oldApprovalFlowDetail = JSON.parseObject(oldValue, ApprovalFlowDetailResponse.class); + } + + // 处理 nodes 字段的差异 + handleNodesDifference(differences, nodesDiff, orgId); + + return differences; + } + + /** + * 处理 nodes 字段的差异 + * 按 number 分组比较节点属性,生成格式为 "流程设置-{number}-{属性名}" 的差异记录 + */ + private void handleNodesDifference(List differences, JsonDifferenceDTO nodesDiff, String orgId) { + if (nodesDiff == null) return; + try { + ObjectMapper mapper = new ObjectMapper(); + JsonNode oldNodes = nodesDiff.getOldValue() != null + ? mapper.valueToTree(nodesDiff.getOldValue()) + : mapper.createArrayNode(); + JsonNode newNodes = nodesDiff.getNewValue() != null + ? mapper.valueToTree(nodesDiff.getNewValue()) + : mapper.createArrayNode(); + + fields = moduleFormCacheService.getBusinessFormConfig(oldApprovalFlowDetail.getFormType(), orgId).getFields(); + + // 按 number 分组 + Map oldNodeMap = buildNodeMapByNumber(oldNodes); + Map newNodeMap = buildNodeMapByNumber(newNodes); + + // 找出所有涉及的 number + Set allNumbers = new LinkedHashSet<>(); + allNumbers.addAll(oldNodeMap.keySet()); + allNumbers.addAll(newNodeMap.keySet()); + + List nodeDifferences = new ArrayList<>(); + + for (String number : allNumbers) { + JsonNode oldNode = oldNodeMap.get(number); + JsonNode newNode = newNodeMap.get(number); + + if (oldNode == null && newNode != null) { + // 新增节点 + handleAddedNode(number, newNode, nodeDifferences); + } else if (oldNode != null && newNode == null) { + // 删除节点 + handleRemovedNode(number, oldNode, nodeDifferences); + } else if (oldNode != null) { + // 修改节点,比较属性 + compareNodeProperties(number, oldNode, newNode, nodeDifferences); + } + } + + differences.addAll(nodeDifferences); + } catch (Exception e) { + // 解析失败时忽略 + } + } + + private JsonDifferenceDTO getNodesJsonDifferenceDTO(List differences, String nodeName) { + Optional nodesDiffOpt = differences.stream() + .filter(differ -> Strings.CS.equals(differ.getColumn(), nodeName)) + .findFirst(); + + return nodesDiffOpt.orElse(null); + } + + /** + * 按 number 构建节点映射 + */ + private Map buildNodeMapByNumber(JsonNode nodes) { + Map nodeMap = new LinkedHashMap<>(); + if (nodes == null || !nodes.isArray()) { + return nodeMap; + } + for (JsonNode node : nodes) { + JsonNode numberNode = node.get("number"); + if (numberNode != null && StringUtils.isNotBlank(numberNode.asText())) { + nodeMap.put(numberNode.asText(), node); + } + } + return nodeMap; + } + + /** + * 处理新增节点 + */ + private void handleAddedNode(String number, JsonNode newNode, List differences) { + String nodeName = getNodeName(newNode); + JsonDifferenceDTO diff = new JsonDifferenceDTO(); + diff.setColumnName(buildNodeColumnName(number, Translator.get("log.nodeName"))); + diff.setNewValue(nodeName); + diff.setNewValueName(nodeName); + diff.setType("add"); + differences.add(diff); + } + + /** + * 处理删除节点 + */ + private void handleRemovedNode(String number, JsonNode oldNode, List differences) { + String nodeName = getNodeName(oldNode); + + JsonDifferenceDTO diff = new JsonDifferenceDTO(); + diff.setColumnName(buildNodeColumnName(number, Translator.get("log.nodeName"))); + diff.setOldValue(nodeName); + diff.setOldValueName(nodeName); + diff.setType("removed"); + differences.add(diff); + } + + /** + * 比较节点属性 + */ + private void compareNodeProperties(String number, JsonNode oldNode, JsonNode newNode, List differences) { + // 需要比较的属性及其翻译 key + Map propertyNames = new LinkedHashMap<>(); + propertyNames.put("name", "log.nodeName"); + propertyNames.put("nodeType", "log.nodeType"); + propertyNames.put("approvalType", "log.approvalType"); + propertyNames.put("approverType", "log.approverType"); + propertyNames.put("approverList", "log.approverList"); + propertyNames.put("multiApproverMode", "log.multiApproverMode"); + propertyNames.put("emptyApproverAction", "log.emptyApproverAction"); + propertyNames.put("fallbackApprover", "log.fallbackApprover"); + propertyNames.put("sameSubmitterAction", "log.sameSubmitterAction"); + propertyNames.put("ccType", "log.ccType"); + propertyNames.put("ccList", "log.ccList"); + propertyNames.put("passPostConfig", "log.passPostConfig"); + propertyNames.put("rejectPostConfig", "log.rejectPostConfig"); + propertyNames.put("fieldPermissions", "log.fieldPermissions"); + propertyNames.put("conditionConfig", "log.conditionConfig"); + + Set comparedFields = new HashSet<>(); + + // 比较旧节点中的属性 + Iterator oldFields = oldNode.fieldNames(); + while (oldFields.hasNext()) { + String field = oldFields.next(); + if (!propertyNames.containsKey(field) || comparedFields.contains(field)) { + continue; + } + comparedFields.add(field); + + JsonNode oldValue = oldNode.get(field); + JsonNode newValue = newNode.get(field); + + if (!newNode.has(field)) { + // 属性被删除 + JsonDifferenceDTO diff = new JsonDifferenceDTO(); + diff.setColumnName(buildNodeColumnName(number, Translator.get(propertyNames.get(field)))); + diff.setOldValue(JsonDifferenceUtils.getValue(oldValue)); + diff.setType("removed"); + setNodePropertyValue(diff, field, oldNode, null, true, false); + differences.add(diff); + } else if (!isNodeEquals(oldValue, newValue)) { + // 属性被修改 + JsonDifferenceDTO diff = new JsonDifferenceDTO(); + diff.setColumnName(buildNodeColumnName(number, Translator.get(propertyNames.get(field)))); + diff.setOldValue(JsonDifferenceUtils.getValue(oldValue)); + diff.setNewValue(JsonDifferenceUtils.getValue(newValue)); + diff.setType("modified"); + setNodePropertyValue(diff, field, oldNode, newNode, true, true); + differences.add(diff); + } + } + + // 检查新增的属性 + Iterator newFields = newNode.fieldNames(); + while (newFields.hasNext()) { + String field = newFields.next(); + if (!propertyNames.containsKey(field) || comparedFields.contains(field)) { + continue; + } + JsonNode newValue = newNode.get(field); + JsonDifferenceDTO diff = new JsonDifferenceDTO(); + diff.setColumnName(buildNodeColumnName(number, Translator.get(propertyNames.get(field)))); + diff.setNewValue(JsonDifferenceUtils.getValue(newValue)); + diff.setType("add"); + setNodePropertyValue(diff, field, null, newNode, false, true); + differences.add(diff); + } + } + + /** + * 构建节点列名 + */ + private String buildNodeColumnName(String number, String propertyName) { + return NODE_COLUMN_PREFIX + "-" + number + "-" + propertyName; + } + + /** + * 获取节点名称 + */ + private String getNodeName(JsonNode node) { + JsonNode nameNode = node.get("name"); + return nameNode != null ? nameNode.asText() : ""; + } + + /** + * 判断两个 JsonNode 是否相等 + */ + private boolean isNodeEquals(JsonNode oldValue, JsonNode newValue) { + if (oldValue == null && newValue == null) { + return true; + } + if (oldValue == null || newValue == null) { + return false; + } + if (oldValue.isNumber() && newValue.isNumber()) { + return oldValue.asDouble() == newValue.asDouble(); + } + return oldValue.equals(newValue); + } + + /** + * 设置节点属性值的显示名称 + * + * @param diff 差异对象 + * @param field 字段名 + * @param oldNode 旧节点数据(用于获取类型) + * @param newNode 新节点数据(用于获取类型) + * @param hasOldValue 是否有旧值 + * @param hasNewValue 是否有新值 + */ + private void setNodePropertyValue(JsonDifferenceDTO diff, String field, JsonNode oldNode, JsonNode newNode, boolean hasOldValue, boolean hasNewValue) { + switch (field) { + case "approvalType": + if (hasOldValue && isValidValue(diff.getOldValue())) { + diff.setOldValueName(Translator.get("approval_flow.approval_type." + diff.getOldValue().toString().toLowerCase())); + } + if (hasNewValue && isValidValue(diff.getNewValue())) { + diff.setNewValueName(Translator.get("approval_flow.approval_type." + diff.getNewValue().toString().toLowerCase())); + } + break; + case "approverType": + case "ccType": + if (hasOldValue && isValidValue(diff.getOldValue())) { + diff.setOldValueName(Translator.get("approval_flow.approver_type." + diff.getOldValue().toString().toLowerCase())); + } + if (hasNewValue && isValidValue(diff.getNewValue())) { + diff.setNewValueName(Translator.get("approval_flow.approver_type." + diff.getNewValue().toString().toLowerCase())); + } + break; + case "multiApproverMode": + if (hasOldValue && isValidValue(diff.getOldValue())) { + diff.setOldValueName(Translator.get("approval_flow.multi_approver_mode." + diff.getOldValue().toString().toLowerCase())); + } + if (hasNewValue && isValidValue(diff.getNewValue())) { + diff.setNewValueName(Translator.get("approval_flow.multi_approver_mode." + diff.getNewValue().toString().toLowerCase())); + } + break; + case "emptyApproverAction": + if (hasOldValue && isValidValue(diff.getOldValue())) { + diff.setOldValueName(Translator.get("approval_flow.empty_approver_action." + diff.getOldValue().toString().toLowerCase())); + } + if (hasNewValue && isValidValue(diff.getNewValue())) { + diff.setNewValueName(Translator.get("approval_flow.empty_approver_action." + diff.getNewValue().toString().toLowerCase())); + } + break; + case "sameSubmitterAction": + if (hasOldValue && isValidValue(diff.getOldValue())) { + diff.setOldValueName(Translator.get("approval_flow.same_submitter_action." + diff.getOldValue().toString().toLowerCase())); + } + if (hasNewValue && isValidValue(diff.getNewValue())) { + diff.setNewValueName(Translator.get("approval_flow.same_submitter_action." + diff.getNewValue().toString().toLowerCase())); + } + break; + case "nodeType": + if (hasOldValue && isValidValue(diff.getOldValue())) { + diff.setOldValueName(Translator.get("approval_flow.node_type." + diff.getOldValue().toString().toLowerCase())); + } + if (hasNewValue && isValidValue(diff.getNewValue())) { + diff.setNewValueName(Translator.get("approval_flow.node_type." + diff.getNewValue().toString().toLowerCase())); + } + break; + case "approverList": + if (hasOldValue && oldNode != null) { + String oldApproverType = getNodeFieldValue(oldNode, "approverType"); + diff.setOldValueName(translateApproverList(diff.getOldValue(), oldApproverType)); + } + if (hasNewValue && newNode != null) { + String newApproverType = getNodeFieldValue(newNode, "approverType"); + diff.setNewValueName(translateApproverList(diff.getNewValue(), newApproverType)); + } + break; + case "ccList": + if (hasOldValue && oldNode != null) { + String oldCcType = getNodeFieldValue(oldNode, "ccType"); + diff.setOldValueName(translateApproverList(diff.getOldValue(), oldCcType)); + } + if (hasNewValue && newNode != null) { + String newCcType = getNodeFieldValue(newNode, "ccType"); + diff.setNewValueName(translateApproverList(diff.getNewValue(), newCcType)); + } + break; + case "fallbackApprover": + if (hasOldValue && isValidValue(diff.getOldValue())) { + diff.setOldValueName(translateMemberList(diff.getOldValue())); + } + if (hasNewValue && isValidValue(diff.getNewValue())) { + diff.setNewValueName(translateMemberList(diff.getNewValue())); + } + break; + case "passPostConfig": + if (hasOldValue && isValidValue(diff.getOldValue())) { + diff.setOldValueName(translatePostConfig(diff.getOldValue())); + } + if (hasNewValue && isValidValue(diff.getNewValue())) { + diff.setNewValueName(translatePostConfig(diff.getNewValue())); + } + break; + case "rejectPostConfig": + if (hasOldValue && isValidValue(diff.getOldValue())) { + diff.setOldValueName(translatePostConfig(diff.getOldValue())); + } + if (hasNewValue && isValidValue(diff.getNewValue())) { + diff.setNewValueName(translatePostConfig(diff.getNewValue())); + } + break; + case "fieldPermissions": + if (hasOldValue && isValidValue(diff.getOldValue())) { + diff.setOldValueName(translateFieldPermissions(diff.getOldValue())); + } + if (hasNewValue && isValidValue(diff.getNewValue())) { + diff.setNewValueName(translateFieldPermissions(diff.getNewValue())); + } + break; + case "conditionConfig": + if (hasOldValue && isValidValue(diff.getOldValue())) { + diff.setOldValueName(JSON.toJSONString(diff.getOldValue())); + } + if (hasNewValue && isValidValue(diff.getNewValue())) { + diff.setNewValueName(JSON.toJSONString(diff.getNewValue())); + } + break; + default: + if (hasOldValue) { + diff.setOldValueName(diff.getOldValue()); + } + if (hasNewValue) { + diff.setNewValueName(diff.getNewValue()); + } + } + } + + /** + * 获取节点字段值 + */ + private String getNodeFieldValue(JsonNode node, String field) { + if (node == null) { + return null; + } + JsonNode fieldNode = node.get(field); + return fieldNode != null ? fieldNode.asText() : null; + } + + /** + * 判断值是否有效(不为 null 且不为 "null" 字符串) + */ + private boolean isValidValue(Object value) { + return value != null && !NULL_STRING.equals(value.toString()); + } + + /** + * 翻译审批人/抄送人列表 + * 根据类型将列表值翻译为可读文本 + */ + private String translateApproverList(Object value, String approverType) { + if (value == null || !isValidValue(approverType)) { + if (value instanceof List) { + return null; + } + return value != null ? value.toString() : ""; + } + + // 如果是层级类型(上级、部门负责人),翻译层级数字 + if (ApproverTypeEnum.SUPERIOR.name().equalsIgnoreCase(approverType) + || ApproverTypeEnum.MULTIPLE_SUPERIOR.name().equalsIgnoreCase(approverType)) { + return translateLevelList(value, "approval_flow.level.supervisor."); + } + if (ApproverTypeEnum.DEPT_HEAD.name().equalsIgnoreCase(approverType) + || ApproverTypeEnum.MULTIPLE_DEPT_HEAD.name().equalsIgnoreCase(approverType)) { + return translateLevelList(value, "approval_flow.level.department."); + } + + // 成员类型,查询用户名称 + if (ApproverTypeEnum.MEMBER.name().equalsIgnoreCase(approverType)) { + return translateMemberList(value); + } + + // 角色类型,查询角色名称 + if (ApproverTypeEnum.ROLE.name().equalsIgnoreCase(approverType)) { + return translateRoleList(value); + } + + if (value instanceof List) { + return null; + } + + // 其他类型,直接返回原值 + return value.toString(); + } + + /** + * 翻译成员列表(ID -> 名称) + * 支持单个用户ID字符串或用户ID列表 + */ + private String translateMemberList(Object value) { + List userIds = parseStringList(value); + // 如果不是列表格式,尝试作为单个ID处理 + if (userIds.isEmpty() && value != null && !NULL_STRING.equals(value.toString())) { + String singleId = value.toString(); + if (StringUtils.isNotBlank(singleId)) { + userIds = List.of(singleId); + } + } + if (userIds.isEmpty()) { + return ""; + } + List userOptions = extUserMapper.selectUserOptionByIds(userIds); + if (userOptions.isEmpty()) { + return value.toString(); + } + // 按 userIds 顺序返回名称 + Map nameMap = userOptions.stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName, (a, b) -> a)); + return userIds.stream() + .map(nameMap::get) + .filter(Objects::nonNull) + .collect(Collectors.joining(", ")); + } + + /** + * 翻译角色列表(ID -> 名称) + * 支持单个角色ID字符串或角色ID列表 + */ + private String translateRoleList(Object value) { + List roleIds = parseStringList(value); + // 如果不是列表格式,尝试作为单个ID处理 + if (roleIds.isEmpty() && value != null && !NULL_STRING.equals(value.toString())) { + String singleId = value.toString(); + if (StringUtils.isNotBlank(singleId)) { + roleIds = List.of(singleId); + } + } + if (roleIds.isEmpty()) { + return ""; + } + List roleOptions = extRoleMapper.getIdNameByIds(roleIds); + if (roleOptions.isEmpty()) { + return value.toString(); + } + // 按 roleIds 顺序返回名称 + Map nameMap = roleOptions.stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName, (a, b) -> a)); + return roleIds.stream() + .map(nameMap::get) + .filter(Objects::nonNull) + .collect(Collectors.joining(", ")); + } + + /** + * 解析字符串列表 + */ + private List parseStringList(Object value) { + if (value == null) { + return List.of(); + } + try { + if (value instanceof List list) { + return list.stream() + .filter(Objects::nonNull) + .map(Object::toString) + .filter(s -> !NULL_STRING.equals(s)) + .collect(Collectors.toList()); + } + String strValue = value.toString(); + if (strValue.startsWith("[")) { + List list = JSON.parseArray(strValue, Object.class); + return list.stream() + .filter(Objects::nonNull) + .map(Object::toString) + .filter(s -> !NULL_STRING.equals(s)) + .collect(Collectors.toList()); + } + } catch (Exception ignored) { + // 解析失败时返回空列表 + } + return List.of(); + } + + /** + * 翻译层级列表 + */ + private String translateLevelList(Object value, String translationPrefix) { + if (value == null) { + return ""; + } + // 尝试解析为列表 + try { + if (value instanceof List list) { + List translated = new ArrayList<>(); + for (Object item : list) { + String levelKey = translationPrefix + item.toString(); + translated.add(Translator.get(levelKey)); + } + return String.join(", ", translated); + } + // 如果是 JSON 字符串 + String strValue = value.toString(); + if (strValue.startsWith("[")) { + List list = JSON.parseArray(strValue, Object.class); + List translated = new ArrayList<>(); + for (Object item : list) { + String levelKey = translationPrefix + item.toString(); + translated.add(Translator.get(levelKey)); + } + return String.join(", ", translated); + } + } catch (Exception ignored) { + // 解析失败时返回原值 + } + return value.toString(); + } + + private void handleFormType(JsonDifferenceDTO differ) { + differ.setColumnName(Translator.get("log.formType")); + if (isValidValue(differ.getOldValue())) { + differ.setOldValueName(Translator.get("approval_flow.form_type." + differ.getOldValue().toString().toLowerCase())); + } + if (isValidValue(differ.getNewValue())) { + differ.setNewValueName(Translator.get("approval_flow.form_type." + differ.getNewValue().toString().toLowerCase())); + } + } + + private void handleEnable(JsonDifferenceDTO differ) { + differ.setColumnName(Translator.get("log.enable")); + if (isValidValue(differ.getOldValue())) { + differ.setOldValueName(Translator.get("approval_flow.enable." + (Boolean.parseBoolean(differ.getOldValue().toString()) ? "true" : "false"))); + } + if (isValidValue(differ.getNewValue())) { + differ.setNewValueName(Translator.get("approval_flow.enable." + (Boolean.parseBoolean(differ.getNewValue().toString()) ? "true" : "false"))); + } + } + + private void handleBooleanValue(JsonDifferenceDTO differ) { + differ.setColumnName(Translator.get("log." + differ.getColumn())); + if (isValidValue(differ.getOldValue())) { + differ.setOldValueName(Translator.get("log.enable." + (Boolean.parseBoolean(differ.getOldValue().toString()) ? "true" : "false"))); + } + if (isValidValue(differ.getNewValue())) { + differ.setNewValueName(Translator.get("log.enable." + (Boolean.parseBoolean(differ.getNewValue().toString()) ? "true" : "false"))); + } + } + + private void handleDuplicateApproverRule(JsonDifferenceDTO differ) { + differ.setColumnName(Translator.get("log.duplicateApproverRule")); + if (isValidValue(differ.getOldValue())) { + differ.setOldValueName(Translator.get("approval_flow.duplicate_approver_rule." + differ.getOldValue().toString().toLowerCase())); + } + if (isValidValue(differ.getNewValue())) { + differ.setNewValueName(Translator.get("approval_flow.duplicate_approver_rule." + differ.getNewValue().toString().toLowerCase())); + } + } + + private void handleStatusPermissions(JsonDifferenceDTO differ) { + differ.setColumnName(Translator.get("log.statusPermissions")); + if (isValidValue(differ.getOldValue())) { + differ.setOldValueName(translateStatusPermissions(differ.getOldValue())); + } + if (isValidValue(differ.getNewValue())) { + differ.setNewValueName(translateStatusPermissions(differ.getNewValue())); + } + } + + /** + * 翻译状态权限配置 + * 将 [{"approvalStatus":"REVOKED","permission":"ORDER:READ","enabled":true}] + * 翻译为换行分隔的字符串 + */ + @SuppressWarnings("unchecked") + private String translateStatusPermissions(Object value) { + if (value == null) { + return ""; + } + try { + List list; + if (value instanceof List l) { + list = l; + } else { + String strValue = value.toString(); + list = JSON.parseArray(strValue, Object.class); + } + + List results = new ArrayList<>(); + for (Object item : list) { + if (item instanceof Map map) { + String enabled = map.get("enabled") != null ? map.get("enabled").toString() : ""; + if (Strings.CI.equals(enabled, "true")) { + String status = map.get("approvalStatus") != null ? map.get("approvalStatus").toString() : ""; + String permission = map.get("permission") != null ? map.get("permission").toString() : ""; + String translatedStatus = translateApprovalStatus(status); + String translatedPermission = translatePermission(permission); + results.add(translatedStatus + "-" + translatedPermission); + } + } + } + + return String.join("\n", results); + } catch (Exception e) { + return value.toString(); + } + } + + /** + * 翻译审批状态 + */ + private String translateApprovalStatus(String status) { + if (StringUtils.isBlank(status)) { + return ""; + } + return Translator.get("log.approvalStatus." + status, status); + } + + /** + * 翻译权限字符串 + * 将 "ORDER:READ" 翻译为 "订单:查看" + */ + private String translatePermission(String permission) { + if (StringUtils.isBlank(permission)) { + return ""; + } + String[] parts = permission.split(":"); + if (parts.length >= 2) { + String moduleKey = "permission." + parts[0].toLowerCase() + ".name"; + String permissionKey = "permission." + parts[1].toLowerCase(); + return Translator.get(moduleKey, parts[0]) + ":" + Translator.get(permissionKey, parts[1]); + } + return Translator.get("permission." + permission.toLowerCase(), permission); + } + + private void handleDescription(JsonDifferenceDTO differ) { + differ.setColumnName(Translator.get("approval_flow.log.description")); + differ.setOldValueName(differ.getOldValue()); + differ.setNewValueName(differ.getNewValue()); + } + + /** + * 翻译审批通过/驳回后配置 + * 将 fieldUpdateConfigs 翻译为 "字段名: 值 (启用/禁用)" 格式 + */ + private String translatePostConfig(Object value) { + if (value == null) return ""; + try { + String jsonStr = value instanceof String ? (String) value : JSON.toJSONString(value); + ApprovalPostConfigDTO postConfig = JSON.parseObject(jsonStr, ApprovalPostConfigDTO.class); + + if (postConfig == null) { + return ""; + } + + Map fieldMap = fields != null + ? fields.stream().collect(Collectors.toMap(BaseField::getId, f -> f, (a, b) -> a)) + : Map.of(); + + List parts = new ArrayList<>(); + for (EnableFieldValue config : postConfig.getFieldUpdateConfigs()) { + if (config.getFieldId() == null) continue; + + BaseField field = fieldMap.get(config.getFieldId()); + String fieldName = field != null ? field.getName() : config.getFieldId(); + + Object translatedValue = config.getFieldValue(); + if (field != null && config.getFieldValue() != null) { + translatedValue = transformFieldValue(field, config.getFieldValue()); + } + + String enableStr = BooleanUtils.isTrue(config.getEnable()) + ? Translator.get("log.enable.true") + : Translator.get("log.enable.false"); + parts.add(fieldName + ": " + translatedValue + " (" + enableStr + ")"); + } + if (postConfig.getWebHookConfig() != null) { + WebHookConfig webHookConfig = postConfig.getWebHookConfig(); + parts.add(Translator.get("webhook.enable") + ": " + (BooleanUtils.isTrue(webHookConfig.getWebHookEnable()) ? Translator.get("log.enable.true") : Translator.get("log.enable.false"))); + parts.add(Translator.get("webhook.describe") + ": " + webHookConfig.getWebHookDescribe()); + parts.add(Translator.get("webhook.url") + ": " + webHookConfig.getWebHookUrl()); + parts.add(Translator.get("webhook.method") + ": " + webHookConfig.getWebHookMethod()); + parts.add(Translator.get("webhook.header") + ": " + webHookConfig.getWebHookHeader()); + parts.add(Translator.get("webhook.body") + ": " + webHookConfig.getWebHookBody()); + } + + return String.join("\n", parts); + } catch (Exception e) { + return value.toString(); + } + } + + /** + * 翻译字段权限配置 + * 将 FieldPermissionDTO 列表翻译为 "字段名: 权限类型" 格式 + */ + private String translateFieldPermissions(Object value) { + if (value == null) return ""; + try { + List list; + if (value instanceof List l) { + list = l; + } else { + String strValue = value.toString(); + list = JSON.parseArray(strValue, Object.class); + } + + Map fieldMap = fields != null + ? fields.stream().collect(Collectors.toMap(BaseField::getId, f -> f, (a, b) -> a)) + : Map.of(); + + List parts = new ArrayList<>(); + for (Object item : list) { + if (item instanceof Map map) { + String fieldId = map.get("fieldId") != null ? map.get("fieldId").toString() : ""; + String permissionType = map.get("permissionType") != null ? map.get("permissionType").toString() : ""; + + BaseField field = fieldMap.get(fieldId); + String fieldName = field != null ? field.getName() : fieldId; + String translatedPermission = Translator.get("approval_flow.field_permission." + permissionType.toLowerCase(), permissionType); + + parts.add(fieldName + ": " + translatedPermission); + } + } + + return String.join("\n", parts); + } catch (Exception e) { + return value.toString(); + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalFlowService.java b/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalFlowService.java new file mode 100644 index 0000000..7b65ba8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalFlowService.java @@ -0,0 +1,2232 @@ +package cn.cordys.crm.approval.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.condition.CombineSearch; +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.permission.Permission; +import cn.cordys.common.permission.PermissionDefinitionItem; +import cn.cordys.common.permission.PermissionUtils; +import cn.cordys.common.response.result.CrmHttpResultCode; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.approval.constants.*; +import cn.cordys.crm.approval.domain.*; +import cn.cordys.crm.approval.dto.ApprovalPostConfigDTO; +import cn.cordys.crm.approval.dto.FieldPermissionDTO; +import cn.cordys.crm.approval.dto.StatusPermissionDTO; +import cn.cordys.crm.approval.dto.WebHookConfig; +import cn.cordys.crm.approval.dto.request.*; +import cn.cordys.crm.approval.dto.response.*; +import cn.cordys.crm.approval.mapper.ExtApprovalFlowMapper; +import cn.cordys.crm.approval.mapper.ExtApprovalInstanceMapper; +import cn.cordys.crm.system.domain.Department; +import cn.cordys.crm.system.domain.OrganizationUser; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.mapper.ExtDepartmentCommanderMapper; +import cn.cordys.crm.system.mapper.ExtRoleMapper; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.crm.system.mapper.ExtUserRoleMapper; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.crm.system.service.RoleService; +import cn.cordys.crm.system.service.UserViewService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class ApprovalFlowService { + + @Resource + private BaseMapper approvalFlowMapper; + @Resource + private ExtApprovalFlowMapper extApprovalFlowMapper; + @Resource + private BaseMapper approvalFlowVersionMapper; + @Resource + private BaseMapper approvalNodeMapper; + @Resource + private BaseMapper approvalNodeApproverMapper; + @Resource + private BaseMapper approvalNodeConditionMapper; + @Resource + private BaseMapper approvalNodeLinkMapper; + @Resource + private RoleService roleService; + @Resource + private BaseService baseService; + @Resource + private BaseMapper departmentBaseMapper; + @Resource + private StringRedisTemplate stringRedisTemplate; + @Resource + private BaseMapper organizationUserMapper; + @Resource + private ExtUserRoleMapper extUserRoleMapper; + @Resource + private ExtDepartmentCommanderMapper extDepartmentCommanderMapper; + @Resource + private ExtUserMapper extUserMapper; + @Resource + private ExtRoleMapper extRoleMapper; + @Resource + private BaseMapper approvalRecordMapper; + @Resource + private BaseMapper approvalTaskMapper; + @Resource + private ModuleFormService formService; + @Resource + private UserViewService userViewService; + @Resource + private ExtApprovalInstanceMapper extApprovalInstanceMapper; + @Resource + private ApprovalInstanceService approvalInstanceService; + + /** + * 加签节点后缀分隔符 + */ + public static final String SIGN_SPILT = "-SN"; + /** + * 范围大小 + */ + public static final int RANGE_SIZE = 2; + /** + * 审批流导出权限 + */ + public static final String EXPORT_SUFFIX = "EXPORT"; + + /** + * 根据表单类型获取审批流状态权限配置 + */ + public StatusPermissionSettingResponse getStatusPermissionsByFormType(String formType, String organizationId) { + StatusPermissionSettingResponse response = new StatusPermissionSettingResponse(); + + // 获取权限列表 + List permissions = getResourcePermissions(getPermissionsByFormType(formType)); + response.setPermissions(permissions); + + // 查询该表单类型对应的审批流 + ApprovalFlow criteria = new ApprovalFlow(); + criteria.setFormType(formType); + criteria.setEnable(true); + criteria.setDeleted(false); + criteria.setOrganizationId(organizationId); + List flows = approvalFlowMapper.select(criteria); + + if (CollectionUtils.isEmpty(flows)) { + response.setStatusPermissions(List.of()); + return response; + } + + // 优先使用启用的审批流 + ApprovalFlow targetFlow = flows.stream() + .filter(ApprovalFlow::getEnable) + .findFirst() + .orElse(flows.getFirst()); + + // 从主表获取状态权限配置 + if (StringUtils.isBlank(targetFlow.getStatusPermissions())) { + response.setStatusPermissions(List.of()); + return response; + } + + // 解析状态权限配置 + response.setStatusPermissions(parseStatusPermissions(permissions, targetFlow.getStatusPermissions())); + return response; + } + + /** + * 批量检查资源的操作权限 + * + * @param formType 表单类型 + * @param resources 资源列表 + * @param permission 权限标识 + * @param organizationId 组织ID + * @param idGetter 获取资源ID的函数 + * @param statusGetter 获取审批状态的函数 + * @return 有权限的资源ID列表 + */ + public List filterResourcesWithPermission( + String formType, List resources, String permission, String organizationId, + java.util.function.Function idGetter, + java.util.function.Function statusGetter) { + if (CollectionUtils.isEmpty(resources)) { + return List.of(); + } + + // 检查是否所有资源都没有审批状态,如果是则直接返回所有资源ID,无需权限校验 + boolean allStatus = resources.stream() + .allMatch(resource -> StringUtils.isNotBlank(statusGetter.apply(resource)) + && Strings.CS.equals(statusGetter.apply(resource), ApprovalStatus.NONE.name())); + + if (allStatus) { + // 所有资源都没有审批状态,则无需校验权限,直接返回所有资源ID + return resources.stream().map(idGetter).collect(Collectors.toList()); + } + + // 获取状态权限配置 + StatusPermissionSettingResponse setting = getStatusPermissionsByFormType(formType, organizationId); + List statusPermissions = setting.getStatusPermissions(); + + if (CollectionUtils.isEmpty(statusPermissions)) { + // 没有配置状态权限限制,全部允许操作 + return resources.stream().map(idGetter).collect(Collectors.toList()); + } + + // 构建需要权限的状态集合:(审批状态, 权限) -> 是否需要权限 + Map permissionRequiredMap = new HashMap<>(statusPermissions.size()); + for (StatusPermissionDTO sp : statusPermissions) { + if (permission.equals(sp.getPermission())) { + permissionRequiredMap.put(sp.getApprovalStatus(), sp.getEnabled()); + } + } + // 没有开启审核时创建的数据,状态为NONE,也应该能被修改 + permissionRequiredMap.put(ApprovalStatus.NONE.name(), true); + // 检查用户是否有该权限 + boolean hasPermission = PermissionUtils.hasPermission(permission); + + // 过滤出有权限的资源 + return resources.stream() + .filter(resource -> { + String status = statusGetter.apply(resource); + if (StringUtils.isBlank(status)) { + // 没有审批状态,不受限制 + return true; + } + Boolean required = permissionRequiredMap.get(status); + // 需要权限,检查用户是否有权限 + return BooleanUtils.isTrue(required) && hasPermission; + }) + .map(idGetter) + .collect(Collectors.toList()); + } + + /** + * 批量检查资源的导出权限 + * 根据审批流配置的状态权限,过滤出当前用户可以导出的资源 + * + * @param formKey 表单Key(如: quotation, contract, invoice, order) + * @param resources 资源列表 + * @param organizationId 组织ID + * @param idGetter 获取资源ID的函数 + * @param statusGetter 获取审批状态的函数 + * @return 可以导出的资源ID列表 + */ + public List filterResourcesWithExportPermission( + String formKey, List resources, String organizationId, + java.util.function.Function idGetter, + java.util.function.Function statusGetter) { + if (CollectionUtils.isEmpty(resources)) { + return List.of(); + } + // 审批状态缺失走数据权限, 无需再过滤 + boolean allNoApprovalStatus = resources.stream() + .allMatch(resource -> { + String status = statusGetter.apply(resource); + return StringUtils.isBlank(status) || Strings.CS.equals(status, ApprovalStatus.NONE.name()); + }); + if (allNoApprovalStatus) { + return resources.stream().map(idGetter).collect(Collectors.toList()); + } + + // 获取审批流状态权限配置 + StatusPermissionSettingResponse setting = getStatusPermissionsByFormType(formKey, organizationId); + if (setting == null || CollectionUtils.isEmpty(setting.getStatusPermissions())) { + // 没有开启审批流或者没有配置状态权限限制,全部允许导出 + return resources.stream().map(idGetter).collect(Collectors.toList()); + } + + // 过滤出导出权限开启的状态集合 + List statusPermissions = setting.getStatusPermissions(); + List exportApprovalStatus = statusPermissions.stream().filter(sp -> sp.getPermission().contains(EXPORT_SUFFIX) && sp.getEnabled()).map(StatusPermissionDTO::getApprovalStatus).toList(); + + // 如果没有配置任何导出权限,无需过滤 + if (CollectionUtils.isEmpty(exportApprovalStatus)) { + return resources.stream().filter(resource -> { + String status = statusGetter.apply(resource); + return StringUtils.isBlank(status) || Strings.CS.equals(status, ApprovalStatus.NONE.name()); + }).map(idGetter) + .collect(Collectors.toList()); + } + + + // 过滤出有导出权限的资源 + return resources.stream() + .filter(resource -> { + String status = statusGetter.apply(resource); + if (StringUtils.isBlank(status) || Strings.CS.equals(status, ApprovalStatus.NONE.name())) { + // 没有审批状态,不受限制,允许导出 + return true; + } + // 审批状态开启了导出权限, 可导出 + return exportApprovalStatus.contains(status); + }) + .map(idGetter) + .collect(Collectors.toList()); + } + + /** + * 分页查询审批流列表 + */ + public Pager> list(ApprovalFlowPageRequest request, String organizationId) { + PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List responses = extApprovalFlowMapper.list(request, organizationId); + baseService.setCreateAndUpdateUserName(responses); + Page page = (Page) responses; + return PageUtils.setPageInfo(page, responses); + } + + /** + * 获取审批流详情 + */ + public ApprovalFlowDetailResponse getDetail(String id, String organizationId) { + ApprovalFlow flow = approvalFlowMapper.selectByPrimaryKey(id); + if (flow == null || !flow.getOrganizationId().equals(organizationId)) { + throw new GenericException(CrmHttpResultCode.NOT_FOUND); + } + + ApprovalFlowDetailResponse response = BeanUtils.copyBean(new ApprovalFlowDetailResponse(), flow); + + // 设置创建人和更新人名称 + baseService.setCreateAndUpdateUserName(response); + + List permissions = getPermissionsByFormType(flow.getFormType()); + + // 设置对应资源的权限列表 + response.setPermissions(getResourcePermissions(permissions)); + + // 解析状态权限配置 + response.setStatusPermissions(parseStatusPermissions(response.getPermissions(), flow.getStatusPermissions())); + + // 查询节点配置和连接关系(按执行时机分组) + buildNodeConfigs(flow.getCurrentVersionId(), response, organizationId); + + return response; + } + + /** + * 根据表单类型获取审批流信息(不含节点配置) + */ + public ApprovalFlowByFormTypeResponse getByFormType(String formType, String organizationId) { + ApprovalFlow criteria = new ApprovalFlow(); + criteria.setFormType(formType); + criteria.setEnable(true); + criteria.setDeleted(false); + criteria.setOrganizationId(organizationId); + List flows = approvalFlowMapper.select(criteria); + + ApprovalFlow targetFlow = flows.stream() + .filter(ApprovalFlow::getEnable) + .findFirst() + .orElse(null); + + if (targetFlow == null) { + return null; + } + + ApprovalFlowByFormTypeResponse response = BeanUtils.copyBean(new ApprovalFlowByFormTypeResponse(), targetFlow); + + List permissions = getPermissionsByFormType(targetFlow.getFormType()); + response.setPermissions(getResourcePermissions(permissions)); + + // 解析状态权限配置(从主表获取) + response.setStatusPermissions(parseStatusPermissions(response.getPermissions(), targetFlow.getStatusPermissions())); + + return response; + } + + /** + * 新建审批流 + */ + @OperationLog(module = LogModule.APPROVAL_FLOW, type = LogType.ADD, resourceName = "{#request.name}") + public ApprovalFlowDetailResponse add(ApprovalFlowAddRequest request, String userId, String organizationId) { + // 检查该表单类型是否已存在审批流(每个表单类型只能创建一个) + ApprovalFlow existFlow = selectApprovalFlowByFormType(request.getFormType(), organizationId); + if (existFlow != null) { + throw new GenericException(Translator.get("approval_flow.type.already.exists")); + } + + // 创建审批流主表 + ApprovalFlow flow = BeanUtils.copyBean(new ApprovalFlow(), request); + flow.setId(IDGenerator.nextStr()); + flow.setNumber(generateFlowNumber(request.getFormType(), organizationId)); + flow.setCreateUser(userId); + flow.setCreateTime(System.currentTimeMillis()); + flow.setUpdateUser(userId); + flow.setUpdateTime(System.currentTimeMillis()); + flow.setOrganizationId(organizationId); + flow.setDeleted(false); + + // 保存状态权限配置到主表 + if (request.getStatusPermissions() != null) { + flow.setStatusPermissions(JSON.toJSONString(request.getStatusPermissions())); + } + + // 创建版本(版本表只存储节点配置的关联) + ApprovalFlowVersion version = createFlowVersion(flow.getId(), userId, organizationId); + approvalFlowVersionMapper.insert(version); + + // 更新当前版本ID + flow.setCurrentVersionId(version.getId()); + approvalFlowMapper.insert(flow); + + // 保存节点配置(按执行时机分别保存) + saveNodeConfigs(request, version.getId(), flow.getId()); + + ApprovalFlowDetailResponse detail = getDetail(flow.getId(), organizationId); + + // 设置日志上下文 + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(flow.getId()) + .modifiedValue(detail) + .build() + ); + + return detail; + } + + public ApprovalFlow selectApprovalFlowByFormType(String formType, String organizationId) { + ApprovalFlow existCriteria = new ApprovalFlow(); + existCriteria.setFormType(formType); + existCriteria.setDeleted(false); + existCriteria.setOrganizationId(organizationId); + List existFlows = approvalFlowMapper.select(existCriteria); + if (CollectionUtils.isEmpty(existFlows)) { + return null; + } + return existFlows.getFirst(); + } + + /** + * 创建审批流版本 + */ + private ApprovalFlowVersion createFlowVersion(String flowId, String userId, String organizationId) { + ApprovalFlowVersion version = new ApprovalFlowVersion(); + version.setId(IDGenerator.nextStr()); + version.setFlowId(flowId); + version.setOrganizationId(organizationId); + version.setCreateUser(userId); + version.setCreateTime(System.currentTimeMillis()); + return version; + } + + /** + * 更新审批流 + */ + @OperationLog(module = LogModule.APPROVAL_FLOW, type = LogType.UPDATE, resourceId = "{#request.id}") + public ApprovalFlowDetailResponse update(ApprovalFlowUpdateRequest request, String userId, String organizationId) { + // 获取原始日志DTO + ApprovalFlowDetailResponse originDetail = getDetail(request.getId(), organizationId); + + // 更新审批流主表(配置字段直接保存到主表) + ApprovalFlow flow = BeanUtils.copyBean(new ApprovalFlow(), request); + flow.setUpdateUser(userId); + flow.setUpdateTime(System.currentTimeMillis()); + + // 保存状态权限配置到主表 + if (request.getStatusPermissions() != null) { + flow.setStatusPermissions(JSON.toJSONString(request.getStatusPermissions())); + } + + // 如果有节点更新,创建新版本 + if (hasNodeConfigUpdate(request)) { + ApprovalFlowVersion newVersion = createFlowVersion(originDetail.getId(), userId, organizationId); + approvalFlowVersionMapper.insert(newVersion); + + // 更新当前版本ID + flow.setCurrentVersionId(newVersion.getId()); + + // 保存新节点配置(按执行时机分别保存) + saveNodeConfigs(request, newVersion.getId(), originDetail.getId()); + } + + approvalFlowMapper.updateById(flow); + + ApprovalFlowDetailResponse detail = getDetail(flow.getId(), organizationId); + + // 获取更新后的日志DTO + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceName(detail.getName()) + .originalValue(originDetail) + .modifiedValue(detail) + .build() + ); + + return detail; + } + + /** + * 删除审批流 + */ + @OperationLog(module = LogModule.APPROVAL_FLOW, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id, String organizationId) { + ApprovalFlow flow = approvalFlowMapper.selectByPrimaryKey(id); + if (flow == null || !flow.getOrganizationId().equals(organizationId)) { + throw new GenericException(CrmHttpResultCode.NOT_FOUND); + } + + // 软删除:设置 deleted = true + ApprovalFlow update = new ApprovalFlow(); + update.setId(id); + update.setDeleted(true); + update.setUpdateTime(System.currentTimeMillis()); + approvalFlowMapper.updateById(update); + + // 清除审批中的资源和待办 + approvalInstanceService.clearApprovingInstanceOfFlow(id); + + // 设置日志上下文 + OperationLogContext.setResourceName(flow.getName()); + } + + /** + * 启用/禁用审批流 + */ + @OperationLog(module = LogModule.APPROVAL_FLOW, type = LogType.UPDATE, resourceId = "{#id}") + public void updateEnable(String id, Boolean enable, String userId, String organizationId) { + ApprovalFlow flow = approvalFlowMapper.selectByPrimaryKey(id); + if (flow == null || !flow.getOrganizationId().equals(organizationId) || Boolean.TRUE.equals(flow.getDeleted())) { + throw new GenericException(CrmHttpResultCode.NOT_FOUND); + } + + Boolean oldEnable = flow.getEnable(); + + ApprovalFlow update = new ApprovalFlow(); + update.setId(id); + update.setEnable(enable); + update.setUpdateUser(userId); + update.setUpdateTime(System.currentTimeMillis()); + approvalFlowMapper.updateById(update); + + // 设置日志上下文 + Map originalVal = new HashMap<>(2); + originalVal.put("enable", oldEnable); + Map modifiedVal = new HashMap<>(2); + modifiedVal.put("enable", enable); + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceName(flow.getName()) + .originalValue(originalVal) + .modifiedValue(modifiedVal) + .build() + ); + } + + /** + * 生成流程编码 + */ + private String generateFlowNumber(String formType, String organizationId) { + String prefix = getNumberPrefix(formType); + String key = "approval_flow_num:" + organizationId + ":" + formType; + long seq = stringRedisTemplate.opsForValue().increment(key); + return String.format("%s-%05d", prefix, seq); + } + + /** + * 生成节点编码 + */ + private String generateNodeNumber(String flowId) { + String key = "approval_node_num:" + ":" + flowId; + long seq = stringRedisTemplate.opsForValue().increment(key); + return String.format("P" + "N%03d", seq); + } + + private String getNumberPrefix(String formType) { + try { + return ApprovalFormTypeEnum.valueOf(formType.toUpperCase()).getPrefix(); + } catch (IllegalArgumentException e) { + return "APV"; + } + } + + private List getNodesByFlowVersionId(String flowVersionId) { + if (StringUtils.isBlank(flowVersionId)) { + return List.of(); + } + ApprovalNode criteria = new ApprovalNode(); + criteria.setFlowVersionId(flowVersionId); + List nodes = approvalNodeMapper.select(criteria); + return nodes.stream().map(node -> { + // 查询审批人节点配置 + if (ApprovalNodeTypeEnum.APPROVER.name().equals(node.getNodeType())) { + ApprovalNodeApprover approverNode = approvalNodeApproverMapper.selectByPrimaryKey(node.getId()); + if (approverNode != null) { + ApprovalNodeApproverResponse approverResponse = BeanUtils.copyBean( + new ApprovalNodeApproverResponse(), node); + BeanUtils.copyBean(approverResponse, approverNode); + // 解析 JSON 字段为对象 + parseApproverNodeFields(approverNode, approverResponse); + return approverResponse; + } + } + // 查询条件节点配置 + if (ApprovalNodeTypeEnum.CONDITION.name().equals(node.getNodeType())) { + ApprovalNodeCondition conditionNode = approvalNodeConditionMapper.selectByPrimaryKey(node.getId()); + if (conditionNode != null) { + ApprovalNodeConditionResponse conditionResponse = BeanUtils.copyBean( + new ApprovalNodeConditionResponse(), node); + if (StringUtils.isNotBlank(conditionNode.getConditionConfig())) { + conditionResponse.setConditionConfig(JSON.parseObject(conditionNode.getConditionConfig(), CombineSearch.class)); + } + return conditionResponse; + } + } + // 其他类型节点 + return BeanUtils.copyBean(new ApprovalNodeResponse(), node); + }).collect(Collectors.toList()); + } + + /** + * 解析审批人节点 JSON 字段为对象 + */ + private void parseApproverNodeFields(ApprovalNodeApprover approverNode, ApprovalNodeApproverResponse response) { + // 解析审批人列表 + List approverIds = null; + if (StringUtils.isNotBlank(approverNode.getApproverList())) { + approverIds = JSON.parseArray(approverNode.getApproverList(), String.class); + response.setApproverList(approverIds); + } + // 解析抄送人列表 + List ccIds = null; + if (StringUtils.isNotBlank(approverNode.getCcList())) { + ccIds = JSON.parseArray(approverNode.getCcList(), String.class); + response.setCcList(ccIds); + } + // 解析其他配置 + if (StringUtils.isNotBlank(approverNode.getPassPostConfig())) { + response.setPassPostConfig(JSON.parseObject(approverNode.getPassPostConfig(), ApprovalPostConfigDTO.class)); + } + if (StringUtils.isNotBlank(approverNode.getRejectPostConfig())) { + response.setRejectPostConfig(JSON.parseObject(approverNode.getRejectPostConfig(), ApprovalPostConfigDTO.class)); + } + if (StringUtils.isNotBlank(approverNode.getFieldPermissions())) { + response.setFieldPermissions(JSON.parseArray(approverNode.getFieldPermissions(), FieldPermissionDTO.class)); + } + + // 查询审批人选择项名称(用于前端回显) + if (CollectionUtils.isNotEmpty(approverIds)) { + response.setApproverSelectOptions(resolveSelectOptions(approverNode.getApproverType(), approverIds)); + } + // 查询抄送人选择项名称(用于前端回显) + if (CollectionUtils.isNotEmpty(ccIds)) { + response.setCcSelectOptions(resolveSelectOptions(approverNode.getCcType(), ccIds)); + } + } + + /** + * 根据类型解析选择项列表 + */ + private List resolveSelectOptions(String type, List ids) { + if (StringUtils.isBlank(type) || CollectionUtils.isEmpty(ids)) { + return List.of(); + } + try { + ApproverTypeEnum approverType = ApproverTypeEnum.valueOf(type); + return switch (approverType) { + case MEMBER -> resolveMemberOptions(ids); + case ROLE -> resolveRoleOptions(ids); + default -> List.of(); + }; + } catch (IllegalArgumentException e) { + return List.of(); + } + } + + /** + * 解析成员选择项 + */ + private List resolveMemberOptions(List userIds) { + List userOptions = extUserMapper.selectUserOptionByIds(userIds); + if (CollectionUtils.isEmpty(userOptions)) { + return List.of(); + } + // 按照 userIds 顺序返回 + Map optionMap = userOptions.stream() + .collect(Collectors.toMap(OptionDTO::getId, o -> o)); + return userIds.stream() + .map(optionMap::get) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + /** + * 解析角色选择项 + */ + private List resolveRoleOptions(List roleIds) { + List roleOptions = extRoleMapper.getIdNameByIds(roleIds); + if (CollectionUtils.isEmpty(roleOptions)) { + return List.of(); + } + // 按照 roleIds 顺序返回 + Map optionMap = roleOptions.stream() + .collect(Collectors.toMap(OptionDTO::getId, o -> o)); + return roleIds.stream() + .map(optionMap::get) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + /** + * 构建节点和连接关系 + */ + private void buildNodesAndLinks(String flowVersionId, ApprovalFlowDetailResponse response, String organizationId) { + if (StringUtils.isBlank(flowVersionId)) { + response.setCreateNodeConfig(emptyNodeConfig()); + response.setUpdateNodeConfig(emptyNodeConfig()); + response.setDeleteNodeConfig(emptyNodeConfig()); + return; + } + // 获取所有节点并按 sort 排序 + List allNodes = getNodesByFlowVersionId(flowVersionId); + allNodes.sort(Comparator.comparing(ApprovalNodeResponse::getSort)); + + // 收集所有审批人节点的 fallbackApprover ID + Set fallbackApproverIds = allNodes.stream() + .filter(node -> node instanceof ApprovalNodeApproverResponse) + .map(node -> (ApprovalNodeApproverResponse) node) + .map(ApprovalNodeApproverResponse::getFallbackApprover) + .filter(StringUtils::isNotBlank) + .collect(Collectors.toSet()); + + // 批量查询兜底审批人名称 + Map fallbackApproverNameMap = baseService.getUserNameMap(fallbackApproverIds); + + // 设置兜底审批人名称 + allNodes.stream() + .filter(node -> node instanceof ApprovalNodeApproverResponse) + .map(node -> (ApprovalNodeApproverResponse) node) + .filter(node -> StringUtils.isNotBlank(node.getFallbackApprover())) + .forEach(node -> node.setFallbackApproverName( + baseService.getAndCheckOptionName(fallbackApproverNameMap.get(node.getFallbackApprover())))); + + // 获取节点连接关系并按 sort 排序 + ApprovalNodeLink linkCriteria = new ApprovalNodeLink(); + linkCriteria.setFlowVersionId(flowVersionId); + List links = approvalNodeLinkMapper.select(linkCriteria); + links.sort(Comparator.comparing(ApprovalNodeLink::getSort)); + + List linkResponses = links.stream() + .map(link -> { + ApprovalNodeLinkResponse linkResponse = new ApprovalNodeLinkResponse(); + linkResponse.setId(link.getId()); + linkResponse.setFromNodeId(link.getFromNodeId()); + linkResponse.setToNodeId(link.getToNodeId()); + return linkResponse; + }) + .collect(Collectors.toList()); + + // 按 executeTime 分组 + Map> nodesByExecuteTime = allNodes.stream() + .filter(node -> StringUtils.isNotBlank(node.getExecuteTime())) + .collect(Collectors.groupingBy(ApprovalNodeResponse::getExecuteTime)); + + Map> linksByExecuteTime = linkResponses.stream() + .collect(Collectors.groupingBy(link -> { + String fromNodeId = link.getFromNodeId(); + return allNodes.stream() + .filter(n -> n.getId().equals(fromNodeId)) + .map(ApprovalNodeResponse::getExecuteTime) + .findFirst().orElse(""); + })); + + Map> optionMap = buildConditionOptionMap(allNodes, response.getFormType(), organizationId); + response.setOptionMap(optionMap); + + // 构建3个执行时机的节点配置 + for (ExecuteTimingEnum executeTime : ExecuteTimingEnum.values()) { + List configNodes = nodesByExecuteTime.getOrDefault(executeTime.name(), List.of()); + List configLinks = linksByExecuteTime.getOrDefault(executeTime.name(), List.of()); + + ApprovalFlowNodeConfigResponse config = new ApprovalFlowNodeConfigResponse(); + config.setNodes(configNodes); + config.setLinks(configLinks); + + switch (executeTime) { + case CREATE -> response.setCreateNodeConfig(config); + case UPDATE -> response.setUpdateNodeConfig(config); + case DELETE -> response.setDeleteNodeConfig(config); + } + } + } + + private ApprovalFlowNodeConfigResponse emptyNodeConfig() { + ApprovalFlowNodeConfigResponse config = new ApprovalFlowNodeConfigResponse(); + config.setNodes(List.of()); + config.setLinks(List.of()); + return config; + } + + /** + * 按执行时机构建节点配置 + */ + private void buildNodeConfigs(String flowVersionId, ApprovalFlowDetailResponse response, String organizationId) { + buildNodesAndLinks(flowVersionId, response, organizationId); + } + + /** + * 判断请求中是否包含节点配置更新 + */ + private boolean hasNodeConfigUpdate(ApprovalFlowUpdateRequest request) { + return hasNodeConfig(request.getCreateNodeConfig()) + || hasNodeConfig(request.getUpdateNodeConfig()) + || hasNodeConfig(request.getDeleteNodeConfig()); + } + + private boolean hasNodeConfig(ApprovalFlowNodeConfigRequest config) { + return config != null && CollectionUtils.isNotEmpty(config.getNodes()); + } + + /** + * 保存审批流的节点配置(按执行时机分别保存) + */ + private void saveNodeConfigs(ApprovalFlowAddRequest request, String flowVersionId, String flowId) { + if (Boolean.TRUE.equals(request.getCreateExecute()) && request.getCreateNodeConfig() != null) { + saveNodesAndLinks(request.getCreateNodeConfig().getNodes(), request.getCreateNodeConfig().getLinks(), + flowVersionId, flowId, ExecuteTimingEnum.CREATE.name()); + } + if (Boolean.TRUE.equals(request.getUpdateExecute()) && request.getUpdateNodeConfig() != null) { + saveNodesAndLinks(request.getUpdateNodeConfig().getNodes(), request.getUpdateNodeConfig().getLinks(), + flowVersionId, flowId, ExecuteTimingEnum.UPDATE.name()); + } + if (Boolean.TRUE.equals(request.getDeleteExecute()) && request.getDeleteNodeConfig() != null) { + saveNodesAndLinks(request.getDeleteNodeConfig().getNodes(), request.getDeleteNodeConfig().getLinks(), + flowVersionId, flowId, ExecuteTimingEnum.DELETE.name()); + } + } + + private void saveNodeConfigs(ApprovalFlowUpdateRequest request, String flowVersionId, String flowId) { + if (Boolean.TRUE.equals(request.getCreateExecute()) && request.getCreateNodeConfig() != null) { + saveNodesAndLinks(request.getCreateNodeConfig().getNodes(), request.getCreateNodeConfig().getLinks(), + flowVersionId, flowId, ExecuteTimingEnum.CREATE.name()); + } + if (Boolean.TRUE.equals(request.getUpdateExecute()) && request.getUpdateNodeConfig() != null) { + saveNodesAndLinks(request.getUpdateNodeConfig().getNodes(), request.getUpdateNodeConfig().getLinks(), + flowVersionId, flowId, ExecuteTimingEnum.UPDATE.name()); + } + if (Boolean.TRUE.equals(request.getDeleteExecute()) && request.getDeleteNodeConfig() != null) { + saveNodesAndLinks(request.getDeleteNodeConfig().getNodes(), request.getDeleteNodeConfig().getLinks(), + flowVersionId, flowId, ExecuteTimingEnum.DELETE.name()); + } + } + + /** + * 构建条件节点字段选项映射 + */ + private Map> buildConditionOptionMap(List nodes, String formType, String organizationId) { + // 收集所有条件节点的条件配置 + List allConditions = nodes.stream() + .filter(node -> node instanceof ApprovalNodeConditionResponse) + .map(node -> (ApprovalNodeConditionResponse) node) + .filter(node -> node.getConditionConfig() != null && CollectionUtils.isNotEmpty(node.getConditionConfig().getConditions())) + .map(node -> node.getConditionConfig().getConditions()) + .flatMap(List::stream) + .collect(Collectors.toList()); + + List postConfigFieldValues = getNodePostConfigFieldValues(nodes); + + if (CollectionUtils.isEmpty(allConditions) && CollectionUtils.isEmpty(postConfigFieldValues)) { + return Map.of(); + } + + List postConfigConditions = postConfigFieldValues.stream() + .filter(BaseModuleFieldValue::valid) + .map(fieldValue -> { + FilterCondition condition = new FilterCondition(); + condition.setName(fieldValue.getFieldId()); + condition.setValue(fieldValue.getFieldValue()); + return condition; + }) + .toList(); + + // 合并所有条件 + List combinedConditions = new ArrayList<>(allConditions); + combinedConditions.addAll(postConfigConditions); + + return userViewService.buildOptionMap(organizationId, formType, combinedConditions); + } + + private List getNodePostConfigFieldValues(List nodes) { + // 收集审批人节点中的 passPostConfig 和 rejectPostConfig 中的字段值 + return nodes.stream() + .filter(node -> node instanceof ApprovalNodeApproverResponse) + .map(node -> (ApprovalNodeApproverResponse) node) + .flatMap(approverNode -> { + List fieldValues = new ArrayList<>(); + if (approverNode.getPassPostConfig() != null + && CollectionUtils.isNotEmpty(approverNode.getPassPostConfig().getFieldUpdateConfigs())) { + fieldValues.addAll(approverNode.getPassPostConfig().getFieldUpdateConfigs()); + } + if (approverNode.getRejectPostConfig() != null + && CollectionUtils.isNotEmpty(approverNode.getRejectPostConfig().getFieldUpdateConfigs())) { + fieldValues.addAll(approverNode.getRejectPostConfig().getFieldUpdateConfigs()); + } + return fieldValues.stream(); + }) + .collect(Collectors.toList()); + } + + /** + * 批量保存节点配置和连接关系 + */ + private void saveNodesAndLinks(List nodes, List links, + String flowVersionId, String flowId, String executeTime) { + if (CollectionUtils.isEmpty(nodes)) { + return; + } + + // 用于收集节点和配置信息 + List allNodes = new ArrayList<>(); + List allApproverNodes = new ArrayList<>(); + List allConditionNodes = new ArrayList<>(); + + // 构建前端ID到数据库ID的映射 + Map nodeIdMap = new HashMap<>(nodes.size()); + + // 收集所有节点信息,按数组顺序设置 sort + // 所有节点都生成新的数据库ID,前端传的ID仅用于关联关系映射 + int nodeSort = 0; + for (ApprovalNodeRequest nodeRequest : nodes) { + String frontEndId = nodeRequest.getId(); + String newNodeId = IDGenerator.nextStr(); + // 前端ID映射到新的数据库ID + if (StringUtils.isNotBlank(frontEndId)) { + nodeIdMap.put(frontEndId, newNodeId); + } + + // 收集节点基本信息 + ApprovalNode node = BeanUtils.copyBean(new ApprovalNode(), nodeRequest); + node.setId(newNodeId); + node.setFlowVersionId(flowVersionId); + // 如果前端传了number则使用,否则生成新的number + if (StringUtils.isBlank(nodeRequest.getNumber())) { + node.setNumber(generateNodeNumber(flowId)); + } else { + node.setNumber(nodeRequest.getNumber()); + } + node.setSort(nodeSort++); + node.setExecuteTime(executeTime); + allNodes.add(node); + + // 收集审批人节点配置 + if (nodeRequest instanceof ApprovalNodeApproverRequest approverRequest) { + ApprovalNodeApprover approverNode = BeanUtils.copyBean(new ApprovalNodeApprover(), approverRequest, + "approverList", "ccList", "passPostConfig", "rejectPostConfig", "fieldPermissions"); + approverNode.setId(newNodeId); + approverNode.setFlowVersionId(flowVersionId); + approverNode.setApproverList(JSON.toJSONString(approverRequest.getApproverList())); + approverNode.setCcList(JSON.toJSONString(approverRequest.getCcList())); + approverNode.setPassPostConfig(JSON.toJSONString(approverRequest.getPassPostConfig())); + approverNode.setRejectPostConfig(JSON.toJSONString(approverRequest.getRejectPostConfig())); + approverNode.setFieldPermissions(JSON.toJSONString(approverRequest.getFieldPermissions())); + allApproverNodes.add(approverNode); + } + // 收集条件节点配置 + else if (nodeRequest instanceof ApprovalNodeConditionRequest conditionRequest) { + ApprovalNodeCondition conditionNode = BeanUtils.copyBean(new ApprovalNodeCondition(), conditionRequest, + "rules"); + conditionNode.setId(newNodeId); + conditionNode.setFlowVersionId(flowVersionId); + conditionNode.setConditionConfig(JSON.toJSONString(conditionRequest.getConditionConfig())); + allConditionNodes.add(conditionNode); + } + } + + // 收集连接信息,按数组顺序设置 sort + // 所有连接都使用映射后的新节点ID + List allLinks = new ArrayList<>(); + if (CollectionUtils.isNotEmpty(links)) { + int linkSort = 0; + for (ApprovalNodeLinkRequest linkRequest : links) { + String fromNodeId = nodeIdMap.get(linkRequest.getFromNodeId()); + String toNodeId = nodeIdMap.get(linkRequest.getToNodeId()); + // 只有当 from 和 to 节点都存在时才创建连接 + if (fromNodeId == null || toNodeId == null) { + continue; + } + + ApprovalNodeLink link = new ApprovalNodeLink(); + link.setId(IDGenerator.nextStr()); + link.setFlowVersionId(flowVersionId); + link.setFromNodeId(fromNodeId); + link.setToNodeId(toNodeId); + link.setSort(linkSort++); + allLinks.add(link); + } + } + + // 批量插入 + approvalNodeMapper.batchInsert(allNodes); + if (CollectionUtils.isNotEmpty(allLinks)) { + approvalNodeLinkMapper.batchInsert(allLinks); + } + if (CollectionUtils.isNotEmpty(allApproverNodes)) { + approvalNodeApproverMapper.batchInsert(allApproverNodes); + } + if (CollectionUtils.isNotEmpty(allConditionNodes)) { + approvalNodeConditionMapper.batchInsert(allConditionNodes); + } + } + + /** + * 获取对应资源的权限列表 + */ + private List getResourcePermissions(List permissions) { + return permissions.stream() + .map(p -> new OptionDTO(p.getId(), p.getName())) + .collect(Collectors.toList()); + } + + private List getPermissionsByFormType(String formType) { + List permissionSetting = roleService.getPermissionSetting(); + String permissionId = Objects.requireNonNull(ApprovalFormTypeEnum.getByValue(formType)).getPermissionId(); + List permissions = findPermissionsByPermissionId(permissionSetting, permissionId); + if (permissions == null) { + return List.of(); + } + return permissions; + } + + /** + * 解析状态权限配置 + */ + private List parseStatusPermissions(List permissions, String statusPermissions) { + if (StringUtils.isBlank(statusPermissions)) { + return List.of(); + } + // 解析已保存的状态权限配置 + List savedPermissions = JSON.parseArray(statusPermissions, StatusPermissionDTO.class); + + if (CollectionUtils.isEmpty(savedPermissions)) { + return List.of(); + } + + // 获取所有审批状态 + Set approvalStatuses = savedPermissions.stream() + .map(StatusPermissionDTO::getApprovalStatus) + .collect(Collectors.toSet()); + + // 构建已保存权限的映射:(审批状态, 权限ID) -> StatusPermissionDTO + Map savedPermissionMap = savedPermissions.stream() + .collect(Collectors.toMap( + p -> p.getApprovalStatus() + ":" + p.getPermission(), + p -> p)); + + // 更新权限名称并补充缺失的权限 + List updatedPermissions = new ArrayList<>(); + for (String approvalStatus : approvalStatuses) { + for (OptionDTO permission : permissions) { + String key = approvalStatus + ":" + permission.getId(); + StatusPermissionDTO item = savedPermissionMap.get(key); + if (item != null) { + updatedPermissions.add(item); + } else { + // 添加缺失的权限,默认不启用 + StatusPermissionDTO newItem = new StatusPermissionDTO(); + newItem.setApprovalStatus(approvalStatus); + newItem.setPermission(permission.getId()); + newItem.setEnabled(false); + updatedPermissions.add(newItem); + } + // 审批中编辑和删除权限 enable 设置为 false + if (Strings.CS.equals(approvalStatus, ApprovalStatus.APPROVING.name()) + && (permission.getId().endsWith(":UPDATE") || permission.getId().endsWith(":DELETE"))) { + item.setEnabled(false); + } + } + } + + return updatedPermissions; + } + + /** + * 获取下一个节点(带 updateFields 上下文) + */ + public ApprovalNodeResponse getNextNode(String nodeId, List fieldValues, Set updateFields) { + List nextNodes = getNextNodes(nodeId); + + if (CollectionUtils.isEmpty(nextNodes)) { + return null; + } + + // 检查是否存在条件节点 + boolean hasConditionNode = nextNodes.stream() + .anyMatch(n -> ApprovalNodeTypeEnum.CONDITION.name().equals(n.getNodeType())); + + if (!hasConditionNode) { + // 非条件节点,通常只有一个,直接返回第一个 + return nextNodes.getFirst(); + } + + // 条件节点匹配逻辑:按 sort 顺序依次匹配所有条件节点 + // 条件节点 + 默认节点组成 if-else 组合 + ApprovalNodeResponse defaultNode = null; + + for (ApprovalNodeResponse nextNode : nextNodes) { + if (nextNode instanceof ApprovalNodeConditionResponse conditionNode) { + // 匹配条件节点,如果匹配成功则立即返回 + if (matchCondition(conditionNode.getConditionConfig(), fieldValues, updateFields)) { + return conditionNode; + } + } else if (ApprovalNodeTypeEnum.DEFAULT.name().equals(nextNode.getNodeType())) { + // 记录 DEFAULT 节点,等所有条件节点检查完毕后再返回 + defaultNode = nextNode; + } + } + + // 所有条件节点都不匹配,返回 DEFAULT 节点(如果存在) + return defaultNode; + } + + /** + * 匹配条件 + */ + private boolean matchCondition(CombineSearch combineSearch, List fieldValues, Set updateFields) { + if (combineSearch == null || CollectionUtils.isEmpty(combineSearch.getConditions())) { + return false; + } + + // 构建字段值映射 + Map fieldValueMap = fieldValues.stream() + .filter(BaseModuleFieldValue::valid) + .collect(Collectors.toMap(BaseModuleFieldValue::getFieldId, BaseModuleFieldValue::getFieldValue)); + + List conditions = combineSearch.getConditions(); + String searchMode = combineSearch.getSearchMode(); + + // 根据匹配模式进行条件判断 + if (CombineSearch.SearchMode.AND.name().equals(searchMode)) { + // AND 模式:所有都必须满足 + return conditions.stream().allMatch(condition -> matchSingleCondition(condition, fieldValueMap, updateFields)); + } else { + // OR 模式:任一满足即可 + return conditions.stream().anyMatch(condition -> matchSingleCondition(condition, fieldValueMap, updateFields)); + } + } + + /** + * 匹配单个条件 + */ + private boolean matchSingleCondition(FilterCondition condition, Map fieldValueMap, Set updateFields) { + String fieldName = condition.getName(); + Object actualValue = fieldValueMap.get(getParentFieldIdForSubFieldId(fieldName)); + + // 获取动态转换后的值和操作符 + Object expectedValue = condition.getCombineValue(); + String operatorStr = condition.getCombineOperator(); + + // 获取操作符枚举 + FilterCondition.CombineConditionOperator operator; + try { + operator = FilterCondition.CombineConditionOperator.valueOf(operatorStr); + } catch (IllegalArgumentException e) { + return false; + } + + // 处理 NOT_EQUAL_ORIGINAL:判断字段值是否与原值不同(即字段在修改列表中) + if (operator == FilterCondition.CombineConditionOperator.NOT_EQUAL_ORIGINAL) { + if (updateFields == null || updateFields.isEmpty()) { + return false; + } + return updateFields.contains(getFieldIdForSubFieldId(fieldName)); + } + + if (fieldName.contains(".") && actualValue instanceof List listValues) { + for (Object listValue : listValues) { + if (listValue instanceof Map map) { + Object subFileValue = map.get(getFieldIdForSubFieldId(fieldName)); + boolean match = matchFieldValue(subFileValue, expectedValue, operator); + if (match) { + return true; + } + } + } + return false; + } else { + return matchFieldValue(actualValue, expectedValue, operator); + } + } + + private boolean matchFieldValue(Object actualValue, Object expectedValue, FilterCondition.CombineConditionOperator operator) { + // 处理空值判断操作符 + if (operator == FilterCondition.CombineConditionOperator.EMPTY) { + return actualValue == null; + } + if (operator == FilterCondition.CombineConditionOperator.NOT_EMPTY) { + return actualValue != null; + } + + if (actualValue == null) { + return false; + } + + try { + return switch (operator) { + case EQUALS -> matchEquals(actualValue, expectedValue); + case NOT_EQUALS -> !matchEquals(actualValue, expectedValue); + case CONTAINS -> matchContains(actualValue, expectedValue); + case NOT_CONTAINS -> !matchContains(actualValue, expectedValue); + case IN -> matchIn(actualValue, expectedValue); + case NOT_IN -> !matchIn(actualValue, expectedValue); + case GT -> matchCompare(actualValue, expectedValue) > 0; + case LT -> matchCompare(actualValue, expectedValue) < 0; + case GE -> matchCompare(actualValue, expectedValue) >= 0; + case LE -> matchCompare(actualValue, expectedValue) <= 0; + case BETWEEN -> matchBetween(actualValue, expectedValue); + default -> false; + }; + } catch (Exception e) { + return false; + } + } + + private String getParentFieldIdForSubFieldId(String fieldId) { + if (fieldId.contains(".")) { + String[] split = fieldId.split("\\."); + if (split.length > 1) { + return split[0]; + } + } + return fieldId; + } + + private String getFieldIdForSubFieldId(String fieldId) { + if (fieldId.contains(".")) { + String[] split = fieldId.split("\\."); + if (split.length > 1) { + return split[1]; + } + } + return fieldId; + } + + /** + * 等于匹配 + */ + private boolean matchEquals(Object actualValue, Object expectedValue) { + if (actualValue instanceof List actualList && expectedValue instanceof List expectedList) { + return actualList.equals(expectedList); + } + return Objects.equals(actualValue, expectedValue); + } + + /** + * 包含匹配 + */ + private boolean matchContains(Object actualValue, Object expectedValue) { + String actualStr = String.valueOf(actualValue); + String expectedStr = String.valueOf(expectedValue); + return actualStr.contains(expectedStr); + } + + /** + * IN 匹配(实际值在期望值列表中) + */ + private boolean matchIn(Object actualValue, Object expectedValue) { + if (expectedValue instanceof List expectedList) { + if (actualValue instanceof List actualList) { + // 多值匹配:交集非空 + return actualList.stream().anyMatch(expectedList::contains); + } + return expectedList.contains(actualValue); + } + return false; + } + + /** + * 比较匹配(用于数字或日期比较) + */ + private int matchCompare(Object actualValue, Object expectedValue) { + if (actualValue instanceof Number actualNum && expectedValue instanceof Number expectedNum) { + return Double.compare(actualNum.doubleValue(), expectedNum.doubleValue()); + } + // 尝试转换为数字比较 + try { + double actual = Double.parseDouble(String.valueOf(actualValue)); + double expected = Double.parseDouble(String.valueOf(expectedValue)); + return Double.compare(actual, expected); + } catch (NumberFormatException e) { + // 字符串比较 + return String.valueOf(actualValue).compareTo(String.valueOf(expectedValue)); + } + } + + /** + * BETWEEN 匹配(实际值在范围内) + */ + private boolean matchBetween(Object actualValue, Object expectedValue) { + if (expectedValue instanceof List rangeList && rangeList.size() == RANGE_SIZE) { + Object min = rangeList.get(0); + Object max = rangeList.get(1); + return matchCompare(actualValue, min) >= 0 && matchCompare(actualValue, max) <= 0; + } + return false; + } + + /** + * 加载修改字段列表 + */ + private Set loadUpdateFields(String updateFieldsStr) { + if (StringUtils.isBlank(updateFieldsStr)) { + return Collections.emptySet(); + } + try { + List fields = JSON.parseArray(updateFieldsStr, String.class); + return CollectionUtils.isEmpty(fields) ? Collections.emptySet() : new HashSet<>(fields); + } catch (Exception e) { + return Collections.emptySet(); + } + } + + /** + * 获取下一层节点列表 + */ + public List getNextNodes(String nodeId) { + ApprovalNode node = approvalNodeMapper.selectByPrimaryKey(nodeId); + if (node == null) { + throw new GenericException(CrmHttpResultCode.NOT_FOUND); + } + + String flowVersionId = node.getFlowVersionId(); + + // 查询节点连接关系 + ApprovalNodeLink linkCriteria = new ApprovalNodeLink(); + linkCriteria.setFlowVersionId(flowVersionId); + linkCriteria.setFromNodeId(nodeId); + List links = approvalNodeLinkMapper.select(linkCriteria); + + if (CollectionUtils.isEmpty(links)) { + return List.of(); + } + + // 获取下一层节点ID列表(按sort排序) + List nextNodeIds = links.stream() + .sorted(Comparator.comparing(ApprovalNodeLink::getSort)) + .map(ApprovalNodeLink::getToNodeId) + .collect(Collectors.toList()); + + // 批量查询节点 + List nodes = approvalNodeMapper.selectByIds(nextNodeIds); + Map nodeMap = nodes.stream() + .collect(Collectors.toMap(ApprovalNode::getId, n -> n)); + + // 批量查询审批人节点配置 + List approverNodes = approvalNodeApproverMapper.selectByIds(nextNodeIds); + Map approverNodeMap = approverNodes.stream() + .collect(Collectors.toMap(ApprovalNodeApprover::getId, n -> n)); + + // 批量查询条件节点配置 + List conditionNodes = approvalNodeConditionMapper.selectByIds(nextNodeIds); + Map conditionNodeMap = conditionNodes.stream() + .collect(Collectors.toMap(ApprovalNodeCondition::getId, n -> n)); + + // 组装节点响应 + return nextNodeIds.stream() + .map(nextNodeId -> { + ApprovalNode nextNode = nodeMap.get(nextNodeId); + if (nextNode == null) { + return null; + } + + // 审批人节点 + if (ApprovalNodeTypeEnum.APPROVER.name().equals(nextNode.getNodeType())) { + ApprovalNodeApprover approverNode = approverNodeMap.get(nextNodeId); + if (approverNode != null) { + ApprovalNodeApproverResponse approverResponse = BeanUtils.copyBean( + new ApprovalNodeApproverResponse(), nextNode); + BeanUtils.copyBean(approverResponse, approverNode); + // 解析 JSON 字段为对象(不需要前端回显,传null) + parseApproverNodeFields(approverNode, approverResponse); + return approverResponse; + } + } + + // 条件节点 + if (ApprovalNodeTypeEnum.CONDITION.name().equals(nextNode.getNodeType())) { + ApprovalNodeCondition conditionNode = conditionNodeMap.get(nextNodeId); + if (conditionNode != null) { + ApprovalNodeConditionResponse conditionResponse = BeanUtils.copyBean( + new ApprovalNodeConditionResponse(), nextNode); + // 手动解析条件配置(因为类型不同,BeanUtils 无法自动复制) + if (StringUtils.isNotBlank(conditionNode.getConditionConfig())) { + conditionResponse.setConditionConfig( + JSON.parseObject(conditionNode.getConditionConfig(), CombineSearch.class)); + } + return conditionResponse; + } + } + + // 其他类型节点 + return BeanUtils.copyBean(new ApprovalNodeResponse(), nextNode); + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + /** + * 根据权限ID查找对应的权限列表 + */ + private List findPermissionsByPermissionId(List permissionSetting, String permissionId) { + if (StringUtils.isBlank(permissionId) || CollectionUtils.isEmpty(permissionSetting)) { + return List.of(); + } + for (PermissionDefinitionItem module : permissionSetting) { + if (module.getChildren() != null) { + for (PermissionDefinitionItem resource : module.getChildren()) { + if (resource.getId().equals(permissionId)) { + return resource.getPermissions(); + } + } + } + } + return null; + } + + /** + * 获取表单下启用的审批流配置 + * + * @param formKey 表单类型 + * @param organizationId 组织ID + * @return 审批流配置,如果不存在或未启用返回null + */ + public ApprovalFlow getEnabledFlow(String formKey, String organizationId) { + if (StringUtils.isBlank(formKey) || StringUtils.isBlank(organizationId)) { + return null; + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ApprovalFlow::getFormType, formKey) + .eq(ApprovalFlow::getOrganizationId, organizationId) + .eq(ApprovalFlow::getEnable, true) + .eq(ApprovalFlow::getDeleted, false); + List approvalFlows = approvalFlowMapper.selectListByLambda(wrapper); + if (CollectionUtils.isEmpty(approvalFlows)) { + return null; + } + return approvalFlows.getFirst(); + } + + /** + * 根据审批人类型解析具体审批人用户列表 + * + * @param userId 当前用户ID + * @param approverType 审批人类型枚举 + * @param approverList 审批人值列表 + * @return 具体的用户列表,按照 approverList 顺序返回 + */ + public List resolveApprovers(String userId, String orgId, ApproverTypeEnum approverType, List approverList, ApproverDirectionEnum direction) { + if (StringUtils.isBlank(userId) || approverType == null || CollectionUtils.isEmpty(approverList)) { + return List.of(); + } + + // 获取当前用户的组织用户信息 + OrganizationUser currentUser = getOrganizationUser(userId, orgId); + return switch (approverType) { + case MEMBER -> resolveMemberApprovers(orgId, approverList); + case ROLE -> resolveRoleApprovers(orgId, approverList); + case SUPERIOR -> resolveSuperiorApprovers(orgId, currentUser, approverList, direction); + case MULTIPLE_SUPERIOR -> resolveMultipleSuperiorApprovers(orgId, currentUser, approverList, direction); + case DEPT_HEAD -> resolveDeptHeadApprovers(orgId, currentUser, approverList, direction); + case MULTIPLE_DEPT_HEAD -> resolveMultipleDeptHeadApprovers(orgId, currentUser, approverList, direction); + }; + } + + private ApproverDirectionEnum getApproverDirection(String direction) { + if (StringUtils.isBlank(direction)) { + return ApproverDirectionEnum.BOTTOM_UP; + } + try { + return ApproverDirectionEnum.valueOf(direction); + } catch (IllegalArgumentException e) { + return ApproverDirectionEnum.BOTTOM_UP; + } + } + + private OrganizationUser getOrganizationUser(String userId, String orgId) { + OrganizationUser criteria = new OrganizationUser(); + criteria.setUserId(userId); + criteria.setOrganizationId(orgId); + return organizationUserMapper.selectOne(criteria); + } + + /** + * 解析指定成员审批人 + */ + private List resolveMemberApprovers(String orgId, List approverList) { + if (CollectionUtils.isEmpty(approverList)) { + return List.of(); + } + List organizationUsers = organizationUserMapper.selectListByLambda(new LambdaQueryWrapper().in(OrganizationUser::getUserId, approverList)); + List users = extUserMapper.getOrgUserByUserIds(orgId, approverList); + Map userMap = users.stream().collect(Collectors.toMap(User::getId, u -> u)); + // 按照 approverList 顺序返回 + List resolveUsers = new ArrayList<>(); + approverList.forEach(approver -> { + Optional findUser = organizationUsers.stream().filter(u -> Strings.CI.equals(u.getUserId(), approver)).findAny(); + if (!userMap.containsKey(approver)) { + // 审批人被删除, 替换成ADMIN + User admin = new User(); + admin.setId(InternalUser.ADMIN.getValue()); + resolveUsers.add(admin); + return; + } + if (findUser.isPresent() && !findUser.get().getEnable()) { + // 审批人被禁用, 替换为直属上级 + if (StringUtils.isNotBlank(findUser.get().getSupervisorId())) { + User superUser = new User(); + superUser.setId(findUser.get().getSupervisorId()); + resolveUsers.add(superUser); + } else { + // 直属上级为空, 替换成ADMIN + User admin = new User(); + admin.setId(InternalUser.ADMIN.getValue()); + resolveUsers.add(admin); + } + return; + } + resolveUsers.add(userMap.get(approver)); + }); + return resolveUsers; + } + + /** + * 解析指定角色审批人 + */ + private List resolveRoleApprovers(String orgId, List roleIds) { + List userIds = extUserRoleMapper.getUserIdsByRoleIds(roleIds); + return resolveMemberApprovers(orgId, userIds); + } + + /** + * 解析指定上级审批人 + */ + private List resolveSuperiorApprovers(String orgId, OrganizationUser currentUser, List approverList, ApproverDirectionEnum direction) { + if (currentUser == null) { + return List.of(); + } + // 值是单选 + int approvalLevel = getValidLevel(approverList); + if (approvalLevel <= 0) { + return List.of(); + } + + List allSuperiorIds = getAllSuperiorIds(orgId, currentUser); + String approver = getLevelUserId(allSuperiorIds, approvalLevel, direction); + if (StringUtils.isBlank(approver)) { + return List.of(); + } + + return resolveMemberApprovers(orgId, List.of(approver)); + } + + /** + * 解析多级上级审批人 + */ + private List resolveMultipleSuperiorApprovers(String orgId, OrganizationUser currentUser, List approverList, ApproverDirectionEnum direction) { + if (currentUser == null) { + return List.of(); + } + int approvalLevel = getValidLevel(approverList); + if (approvalLevel <= 0) { + return List.of(); + } + + List allSuperiorIds = getAllSuperiorIds(orgId, currentUser); + if (allSuperiorIds.isEmpty()) { + return List.of(); + } + if (approvalLevel > allSuperiorIds.size()) { + return List.of(); + } + + List resultIds = getLevelUserIds(allSuperiorIds, approvalLevel, direction); + + return resolveMemberApprovers(orgId, resultIds); + } + + private List getAllSuperiorIds(String orgId, OrganizationUser currentUser) { + List allSuperiorIds = new ArrayList<>(); + String currentSupervisorId = currentUser.getSupervisorId(); + + while (StringUtils.isNotBlank(currentSupervisorId)) { + allSuperiorIds.add(currentSupervisorId); + OrganizationUser supervisorOrgUser = getOrganizationUser(currentSupervisorId, orgId); + if (supervisorOrgUser == null) { + break; + } + currentSupervisorId = supervisorOrgUser.getSupervisorId(); + } + return allSuperiorIds; + } + + private Integer getValidLevel(List approverList) { + // 使用 ApproverLevelEnum 验证并获取有效的层级值 + Set validLevels = Arrays.stream(ApproverLevelEnum.values()) + .map(ApproverLevelEnum::getValue) + .collect(Collectors.toSet()); + + // 过滤出有效的层级并按 approverList 顺序保存 + List validRequestedLevels = approverList.stream() + .filter(validLevels::contains) + .collect(Collectors.toList()); + + if (CollectionUtils.isEmpty(validRequestedLevels)) { + return 0; + } + + // 值是单选 + return Integer.parseInt(validRequestedLevels.getFirst()); + } + + /** + * 解析部门的负责人审批人 + */ + private List resolveDeptHeadApprovers(String orgId, OrganizationUser currentUser, List approverList, ApproverDirectionEnum direction) { + if (currentUser == null) { + return List.of(); + } + String departmentId = currentUser.getDepartmentId(); + if (StringUtils.isBlank(departmentId)) { + return List.of(); + } + + // 值是单选 + int approvalLevel = getValidLevel(approverList); + if (approvalLevel <= 0) { + return List.of(); + } + + List allCommanderIds = getAllDeptCommanderIds(orgId, departmentId); + String commanderId = getLevelUserId(allCommanderIds, approvalLevel, direction); + if (StringUtils.isBlank(commanderId)) { + return List.of(); + } + + return resolveMemberApprovers(orgId, List.of(commanderId)); + } + + private String getDeptCommander(String orgId, String departmentId) { + List commanderIds = extDepartmentCommanderMapper.selectCommander(departmentId, orgId); + if (CollectionUtils.isNotEmpty(commanderIds)) { + return commanderIds.getFirst(); + } + return null; + } + + /** + * 解析多级部门的负责人审批人 + */ + private List resolveMultipleDeptHeadApprovers(String orgId, OrganizationUser currentUser, List approverList, ApproverDirectionEnum direction) { + if (currentUser == null) { + return List.of(); + } + String departmentId = currentUser.getDepartmentId(); + if (StringUtils.isBlank(departmentId)) { + return List.of(); + } + + int approvalLevel = getValidLevel(approverList); + if (approvalLevel <= 0) { + return List.of(); + } + + List allCommanderIds = getAllDeptCommanderIds(orgId, departmentId); + if (allCommanderIds.isEmpty()) { + return List.of(); + } + if (approvalLevel > allCommanderIds.size()) { + return List.of(); + } + + List resultIds = getLevelUserIds(allCommanderIds, approvalLevel, direction); + + return resolveMemberApprovers(orgId, resultIds); + } + + private List getLevelUserIds(List bottomUpUserIds, int approvalLevel, ApproverDirectionEnum direction) { + if (CollectionUtils.isEmpty(bottomUpUserIds) || approvalLevel <= 0 || approvalLevel > bottomUpUserIds.size()) { + return List.of(); + } + List levelUserIds; + if (direction == ApproverDirectionEnum.TOP_DOWN) { + List topDownUserIds = new ArrayList<>(bottomUpUserIds); + Collections.reverse(topDownUserIds); + levelUserIds = topDownUserIds.subList(0, approvalLevel); + } else { + levelUserIds = bottomUpUserIds.subList(0, approvalLevel); + } + return levelUserIds.stream().filter(StringUtils::isNotBlank).toList(); + } + + private List getAllDeptCommanderIds(String orgId, String departmentId) { + List allCommanderIds = new ArrayList<>(); + String currentDepartmentId = departmentId; + + while (StringUtils.isNotBlank(currentDepartmentId)) { + Department department = departmentBaseMapper.selectByPrimaryKey(currentDepartmentId); + if (department == null) { + break; + } + String commanderId = getDeptCommander(orgId, currentDepartmentId); + allCommanderIds.add(commanderId); + currentDepartmentId = department.getParentId(); + } + return allCommanderIds; + } + + private String getLevelUserId(List bottomUpUserIds, int approvalLevel, ApproverDirectionEnum direction) { + if (CollectionUtils.isEmpty(bottomUpUserIds) || approvalLevel <= 0 || approvalLevel > bottomUpUserIds.size()) { + return null; + } + if (direction == ApproverDirectionEnum.TOP_DOWN) { + return bottomUpUserIds.get(bottomUpUserIds.size() - approvalLevel); + } + return bottomUpUserIds.get(approvalLevel - 1); + } + + /** + * 获取父部门ID + */ + private String getParentDepartmentId(String departmentId) { + Department department = departmentBaseMapper.selectByPrimaryKey(departmentId); + if (department != null) { + return department.getParentId(); + } + return null; + } + + /** + * 判断当前实例节点是否支持多人审批 + * + * @param currentNodeId 当前节点ID + * @param submitterId 提审人 + * @param currentOrgId 当前组织ID + * @return 是否支持多人审批 + */ + public boolean isCurrentNodeMultiApprover(String currentNodeId, String submitterId, String currentOrgId) { + ApprovalNodeApprover nodeApprover = approvalNodeApproverMapper.selectByPrimaryKey(currentNodeId); + List approvers = resolveApprovers(submitterId, currentOrgId, ApproverTypeEnum.valueOf(nodeApprover.getApproverType()), JSON.parseArray(nodeApprover.getApproverList(), String.class), getApproverDirection(nodeApprover.getApproverDirection())); + return approvers.size() > 1; + } + + /** + * 获取当前实例节点审批人 + * + * @param instance 审批实例 + * @param currentNodeId 当前节点ID + * @param currentOrgId 当前组织ID + * @return 审批人ID集合 + */ + public List getCurrentNodeApproverList(ApprovalInstance instance, String currentNodeId, String currentOrgId) { + ApprovalNodeApprover nodeApprover = approvalNodeApproverMapper.selectByPrimaryKey(currentNodeId); + if (nodeApprover == null) { + return new ArrayList<>(); + } + List approvers = resolveApprovers(instance.getSubmitterId(), currentOrgId, ApproverTypeEnum.valueOf(nodeApprover.getApproverType()), JSON.parseArray(nodeApprover.getApproverList(), String.class), getApproverDirection(nodeApprover.getApproverDirection())); + // 审批人为空时 + if (CollectionUtils.isEmpty(approvers)) { + if (EmptyApproverActionEnum.valueOf(nodeApprover.getEmptyApproverAction()) != EmptyApproverActionEnum.AUTO_PASS) { + // 指定人员, 审批管理员处理, 返回兜底审批人 + return List.of(nodeApprover.getFallbackApprover()); + } + } + + // 审批人与提交人同一人时 + Optional findSame = approvers.stream().filter(approver -> Strings.CS.equals(approver.getId(), instance.getSubmitterId())).findAny(); + if (SameSubmitterActionEnum.valueOf(nodeApprover.getSameSubmitterAction()) == SameSubmitterActionEnum.ASSIGN_SUPERIOR && findSame.isPresent()) { + // 转交给直属上级审批 + OrganizationUser criteria = new OrganizationUser(); + criteria.setUserId(findSame.get().getId()); + criteria.setOrganizationId(currentOrgId); + OrganizationUser currentUser = organizationUserMapper.selectOne(criteria); + if (currentUser != null && StringUtils.isNotEmpty(currentUser.getSupervisorId())) { + // 替换审批人列表中与提审人相同审批人 => 直属上级 + String supervisorId = currentUser.getSupervisorId(); + return approvers.stream() + .map(approver -> Strings.CS.equals(approver.getId(), instance.getSubmitterId()) ? supervisorId : approver.getId()) + .collect(Collectors.toList()); + } + } + return approvers.stream().map(User::getId).toList(); + } + + /** + * 获取当前实例节点审批人 + * + * @param approverType 审批类型 + * @param approverList 审批人集合 + * @param userId 用户ID + * @param currentOrgId 当前组织ID + * @return 审批人ID集合 + */ + public List getCurrentNodeApproverList(String approverType, List approverList, String userId, String currentOrgId, String approverDirection) { + return resolveApprovers(userId, currentOrgId, ApproverTypeEnum.valueOf(approverType), approverList, getApproverDirection(approverDirection)); + } + + /** + * 获取当前实例节点抄送人 + * + * @param currentNodeId 当前节点ID + * @param userId 用户ID + * @param currentOrgId 当前组织ID + * @return 审批人ID集合 + */ + public List getCurrentNodeCcList(String currentNodeId, String userId, String currentOrgId) { + ApprovalNodeApprover nodeApprover = approvalNodeApproverMapper.selectByPrimaryKey(currentNodeId); + if (StringUtils.isBlank(nodeApprover.getCcType())) { + return new ArrayList<>(); + } + return resolveApprovers(userId, currentOrgId, ApproverTypeEnum.valueOf(nodeApprover.getCcType()), JSON.parseArray(nodeApprover.getCcList(), String.class), getApproverDirection(nodeApprover.getCcDirection())); + } + + /** + * 获取当前实例节点抄送人 + * + * @param ccType 抄送类型 + * @param ccList 抄送人集合 + * @param userId 用户ID + * @param currentOrgId 当前组织ID + * @return 抄送人ID集合 + */ + public List getCurrentNodeCcList(String ccType, List ccList, String userId, String currentOrgId, String approverDirection) { + if (StringUtils.isBlank(ccType)) { + return List.of(); + } + return resolveApprovers(userId, currentOrgId, ApproverTypeEnum.valueOf(ccType), ccList, getApproverDirection(approverDirection)); + } + + /** + * 获取当前资源审批实例第一个节点 + * + * @param instance 审批实例 + * @param currentOrgId 组织ID + * @return 第一个节点 + */ + public ApprovalNodeResponse getResourceApprovalInstanceFirstNode(ApprovalInstance instance, String currentOrgId) { + ApprovalNode nodeCriteria = new ApprovalNode(); + nodeCriteria.setFlowVersionId(instance.getFlowVersionId()); + nodeCriteria.setNodeType(ApprovalNodeTypeEnum.START.name()); + nodeCriteria.setExecuteTime(StringUtils.isNotBlank(instance.getExecuteTime()) ? instance.getExecuteTime() : ExecuteTimingEnum.CREATE.name()); + ApprovalNode start = approvalNodeMapper.selectOne(nodeCriteria); + List resourceFvs = formService.compressResourceDetail(instance.getType(), instance.getResourceId()); + return getNextNodeWithExceptionHandler(instance, start.getId(), resourceFvs, currentOrgId, false); + } + + /** + * 获取当前待办任务下一个节点 + * + * @param currentTask 待办任务 + * @return 下一个节点 + */ + public ApprovalNodeResponse getTaskNextNode(ApprovalTask currentTask, ApprovalInstance instance, String currentOrgId) { + List resourceFvs = formService.compressResourceDetail(instance.getType(), instance.getResourceId()); + return getNextNodeWithExceptionHandler(instance, currentTask.getNodeId(), resourceFvs, currentOrgId, false); + } + + /** + * 获取当前节点下一个节点 + * + * @param currentNodeId 当前节点ID + * @return 下一个节点 + */ + public ApprovalNodeResponse getCurrentNextNode(String currentNodeId, ApprovalInstance instance, String currentOrgId) { + List resourceFvs = formService.compressResourceDetail(instance.getType(), instance.getResourceId()); + return getNextNodeWithExceptionHandler(instance, currentNodeId, resourceFvs, currentOrgId, false); + } + + /** + * 获取实例当前节点后续所有审批节点 + * + * @param instance 当前节点ID + * @return 后续所有审批节点 + */ + public List getInstanceCurrentFollowNode(ApprovalInstance instance, String currentOrgId) { + List nodes = new ArrayList<>(); + List resourceFvs = formService.compressResourceDetail(instance.getType(), instance.getResourceId()); + String currentNodeId = instance.getCurrentNodeId(); + if (currentNodeId.contains(SIGN_SPILT)) { + currentNodeId = currentNodeId.split(SIGN_SPILT)[0]; + } + ApprovalNodeResponse next = getNextNodeWithExceptionHandler(instance, currentNodeId, resourceFvs, currentOrgId, true); + while (ApprovalNodeTypeEnum.valueOf(next.getNodeType()) == ApprovalNodeTypeEnum.APPROVER) { + nodes.add((ApprovalNodeApproverResponse) next); + next = getNextNodeWithExceptionHandler(instance, next.getId(), resourceFvs, currentOrgId, true); + } + return nodes; + } + + /** + * 获取实例下一个节点,包含异常处理和自动审批, 跳过条件节点 + * + * @param instance 审批实例 + * @param nodeId 节点ID + * @param fieldValues 资源业务字段值 + * @param currentOrgId 当前组织ID + * @return 下一个节点 (结束节点或者审批节点) + */ + private ApprovalNodeResponse getNextNodeWithExceptionHandler(ApprovalInstance instance, String nodeId, List fieldValues, String currentOrgId, boolean preview) { + // 加载修改字段列表,用于 NOT_EQUAL_ORIGINAL 条件判断 + Set updateFields = loadUpdateFields(instance.getUpdateFields()); + ApprovalNodeResponse nextNode = getNextNode(nodeId, fieldValues, updateFields); + if (nextNode == null) { + throw new GenericException(Translator.get("no.approval.next.node")); + } + if (ApprovalNodeTypeEnum.valueOf(nextNode.getNodeType()) == ApprovalNodeTypeEnum.END) { + return nextNode; + } + + if (ApprovalNodeTypeEnum.valueOf(nextNode.getNodeType()) == ApprovalNodeTypeEnum.APPROVER) { + if (preview) { + return previewNextApproverNodeWithExceptionHandler(instance, nextNode, currentOrgId); + } else { + Integer round = extApprovalInstanceMapper.getNodeRound(instance.getId(), nodeId); + List approvedTasks = approvalTaskMapper.selectListByLambda(new LambdaQueryWrapper().eq(ApprovalTask::getInstanceId, instance.getId()) + .eq(ApprovalTask::getNodeId, nodeId).eq(ApprovalTask::getNodeRound, round).eq(ApprovalTask::getStatus, ApprovalStatus.APPROVED)); + List preApprovers = approvedTasks.stream().map(ApprovalTask::getApproverId).toList(); + return handleNextApproverNodeWithExceptionHandler(instance, nextNode, fieldValues, currentOrgId, preApprovers); + } + } + // 条件类型节点, 继续往下获取 + return getNextNodeWithExceptionHandler(instance, nextNode.getId(), fieldValues, currentOrgId, preview); + } + + /** + * 处理实例下一个审批节点,包含异常处理 + * + * @param instance 审批实例 + * @param nextNode 下一个审批节点 + * @param fieldValues 资源业务字段值 + * @param currentOrgId 当前组织ID + * @return 下一个节点 (审批节点) + */ + private ApprovalNodeResponse handleNextApproverNodeWithExceptionHandler(ApprovalInstance instance, ApprovalNodeResponse nextNode, + List fieldValues, String currentOrgId, List preApprovers) { + ApprovalNodeApproverResponse nextApproverNode = (ApprovalNodeApproverResponse) nextNode; + if (ApprovalTypeEnum.valueOf(nextApproverNode.getApprovalType()) == ApprovalTypeEnum.AUTO_PASS) { + // 自动通过, 插入审批记录, 获取下一个节点 + saveAutoRecord(instance.getId(), nextApproverNode.getId(), ApprovalStatus.AUTO_APPROVED, null, null, nextApproverNode.getCcList(), true, null); + updateApprovalPostField(instance, nextApproverNode.getId(), ApprovalAction.APPROVE, InternalUser.ADMIN.getValue()); + return getNextNodeWithExceptionHandler(instance, nextApproverNode.getId(), fieldValues, currentOrgId, false); + } + if (ApprovalTypeEnum.valueOf(nextApproverNode.getApprovalType()) == ApprovalTypeEnum.AUTO_REJECT) { + // 自动驳回, 插入审批记录 + saveAutoRecord(instance.getId(), nextApproverNode.getId(), ApprovalStatus.AUTO_UNAPPROVED, null, null, nextApproverNode.getCcList(), true, null); + updateApprovalPostField(instance, nextApproverNode.getId(), ApprovalAction.REJECT, InternalUser.ADMIN.getValue()); + ApprovalNodeExceptionResponse exNode = BeanUtils.copyBean(new ApprovalNodeExceptionResponse(), nextApproverNode); + exNode.setNodeType(ApprovalNodeTypeEnum.EXCEPTION.name()); + return exNode; + } + + // 人工审批, 异常处理 + setNodeApproverAndCcList(nextApproverNode, instance.getSubmitterId(), currentOrgId); + + // 审批人为空时 + if (CollectionUtils.isEmpty(nextApproverNode.getApproverList())) { + if (EmptyApproverActionEnum.valueOf(nextApproverNode.getEmptyApproverAction()) == EmptyApproverActionEnum.AUTO_PASS) { + // 自动通过, 插入审批记录, 获取下一个节点 + saveAutoRecord(instance.getId(), nextApproverNode.getId(), ApprovalStatus.AUTO_APPROVED, "审批人为空,自动通过", null, nextApproverNode.getCcList(), true, null); + updateApprovalPostField(instance, nextApproverNode.getId(), ApprovalAction.APPROVE, InternalUser.ADMIN.getValue()); + return getNextNodeWithExceptionHandler(instance, nextApproverNode.getId(), fieldValues, currentOrgId, false); + } else { + // 指定人员, 审批管理员处理, 返回兜底审批人 + nextApproverNode.setApproverType(ApproverTypeEnum.MEMBER.name()); + nextApproverNode.setApproverList(List.of(nextApproverNode.getFallbackApprover())); + } + } + + // 审批人与提交人同一人时 + Optional findSame = nextApproverNode.getApproverList().stream().filter(approver -> Strings.CS.equals(approver, instance.getSubmitterId())).findAny(); + if (findSame.isPresent()) { + SameSubmitterActionEnum sameAction = SameSubmitterActionEnum.valueOf(nextApproverNode.getSameSubmitterAction()); + switch (sameAction) { + case ASSIGN_SUPERIOR -> { + // 转交给直属上级审批 + OrganizationUser criteria = new OrganizationUser(); + criteria.setUserId(findSame.get()); + criteria.setOrganizationId(currentOrgId); + OrganizationUser currentUser = organizationUserMapper.selectOne(criteria); + if (currentUser != null && StringUtils.isNotEmpty(currentUser.getSupervisorId())) { + // 替换审批人列表中与提审人相同审批人 => 直属上级 + String supervisorId = currentUser.getSupervisorId(); + List newApproverList = nextApproverNode.getApproverList().stream() + .map(approver -> Strings.CS.equals(approver, instance.getSubmitterId()) ? supervisorId : approver) + .collect(Collectors.toList()); + nextApproverNode.setApproverList(newApproverList); + } else { + // 不存在直属上级, 自动跳过 + ApprovalTask autoTask = saveAutoSkipTask(instance.getId(), nextApproverNode.getId(), findSame.get()); + saveAutoRecord(instance.getId(), nextApproverNode.getId(), ApprovalStatus.AUTO_APPROVED, "审批人与提交人为同一人时, 直属上级为空, 自动通过", autoTask.getId(), nextApproverNode.getCcList(), true, autoTask.getNodeRound()); + updateApprovalPostField(instance, nextApproverNode.getId(), ApprovalAction.APPROVE, InternalUser.ADMIN.getValue()); + return getNextNodeWithExceptionHandler(instance, nextApproverNode.getId(), fieldValues, currentOrgId, false); + } + } + case SKIP -> { + // 自动跳过 + if (nextApproverNode.getApproverList().size() == 1 || MultiApproverModeEnum.valueOf(nextApproverNode.getMultiApproverMode()) == MultiApproverModeEnum.ANY) { + // 如果刚好为单人审批, 多人或签, 直接插入待办任务和记录, 流转到下一个节点 + ApprovalTask autoTask = saveAutoSkipTask(instance.getId(), nextApproverNode.getId(), findSame.get()); + saveAutoRecord(instance.getId(), nextApproverNode.getId(), ApprovalStatus.AUTO_APPROVED, "审批人与提交人为同一人时, 自动通过", autoTask.getId(), nextApproverNode.getCcList(), true, autoTask.getNodeRound()); + updateApprovalPostField(instance, nextApproverNode.getId(), ApprovalAction.APPROVE, InternalUser.ADMIN.getValue()); + return getNextNodeWithExceptionHandler(instance, nextApproverNode.getId(), fieldValues, currentOrgId, false); + } + } + default -> { + + } + } + } + + return handleNextNodeDuplicateApprover(instance, nextApproverNode, fieldValues, currentOrgId, preApprovers); + } + + /** + * 处理下一个节点 (审批人重复跳过) + * @param instance 审批实例 + * @param nextApproverNode 下一个审批节点 + * @param fieldValues 资源字段值 + * @param currentOrgId 当前组织ID + * @return 下一个节点 + */ + private ApprovalNodeResponse handleNextNodeDuplicateApprover(ApprovalInstance instance, ApprovalNodeApproverResponse nextApproverNode, List fieldValues, String currentOrgId, List preApprovers) { + // 重复审批人处理 + DuplicateApproverRuleEnum duplicateApproverRuleEnum = getFlowDuplicateApproverRule(instance.getFlowVersionId()); + List autoSkipUser = getFlowAutoSkipUser(instance, nextApproverNode.getId(), preApprovers); + if (duplicateApproverRuleEnum == DuplicateApproverRuleEnum.FIRST_ONLY || duplicateApproverRuleEnum == DuplicateApproverRuleEnum.SEQUENTIAL_ALL) { + // 仅首个节点需要审批, 后续节点自动同意 (当前节点审批人在之前在有不同的节点审批过待办任务) + Optional anySkip = nextApproverNode.getApproverList().stream().filter(autoSkipUser::contains).findAny(); + if (anySkip.isPresent()) { + if (nextApproverNode.getApproverList().size() == 1 || MultiApproverModeEnum.valueOf(nextApproverNode.getMultiApproverMode()) == MultiApproverModeEnum.ANY) { + // 如果为单人审批, 或签, 直接同意, 且流转到下一个节点 + ApprovalTask autoTask = saveAutoSkipTask(instance.getId(), nextApproverNode.getId(), anySkip.get()); + saveAutoRecord(instance.getId(), nextApproverNode.getId(), ApprovalStatus.AUTO_APPROVED, "审批人重复出现, 后续节点自动通过", autoTask.getId(), nextApproverNode.getCcList(), true, autoTask.getNodeRound()); + updateApprovalPostField(instance, nextApproverNode.getId(), ApprovalAction.APPROVE, InternalUser.ADMIN.getValue()); + return getNextNodeWithExceptionHandler(instance, nextApproverNode.getId(), fieldValues, currentOrgId, false); + } + } + } + return nextApproverNode; + } + + /** + * 获取当前审批流重复审批人配置 + * + * @param flowVersionId 审批流版本 + * @return 重复审批人配置 + */ + public DuplicateApproverRuleEnum getFlowDuplicateApproverRule(String flowVersionId) { + ApprovalFlowVersion flowVersion = approvalFlowVersionMapper.selectByPrimaryKey(flowVersionId); + ApprovalFlow approvalFlow = approvalFlowMapper.selectByPrimaryKey(flowVersion.getFlowId()); + return DuplicateApproverRuleEnum.valueOf(approvalFlow.getDuplicateApproverRule()); + } + + /** + * 预览实例下一个审批节点,包含异常处理 + * + * @param instance 审批实例 + * @param nextNode 下一个审批节点 + * @param currentOrgId 当前组织ID + * @return 下一个节点 (审批节点) + */ + private ApprovalNodeResponse previewNextApproverNodeWithExceptionHandler(ApprovalInstance instance, ApprovalNodeResponse nextNode, String currentOrgId) { + ApprovalNodeApproverResponse nextApproverNode = (ApprovalNodeApproverResponse) nextNode; + if (ApprovalTypeEnum.valueOf(nextApproverNode.getApprovalType()) == ApprovalTypeEnum.AUTO_PASS || ApprovalTypeEnum.valueOf(nextApproverNode.getApprovalType()) == ApprovalTypeEnum.AUTO_REJECT) { + // 自动节点, 直接返回 + return nextApproverNode; + } + + // 人工审批, 异常处理 + setNodeApproverAndCcList(nextApproverNode, instance.getSubmitterId(), currentOrgId); + + // 审批人为空时 + if (CollectionUtils.isEmpty(nextApproverNode.getApproverList()) && EmptyApproverActionEnum.valueOf(nextApproverNode.getEmptyApproverAction()) != EmptyApproverActionEnum.AUTO_PASS) { + // 指定人员, 审批管理员处理, 返回兜底审批人 + nextApproverNode.setApproverType(ApproverTypeEnum.MEMBER.name()); + nextApproverNode.setApproverList(List.of(nextApproverNode.getFallbackApprover())); + } + + // 审批人与提交人同一人时 + Optional findSame = nextApproverNode.getApproverList().stream().filter(approver -> Strings.CS.equals(approver, instance.getSubmitterId())).findAny(); + if (findSame.isEmpty()) { + return nextApproverNode; + } + if (SameSubmitterActionEnum.valueOf(nextApproverNode.getSameSubmitterAction()) == SameSubmitterActionEnum.ASSIGN_SUPERIOR) { + // 转交给直属上级审批 + OrganizationUser criteria = new OrganizationUser(); + criteria.setUserId(findSame.get()); + criteria.setOrganizationId(currentOrgId); + OrganizationUser currentUser = organizationUserMapper.selectOne(criteria); + if (currentUser != null && StringUtils.isNotEmpty(currentUser.getSupervisorId())) { + // 替换审批人列表中与提审人相同审批人 => 直属上级 + String supervisorId = currentUser.getSupervisorId(); + List newApproverList = nextApproverNode.getApproverList().stream() + .map(approver -> Strings.CS.equals(approver, instance.getSubmitterId()) ? supervisorId : approver) + .collect(Collectors.toList()); + nextApproverNode.setApproverList(newApproverList); + } + } + + return nextApproverNode; + } + + /** + * 自动跳过的待办任务 + * + * @param instanceId 实例ID + * @param nodeId 节点ID + */ + private ApprovalTask saveAutoSkipTask(String instanceId, String nodeId, String approver) { + Integer nextRound = extApprovalInstanceMapper.getNextNodeRound(instanceId, nodeId); + ApprovalTask task = new ApprovalTask(); + task.setId(IDGenerator.nextStr()); + task.setInstanceId(instanceId); + task.setNodeId(nodeId); + task.setNodeRound(nextRound); + task.setApproverId(approver); + task.setType(ApprovalTaskType.NL.name()); + task.setStatus(ApprovalStatus.AUTO_APPROVED.name()); + task.setAction(ApprovalAction.APPROVE.name()); + task.setCreateTime(System.currentTimeMillis()); + task.setCreateUser(InternalUser.ADMIN.getValue()); + task.setUpdateTime(System.currentTimeMillis()); + task.setUpdateUser(InternalUser.ADMIN.getValue()); + approvalTaskMapper.insert(task); + return task; + } + + /** + * 自动审批的记录 + * + * @param instanceId 实例ID + * @param nodeId 自动审批的节点ID + * @param approvalStatus 审批状态 + */ + public void saveAutoRecord(String instanceId, String nodeId, ApprovalStatus approvalStatus, String comment, String taskId, List ccList, boolean sendCc, Integer nodeRound) { + if (nodeRound == null) { + nodeRound = extApprovalInstanceMapper.getNextNodeRound(instanceId, nodeId); + } + + ApprovalRecord record = new ApprovalRecord(); + record.setId(IDGenerator.nextStr()); + if (StringUtils.isNotBlank(taskId)) { + record.setTaskId(taskId); + } + record.setInstanceId(instanceId); + record.setNodeId(nodeId); + record.setNodeRound(nodeRound); + record.setResult(approvalStatus.name()); + if (StringUtils.isBlank(comment)) { + record.setComment(approvalStatus == ApprovalStatus.AUTO_APPROVED ? Translator.get("auto.approval.passed") : Translator.get("auto.approval.rejected")); + } else { + record.setComment(comment); + } + record.setCreateTime(System.currentTimeMillis()); + record.setCreateUser(InternalUser.ADMIN.getValue()); + record.setUpdateTime(System.currentTimeMillis()); + record.setUpdateUser(InternalUser.ADMIN.getValue()); + approvalRecordMapper.insert(record); + if (sendCc && CollectionUtils.isNotEmpty(ccList)) { + // 节点执行完成, 发送抄送 + ApprovalActionService approvalActionService = CommonBeanFactory.getBean(ApprovalActionService.class); + if (approvalActionService != null) { + List ccTasks = approvalActionService.getNodeCcTasks(nodeId, ccList, instanceId, InternalUser.ADMIN.getValue()); + approvalTaskMapper.batchInsert(ccTasks); + } + } + } + + /** + * 获取审批流自动跳过的审批人ID集合 + * + * @param instance 审批实例 + * @param currentNodeId 当前节点ID + * @param preApprovers 上一个节点的审批人集合 + * @return 审批人ID集合 + */ + public List getFlowAutoSkipUser(ApprovalInstance instance, String currentNodeId, List preApprovers) { + List autoSkipUserIds = new ArrayList<>(); + DuplicateApproverRuleEnum duplicateApproverRuleEnum = getFlowDuplicateApproverRule(instance.getFlowVersionId()); + if (duplicateApproverRuleEnum == DuplicateApproverRuleEnum.FIRST_ONLY) { + // 仅首个节点需审批(之前审批过的), 后续审批节点跳过 + List alreadyApprovedTasks = approvalTaskMapper.selectListByLambda(new LambdaQueryWrapper() + .eq(ApprovalTask::getInstanceId, instance.getId()).eq(ApprovalTask::getStatus, ApprovalStatus.APPROVED.name())); + autoSkipUserIds = alreadyApprovedTasks.stream().filter(task -> !Strings.CI.equals(task.getNodeId(), currentNodeId) && task.getNodeRound() != -1).map(ApprovalTask::getApproverId).toList(); + } else if (duplicateApproverRuleEnum == DuplicateApproverRuleEnum.SEQUENTIAL_ALL) { + // 连续审批人跳过 (上一个连续审批人) + autoSkipUserIds.addAll(preApprovers); + } + return autoSkipUserIds; + } + + /** + * 设置节点的审批人集合和抄送人集合 + * + * @param approverNode 审批节点 + * @param submitter 提交人 + * @param currentOrgId 当前组织ID + */ + private void setNodeApproverAndCcList(ApprovalNodeApproverResponse approverNode, String submitter, String currentOrgId) { + if (approverNode == null) { + return; + } + List nextApprovers = getCurrentNodeApproverList(approverNode.getApproverType(), approverNode.getApproverList(), submitter, currentOrgId, approverNode.getApproverDirection()); + approverNode.setApproverList(nextApprovers.stream().map(User::getId).distinct().collect(Collectors.toList())); + List nextCcList = getCurrentNodeCcList(approverNode.getCcType(), approverNode.getCcList(), submitter, currentOrgId, approverNode.getCcDirection()); + approverNode.setCcList(nextCcList.stream().map(User::getId).distinct().collect(Collectors.toList())); + } + + /** + * 审批后操作 (字段更新) + * + * @param instance 审批实例 + * @param currentNodeId 当前字段ID + * @param action 审批动作 + */ + public void updateApprovalPostField(ApprovalInstance instance, String currentNodeId, ApprovalAction action, String currentUserId) { + ApprovalResourceService resourceService = CommonBeanFactory.getBean(ApprovalResourceService.class); + if (resourceService != null) { + ApprovalNodeApprover approvalNodeApprover = approvalNodeApproverMapper.selectByPrimaryKey(currentNodeId); + List resourceFvs = formService.compressResourceRefDetail(instance.getType(), instance.getResourceId()); + resourceService.sendWebHook(action == ApprovalAction.APPROVE ? approvalNodeApprover.getPassPostConfig() : approvalNodeApprover.getRejectPostConfig(), resourceFvs, instance); + resourceService.updateApprovalPostField(FormKey.ofKey(instance.getType()), instance.getResourceId(), + action == ApprovalAction.APPROVE ? approvalNodeApprover.getPassPostConfig() : approvalNodeApprover.getRejectPostConfig(), currentUserId); + } + } + + + /** + * 测试连接 + * + * @param webHookConfig + */ + public void testConnection(WebHookConfig webHookConfig) { + ApprovalResourceService resourceService = CommonBeanFactory.getBean(ApprovalResourceService.class); + resourceService.testConnect(webHookConfig); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalInstanceService.java b/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalInstanceService.java new file mode 100644 index 0000000..c279541 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalInstanceService.java @@ -0,0 +1,729 @@ +package cn.cordys.crm.approval.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.approval.constants.*; +import cn.cordys.crm.approval.domain.*; +import cn.cordys.crm.approval.dto.*; +import cn.cordys.crm.approval.dto.response.ApprovalNodeApproverResponse; +import cn.cordys.crm.system.domain.Attachment; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.UserSimple; +import cn.cordys.crm.system.service.AttachmentService; +import cn.cordys.crm.system.service.UserExtendService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.ListUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class ApprovalInstanceService { + + @Resource + private BaseMapper approvalInstanceMapper; + @Resource + private BaseMapper approvalTaskMapper; + @Resource + private BaseMapper approvalAddSignTaskMapper; + @Resource + private BaseMapper approvalReturnBackRecordMapper; + @Resource + private BaseMapper approvalRecordMapper; + @Resource + private BaseMapper approvalInstanceAttachmentMapper; + @Resource + private BaseMapper attachmentMapper; + @Resource + private BaseMapper flowVersionMapper; + @Resource + private AttachmentService attachmentService; + @Resource + private UserExtendService userExtendService; + @Resource + private BaseMapper approvalNodeApproverMapper; + @Resource + private BaseMapper approvalNodeMapper; + @Resource + @Lazy + private ApprovalFlowService approvalFlowService; + + public static final String SIGN_SPILT = "-SN"; + public static final String NULL_FIELD_PERMISSION_STR = "null"; + + /** + * 获取资源最新审批实例详情 + * @param resourceId 资源ID + * @return 审批实例详情 + */ + public ApprovalInstanceDetail getLatestApprovalInstanceDetail(String resourceId, String currentOrgId) { + ApprovalInstance latestInstance = getLatestInstance(resourceId); + if (latestInstance == null) { + return null; + } + Map simpleUserMap = userExtendService.getAllUserSimpleMap(); + ApprovalInstanceDetail instanceDetail = BeanUtils.copyBean(new ApprovalInstanceDetail(), latestInstance); + if (simpleUserMap.containsKey(instanceDetail.getSubmitterId())) { + instanceDetail.setSubmitAvatar(simpleUserMap.get(instanceDetail.getSubmitterId()).getAvatar()); + instanceDetail.setSubmitter(simpleUserMap.get(instanceDetail.getSubmitterId()).getName()); + } + List tasks = getAllTasks(latestInstance); + List records = getAllRecords(latestInstance); + List signTasks = getAllSignTask(tasks.stream().filter(task -> ApprovalTaskType.valueOf(task.getType()) == ApprovalTaskType.NL || + ApprovalTaskType.valueOf(task.getType()) == ApprovalTaskType.BK).map(ApprovalTask::getId).toList()); + List backRecords = getBackRecords(latestInstance); + Map> elementAttachmentsMap = queryAttachments(records, signTasks, backRecords); + instanceDetail.setNodes(buildApprovalRecordNodeList(latestInstance, tasks, records, signTasks, backRecords, elementAttachmentsMap, simpleUserMap, currentOrgId)); + instanceDetail.setCurrentNodeId(latestInstance.getCurrentNodeId()); + instanceDetail.setComment(latestInstance.getComment()); + return setCurrentNodeFieldPermissions(instanceDetail); + } + + /** + * 设置当前节点审批表单权限 + * @param instanceDetail 实例详情 + * @return instanceDetail 实例详情 + */ + private ApprovalInstanceDetail setCurrentNodeFieldPermissions(ApprovalInstanceDetail instanceDetail) { + String currentNodeId = instanceDetail.getCurrentNodeId(); + if (instanceDetail.getCurrentNodeId().contains(SIGN_SPILT)) { + currentNodeId = instanceDetail.getCurrentNodeId().split(SIGN_SPILT)[0]; + } + ApprovalNodeApprover currentNode = approvalNodeApproverMapper.selectByPrimaryKey(currentNodeId); + if (currentNode == null) { + return instanceDetail; + } + if (instanceDetail.getCurrentNodeId().contains(SIGN_SPILT)) { + // 加签字段 + if (StringUtils.isNotBlank(currentNode.getFieldPermissions()) && !Strings.CI.equals(currentNode.getFieldPermissions(), NULL_FIELD_PERMISSION_STR)) { + List fieldPermissions = JSON.parseArray(currentNode.getFieldPermissions(), FieldPermissionDTO.class); + List viewPermissionsOfSign = fieldPermissions.stream().peek(permission -> permission.setPermissionType(FieldPermissionTypeEnum.VIEW.name())).toList(); + instanceDetail.setCurrentNodeFieldPermissions(JSON.toJSONString(viewPermissionsOfSign)); + } + } else { + instanceDetail.setCurrentNodeFieldPermissions(Strings.CI.equals(currentNode.getFieldPermissions(), NULL_FIELD_PERMISSION_STR) ? null : currentNode.getFieldPermissions()); + } + return instanceDetail; + } + + /** + * 获取资源最新审批实例 + */ + public ApprovalInstance getLatestInstance(String resourceId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ApprovalInstance::getResourceId, resourceId); + List list = approvalInstanceMapper.selectListByLambda(wrapper); + return CollectionUtils.isEmpty(list) ? null : list.stream().sorted(Comparator.comparing(ApprovalInstance::getSubmitTime)).toList().getLast(); + } + + /** + * 获取一批资源最新审批实例 + */ + public List getLatestInstances(List resourceIds) { + if (CollectionUtils.isEmpty(resourceIds)) { + return Collections.emptyList(); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(ApprovalInstance::getResourceId, resourceIds); + List list = approvalInstanceMapper.selectListByLambda(wrapper); + if (CollectionUtils.isEmpty(list)) { + return Collections.emptyList(); + } + Map> groupMap = list.stream().collect(Collectors.groupingBy(ApprovalInstance::getResourceId)); + return groupMap.values().stream().map(instances -> instances.stream() + .sorted(Comparator.comparing(ApprovalInstance::getSubmitTime).reversed()) + .toList().getFirst()).toList(); + } + + /** + * 获取所有的任务列表 + */ + private List getAllTasks(ApprovalInstance latestInstance) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ApprovalTask::getInstanceId, latestInstance.getId()).nq(ApprovalTask::getNodeRound, -1); + return approvalTaskMapper.selectListByLambda(wrapper); + } + + /** + * 获取所有的加签任务列表 + */ + private List getAllSignTask(List taskIds) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(ApprovalAddSignTask::getRootTaskId, taskIds); + return approvalAddSignTaskMapper.selectListByLambda(wrapper); + } + + /** + * 获取所有的记录列表 + */ + private List getAllRecords(ApprovalInstance latestInstance) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ApprovalRecord::getInstanceId, latestInstance.getId()).nq(ApprovalRecord::getNodeRound, -1); + return approvalRecordMapper.selectListByLambda(wrapper); + } + + /** + * 获取回退记录集合 + * @param latestInstance 审批实例 + * @return 回退记录集合 + */ + private List getBackRecords(ApprovalInstance latestInstance) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ApprovalReturnBackRecord::getInstanceId, latestInstance.getId()); + return approvalReturnBackRecordMapper.selectListByLambda(wrapper); + } + + /** + * 查询附件信息并按节点ID分组 + */ + private Map> queryAttachments(List records, List signTasks, List backRecords) { + List elementIds = new ArrayList<>(records.stream().map(ApprovalRecord::getId).toList()); + if (CollectionUtils.isNotEmpty(signTasks)) { + elementIds.addAll(signTasks.stream().map(ApprovalAddSignTask::getId).toList()); + } + if (CollectionUtils.isNotEmpty(backRecords)) { + elementIds.addAll(backRecords.stream().map(ApprovalReturnBackRecord::getId).toList()); + } + if (CollectionUtils.isEmpty(elementIds)) { + return Map.of(); + } + List attachments = approvalInstanceAttachmentMapper.selectListByLambda( + new LambdaQueryWrapper().in(ApprovalInstanceAttachment::getElementId, elementIds)); + if (CollectionUtils.isEmpty(attachments)) { + return Map.of(); + } + + List attachmentIds = attachments.stream().map(ApprovalInstanceAttachment::getAttachmentId).distinct().toList(); + List attachmentList = attachmentMapper.selectByIds(attachmentIds); + List createUserIds = attachmentList.stream().map(Attachment::getCreateUser).toList(); + List updateUserIds = attachmentList.stream().map(Attachment::getUpdateUser).toList(); + List users = userExtendService.getUserOptionByIds(ListUtils.union(createUserIds, updateUserIds)); + Map userMap = users.stream().collect(Collectors.toMap(User::getId, User::getName)); + attachmentList.forEach(attachment -> { + attachment.setCreateUser(userMap.get(attachment.getCreateUser())); + attachment.setUpdateUser(userMap.get(attachment.getUpdateUser())); + }); + + Map attachmentMap = attachmentList.stream().collect(Collectors.toMap(Attachment::getId, a -> a, (v1, v2) -> v1)); + + return attachments.stream().filter(a -> attachmentMap.containsKey(a.getAttachmentId())) + .collect(Collectors.groupingBy(ApprovalInstanceAttachment::getElementId, Collectors.mapping(a -> attachmentMap.get(a.getAttachmentId()), Collectors.toList()))); + } + + /** + * 构建审批记录节点列表 + * @param tasks 审批任务列表 + * @param records 记录列表 + * @param signTasks 加签任务集合 + * @param backRecords 退回记录集合 + * @param attachmentMap 附件集合 + * @param simpleUserMap 用户信息集合 + * @return 审批记录节点列表 + */ + private List buildApprovalRecordNodeList(ApprovalInstance instance, List tasks, List records, List signTasks, + List backRecords, Map> attachmentMap, Map simpleUserMap, String currentOrgId) { + // 处理过的节点 + List processedApprovalNodes = buildProcessedApprovalNodes(instance, tasks, records, signTasks, backRecords, attachmentMap, simpleUserMap, currentOrgId); + // 未来节点 + List pendingApprovalNodes = buildPendingApprovalNodes(instance, simpleUserMap, currentOrgId); + List nodes = new ArrayList<>(ListUtils.union(processedApprovalNodes, pendingApprovalNodes)); + // 处理结束节点 + ApprovalRecordNode endNode = getInstanceEndNode(instance); + nodes.addLast(endNode); + + List allNodeIds = nodes.stream().map(ApprovalRecordNode::getNodeId).distinct().toList(); + Map approverNodeMap = getApproverNodeMapByIds(allNodeIds); + Map approvalNodeMap = getApprovalNodeMapByIds(allNodeIds); + nodes.forEach(node -> { + ApprovalNodeApprover approverNode = approverNodeMap.get(node.getNodeId()); + if (approverNode != null) { + node.setMultiApproverMode(MultiApproverModeEnum.valueOf(approverNode.getMultiApproverMode())); + if (CollectionUtils.isNotEmpty(node.getTaskNodes()) && node.getTaskNodes().size() > 1 && StringUtils.isBlank(node.getApprovalStatus())) { + ApprovalStatus approvalStatusOfMultiNode = getNodeApprovalStatusOfMultiTask(node.getTaskNodes()); + node.setApprovalStatus(approvalStatusOfMultiNode == null ? null : approvalStatusOfMultiNode.name()); + } else if (node.getTaskNodes().size() == 1 && StringUtils.isBlank(node.getApprovalStatus())){ + node.setApprovalStatus(node.getTaskNodes().getFirst().getApprovalStatus()); + } + } + ApprovalNode approvalNode; + if (node.getNodeId().contains(SIGN_SPILT)) { + approvalNode = approvalNodeMap.get(node.getNodeId().split(SIGN_SPILT)[0]); + } else { + approvalNode = approvalNodeMap.get(node.getNodeId()); + } + if (approvalNode != null) { + node.setNodeName(approvalNode.getName()); + node.setSort(approvalNode.getSort()); + if (ApprovalNodeTypeEnum.valueOf(approvalNode.getNodeType()) == ApprovalNodeTypeEnum.END) { + node.setEndNode(true); + } + } + if (CollectionUtils.isEmpty(node.getTaskNodes())) { + + node.setTaskNodes(List.of()); + } + }); + // 节点流程配置顺序 + nodes.sort(Comparator.comparing(ApprovalRecordNode::getSort)); + return nodes; + } + + /** + * 已处理的审批记录节点信息 + * @param tasks 任务集合 + * @param records 记录集合 + * @param signTasks 加签任务集合 + * @param backRecords 退回节点记录集合 + * @param attachmentsMap 附件集合 + * @param simpleUserMap 用户信息集合 + * @return 审批记录节点集合 + */ + private List buildProcessedApprovalNodes(ApprovalInstance instance, List tasks, List records, List signTasks, List backRecords, + Map> attachmentsMap, Map simpleUserMap, String currentOrgId) { + List nodes = new ArrayList<>(); + Map autoNodeRecordMap = records.stream().filter(record -> StringUtils.isBlank(record.getTaskId())) + .collect(Collectors.toMap(ApprovalRecord::getNodeId, r -> r, (existing, newOne) -> newOne.getNodeRound() >= existing.getNodeRound() ? newOne : existing + )); + Map taskRecordMap = records.stream().filter(record -> StringUtils.isNotBlank(record.getTaskId())) + .collect(Collectors.toMap(ApprovalRecord::getTaskId, r -> r, (existing, newOne) -> newOne.getCreateTime() >= existing.getCreateTime() ? newOne : existing)); + List nTasks = tasks.stream().filter(task -> ApprovalTaskType.valueOf(task.getType()) == ApprovalTaskType.NL).toList(); + Map nodeMaxRoundMap = mergeNodeMaxRound(nTasks, records); + List hisNodes = sortNodeRoundMap(nodeMaxRoundMap, nTasks, records); + Map hisApproverNodeMap = getApproverNodeMapByIds(hisNodes); + Map> addSignTaskMapOfRoot = signTasks.stream().collect(Collectors.groupingBy(ApprovalAddSignTask::getRootTaskId)); + Map addSignTaskMapOfTask = signTasks.stream().collect(Collectors.toMap(ApprovalAddSignTask::getTaskId, t -> t)); + Map backRecordMap = backRecords.stream().collect(Collectors.toMap(ApprovalReturnBackRecord::getReturnToNodeId, r -> r)); + // 处理历史节点 + hisNodes.forEach(hisNode -> { + Integer maxRound = nodeMaxRoundMap.get(hisNode); + // 获取节点下最后一轮抄送人 + List ccUsers = tasks.stream().filter(task -> ApprovalTaskType.valueOf(task.getType()) == ApprovalTaskType.CC + && Strings.CI.equals(task.getNodeId(), hisNode) && task.getNodeRound().equals(maxRound)).map(ApprovalTask::getApproverId).distinct().toList(); + List ccNodes = ccUsers.stream().map(cc -> { + ApprovalCcNode ccNode = new ApprovalCcNode(); + ccNode.setCcUserId(cc); + if (simpleUserMap.containsKey(cc)) { + ccNode.setCcUserName(simpleUserMap.get(cc).getName()); + ccNode.setCcUserAvatar(simpleUserMap.get(cc).getAvatar()); + } + return ccNode; + }).toList(); + if (autoNodeRecordMap.containsKey(hisNode) && autoNodeRecordMap.get(hisNode).getNodeRound().equals(maxRound)) { + // 当前节点的最后一轮执行是自动执行 + ApprovalRecordNode recordNode = ApprovalRecordNode.builder().nodeId(hisNode).nodeRound(maxRound).taskNodes(List.of()).approvalStatus(autoNodeRecordMap.get(hisNode).getResult()).build(); + ApprovalTaskNode autoTask = buildAutoTaskNode(); + autoTask.setApprovalTime(autoNodeRecordMap.get(hisNode).getCreateTime()); + autoTask.setApprovalStatus(recordNode.getApprovalStatus()); + autoTask.setRecordId(autoNodeRecordMap.get(hisNode).getId()); + recordNode.setTaskNodes(List.of(autoTask)); + recordNode.setCcNodes(ccNodes); + nodes.addLast(recordNode); + return; + } + /* + * 获取节点下最后一轮正常待办任务 (排除正常加签操作) + * 加签场景下, 同一节点可能存在多条rootTask, 只展示最新创建的那个待办 + */ + List nodeMultiApprover = approvalFlowService.getCurrentNodeApproverList(instance, hisNode, currentOrgId); + List nlTasks = tasks.stream().filter(task -> ApprovalTaskType.valueOf(task.getType()) == ApprovalTaskType.NL && Strings.CI.equals(task.getNodeId(), hisNode) + && task.getNodeRound().equals(maxRound)).sorted(Comparator.comparing(ApprovalTask::getCreateTime)).toList(); + nlTasks = filterNodeLatestTasks(nlTasks); + if (CollectionUtils.isEmpty(nlTasks)) { + return; + } + List snTasks = tasks.stream().filter(task -> ApprovalTaskType.valueOf(task.getType()) == ApprovalTaskType.SN && Strings.CI.contains(task.getNodeId(), hisNode) + && task.getNodeRound().equals(maxRound)).sorted(Comparator.comparing(ApprovalTask::getCreateTime)).toList(); + // 加签任务追加 + if (nodeMultiApprover.size() == 1) { + // 单人执行 + List flatSignTasks = flatSignTask(nlTasks.getFirst(), addSignTaskMapOfRoot, snTasks.stream().collect(Collectors.toMap(ApprovalTask::getId, t -> t))); + for (int i = 0; i < flatSignTasks.size(); i++) { + ApprovalTask signTask = flatSignTasks.get(i); + ApprovalTaskNode taskNode = buildTaskNode(signTask, taskRecordMap, addSignTaskMapOfTask, attachmentsMap, simpleUserMap); + ApprovalRecordNode recordNode = ApprovalRecordNode.builder().nodeId(taskNode.isSign() ? hisNode + SIGN_SPILT + i : hisNode).nodeRound(maxRound) + .approvalStatus(Strings.CI.equals(taskNode.getApprovalStatus(), ApprovalStatus.REVOKED.name()) ? ApprovalStatus.NONE.name() : taskNode.getApprovalStatus()).taskNodes(List.of(taskNode)).build(); + if (ApprovalTaskType.valueOf(signTask.getType()) == ApprovalTaskType.NL) { + recordNode.setCcNodes(ccNodes); + } + if (ApprovalStatus.valueOf(recordNode.getApprovalStatus()) == ApprovalStatus.APPROVING) { + instance.setCurrentNodeId(recordNode.getNodeId()); + } + nodes.addLast(setBackInfo(recordNode, backRecordMap, attachmentsMap)); + } + } else { + List allTask = new ArrayList<>(); + // 多人执行, 追加在同一节点上 + nlTasks.forEach(nlTask -> { + List flatSignTasks = flatSignTask(nlTask, addSignTaskMapOfRoot, snTasks.stream().collect(Collectors.toMap(ApprovalTask::getId, t -> t))); + allTask.addAll(flatSignTasks); + }); + // 依次审批的后续节点审批人需要作为待审批追加 + ApprovalNodeApprover approvalNodeApprover = hisApproverNodeMap.get(hisNode); + if (approvalNodeApprover != null && MultiApproverModeEnum.valueOf(approvalNodeApprover.getMultiApproverMode()) == MultiApproverModeEnum.SEQUENTIAL) { + appendSeqTasks(nodeMultiApprover, allTask); + } + List taskNodes = allTask.stream().map(nTask -> buildTaskNode(nTask, taskRecordMap, addSignTaskMapOfTask, attachmentsMap, simpleUserMap)).toList(); + ApprovalRecordNode recordNode = ApprovalRecordNode.builder().nodeId(hisNode).nodeRound(maxRound).taskNodes(taskNodes).ccNodes(ccNodes).build(); + nodes.addLast(setBackInfo(recordNode, backRecordMap, attachmentsMap)); + } + }); + return nodes; + } + + /** + * 追加依次审批的待审批任务 + * @param nodeMultiApprover 节点审批人 + * @param allTask 所有执行任务 + */ + private void appendSeqTasks(List nodeMultiApprover, List allTask) { + List processApproverIds = allTask.stream().map(ApprovalTask::getApproverId).toList(); + ApprovalTask copyTask = allTask.getFirst(); + nodeMultiApprover.stream().filter(approver -> !processApproverIds.contains(approver)).forEach(user -> { + ApprovalTask seqTask = BeanUtils.copyBean(new ApprovalTask(), copyTask); + seqTask.setId(IDGenerator.nextStr()); + seqTask.setApproverId(user); + seqTask.setStatus(ApprovalStatus.PENDING.name()); + seqTask.setType(ApprovalTaskType.NL.name()); + seqTask.setAction(null); + allTask.addLast(seqTask); + }); + } + + /** + * 设置回退节点信息 + * @param recordNode 记录节点 + * @param backRecordMap 回退信息集合 + * @param attachmentsMap 附件信息集合 + * @return 记录节点 + */ + private ApprovalRecordNode setBackInfo(ApprovalRecordNode recordNode, Map backRecordMap, Map> attachmentsMap) { + ApprovalReturnBackRecord backRecord = backRecordMap.get(recordNode.getNodeId()); + if (backRecord != null) { + recordNode.setBackNode(true); + recordNode.setBackReason(backRecord.getReturnReason()); + recordNode.setBackAttachments(attachmentsMap.getOrDefault(backRecord.getId(), Collections.emptyList())); + } + return recordNode; + } + + /** + * 获取后续待审批节点列表 + * @param instance 审批实例 + * @param simpleUserMap 用户信息映射 + * @return 后续待审批节点列表 + */ + private List buildPendingApprovalNodes(ApprovalInstance instance, Map simpleUserMap, String currentOrgId) { + if (StringUtils.isBlank(instance.getCurrentNodeId()) || ApprovalStatus.valueOf(instance.getApprovalStatus()) == ApprovalStatus.APPROVED) { + // 当前节点为空, 或审批完成的实例, 不存在后续待审批的节点 + return new ArrayList<>(); + } + List nodes = approvalFlowService.getInstanceCurrentFollowNode(instance, currentOrgId); + return nodes.stream().map(node -> { + if (ApprovalTypeEnum.valueOf(node.getApprovalType()) == ApprovalTypeEnum.AUTO_PASS || ApprovalTypeEnum.valueOf(node.getApprovalType()) == ApprovalTypeEnum.AUTO_REJECT) { + // 自动节点没有审批人 + node.setApproverList(new ArrayList<>()); + } + List taskNodes = new ArrayList<>(node.getApproverList().stream().map(approver -> { + ApprovalTaskNode taskNode = ApprovalTaskNode.builder().taskId(IDGenerator.nextStr()).approverId(approver).approvalStatus(ApprovalStatus.PENDING.name()).build(); + if (simpleUserMap.containsKey(approver)) { + taskNode.setApprover(simpleUserMap.get(approver).getName()); + taskNode.setApproverAvatar(simpleUserMap.get(approver).getAvatar()); + } + return taskNode; + }).toList()); + if (CollectionUtils.isEmpty(taskNodes)) { + ApprovalTaskNode taskNode = buildAutoTaskNode(); + taskNode.setApprovalStatus(ApprovalStatus.PENDING.name()); + taskNodes.add(taskNode); + } + return ApprovalRecordNode.builder().nodeId(node.getId()).nodeRound(1).approvalStatus(ApprovalStatus.PENDING.name()).taskNodes(taskNodes).build(); + }).toList(); + } + + /** + * 获取审批流唯一结束节点 + * @param instance 审批实例 + * @return 结束节点 + */ + private ApprovalRecordNode getInstanceEndNode(ApprovalInstance instance) { + ApprovalNode approvalNode = new ApprovalNode(); + approvalNode.setFlowVersionId(instance.getFlowVersionId()); + approvalNode.setNodeType(ApprovalNodeTypeEnum.END.name()); + approvalNode.setExecuteTime(StringUtils.isNotBlank(instance.getExecuteTime()) ? instance.getExecuteTime() : ExecuteTimingEnum.CREATE.name()); + ApprovalNode endNode = approvalNodeMapper.selectOne(approvalNode); + return ApprovalRecordNode.builder().nodeId(endNode.getId()).build(); + } + + + /** + * 处理多人节点的审批状态 + * @param taskNodes 任务节点 + * @return 审批状态 + */ + private ApprovalStatus getNodeApprovalStatusOfMultiTask(List taskNodes) { + boolean anyReject = taskNodes.stream().anyMatch(tn -> ApprovalStatus.valueOf(tn.getApprovalStatus()) == ApprovalStatus.UNAPPROVED); + boolean anyAutoReject = taskNodes.stream().anyMatch(tn -> ApprovalStatus.valueOf(tn.getApprovalStatus()) == ApprovalStatus.AUTO_UNAPPROVED); + boolean anyApproving = taskNodes.stream().anyMatch(tn -> ApprovalStatus.valueOf(tn.getApprovalStatus()) == ApprovalStatus.APPROVING); + boolean anyRevoked = taskNodes.stream().anyMatch(tn -> ApprovalStatus.valueOf(tn.getApprovalStatus()) == ApprovalStatus.REVOKED); + boolean anyApproved = taskNodes.stream().anyMatch(tn -> ApprovalStatus.valueOf(tn.getApprovalStatus()) == ApprovalStatus.APPROVED); + boolean anyAutoApproved = taskNodes.stream().anyMatch(tn -> ApprovalStatus.valueOf(tn.getApprovalStatus()) == ApprovalStatus.AUTO_APPROVED); + + // 所有多人审批模式统一: 任一驳回即驳回 (状态优先级: 驳回 > 自动驳回 -> 审批中 -> 已通过 -> 自动通过) + if (anyReject) { + return ApprovalStatus.UNAPPROVED; + } + if (anyAutoReject) { + return ApprovalStatus.AUTO_UNAPPROVED; + } + if (anyApproving) { + return ApprovalStatus.APPROVING; + } + if (anyRevoked) { + return ApprovalStatus.NONE; + } + if (anyApproved) { + return ApprovalStatus.APPROVED; + } + if (anyAutoApproved) { + return ApprovalStatus.AUTO_APPROVED; + } + + return null; + } + + /** + * 获取所有审批节点集合 + * @param nodeIds 节点ID集合 + * @return 审批节点集合 + */ + private Map getApproverNodeMapByIds(List nodeIds) { + if (CollectionUtils.isEmpty(nodeIds)) { + return Map.of(); + } + List approvalNodeApprovers = approvalNodeApproverMapper.selectByIds(nodeIds); + return approvalNodeApprovers.stream().collect(Collectors.toMap(ApprovalNodeApprover::getId, n -> n)); + } + + /** + * 获取所有审批节点集合 + * @param nodeIds 节点ID集合 + * @return 审批节点集合 + */ + private Map getApprovalNodeMapByIds(List nodeIds) { + if (CollectionUtils.isEmpty(nodeIds)) { + return Map.of(); + } + List approvalNodes = approvalNodeMapper.selectByIds(nodeIds); + return approvalNodes.stream().collect(Collectors.toMap(ApprovalNode::getId, n -> n)); + } + + /** + * 平铺加签任务链 + * @param currentTask 当前任务 + * @param rootAddSignMap 加签信息集合 + * @param signTaskMap 加签任务集合 + * @return 加签任务集合 + */ + private List flatSignTask(ApprovalTask currentTask, Map> rootAddSignMap, Map signTaskMap) { + if (!rootAddSignMap.containsKey(currentTask.getId())) { + // 不存在加签链 + return List.of(currentTask); + } + List signTasks = rootAddSignMap.get(currentTask.getId()); + Optional rootNext = signTasks.stream().filter(signTask -> Strings.CS.equals(signTask.getSignTaskId(), currentTask.getId())).findFirst(); + if (rootNext.isEmpty()) { + // 不存在加签链 + return List.of(currentTask); + } + signTasks.sort(Comparator.comparing(ApprovalAddSignTask::getSort)); + List signChain = new ArrayList<>(); + if (ApprovalAddSignType.valueOf(rootNext.get().getType()) == ApprovalAddSignType.BEFORE) { + // 下一个节点在基础节点之前 + signChain.addAll(signTasks.stream().map(signTask -> signTaskMap.get(signTask.getTaskId())).toList()); + signChain.addLast(currentTask); + } else { + // 下一个节点在基础节点之后 + signChain.addFirst(currentTask); + signChain.addAll(signTasks.stream().map(signTask -> signTaskMap.get(signTask.getTaskId())).toList()); + } + return signChain; + } + + /** + * 获取最终的节点顺序 (同一节点可能执行多轮) + * @param tasks 节点待办 + * @param records 节点执行记录 + * @return 节点ID -> 最大轮次 + */ + private Map mergeNodeMaxRound(List tasks, List records) { + // 待办中提取节点和最大轮次 + Map taskNodeMaxRoundMap = tasks.stream().collect(Collectors.toMap(ApprovalTask::getNodeId, ApprovalTask::getNodeRound, Math::max)); + // 记录中提取节点和最大轮次 + Map recordNodeMaxRoundMap = records.stream().collect(Collectors.toMap(ApprovalRecord::getNodeId, ApprovalRecord::getNodeRound, Math::max)); + // 合并节点集合, 按照最大轮次保留 + Map mergedMap = new HashMap<>(recordNodeMaxRoundMap); + taskNodeMaxRoundMap.forEach((nodeId, nodeRound) -> mergedMap.merge(nodeId, nodeRound, Math::max)); + return mergedMap; + } + + /** + * 轮次节点的最终执行排序 + * 按照该节点的最大轮次中的最小创建时间来排序,早的在前面 + * @param nodeRoundMap 节点ID -> 最大轮次 + * @param tasks 所有待办 + * @param records 所有记录 + * @return 排序后的节点ID列表 + */ + private List sortNodeRoundMap(Map nodeRoundMap, List tasks, List records) { + // 找出每个节点最大轮次中的最小创建时间 + Map nodeMinCreateTimeMap = new HashMap<>(nodeRoundMap.size()); + + // 从待办中找:筛选出每个节点最大轮次的待办,取最小创建时间 + tasks.forEach(task -> { + Integer maxRound = nodeRoundMap.get(task.getNodeId()); + if (task.getNodeRound() != null && task.getNodeRound().equals(maxRound)) { + nodeMinCreateTimeMap.merge(task.getNodeId(), task.getCreateTime(), Math::min); + } + }); + + // 从记录中找:筛选出每个节点最大轮次的记录,取最小创建时间 + records.forEach(record -> { + Integer maxRound = nodeRoundMap.get(record.getNodeId()); + if (record.getNodeRound() != null && record.getNodeRound().equals(maxRound)) { + nodeMinCreateTimeMap.merge(record.getNodeId(), record.getCreateTime(), Math::min); + } + }); + + // 按最小创建时间升序排序 + return nodeMinCreateTimeMap.entrySet().stream() + .sorted(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .collect(Collectors.toList()); + } + + /** + * 处理任务节点信息 + * @param task 任务信息 + * @param taskRecordMap 记录信息 + * @param attachmentsMap 附件信息 + * @param simpleUserMap 用户信息集合 + * @return 任务节点信息 + */ + private ApprovalTaskNode buildTaskNode(ApprovalTask task, Map taskRecordMap, Map addSignTaskMapOfTask, + Map> attachmentsMap, Map simpleUserMap) { + ApprovalRecord record = taskRecordMap.get(task.getId()); + ApprovalTaskNode taskNode = ApprovalTaskNode.builder().taskId(task.getId()) + .sign(ApprovalTaskType.valueOf(task.getType()) == ApprovalTaskType.SN) + .approverId(task.getApproverId()).approvalStatus(task.getStatus()).build(); + if (StringUtils.isNotBlank(task.getAction()) && ApprovalAction.valueOf(task.getAction()) == ApprovalAction.SIGN) { + taskNode.setSignAction(true); + } + if (addSignTaskMapOfTask.containsKey(task.getId()) && taskNode.isSign()) { + // 加签任务 + taskNode.setSignComment(addSignTaskMapOfTask.get(task.getId()).getComment()); + taskNode.setSignAttachments(attachmentsMap.getOrDefault(addSignTaskMapOfTask.get(task.getId()).getId(), Collections.emptyList())); + } + if (simpleUserMap.containsKey(task.getApproverId())) { + taskNode.setApprover(simpleUserMap.get(task.getApproverId()).getName()); + taskNode.setApproverAvatar(simpleUserMap.get(task.getApproverId()).getAvatar()); + } + if (record != null) { + taskNode.setComment(record.getComment()); + taskNode.setAttachments(attachmentsMap.getOrDefault(record.getId(), Collections.emptyList())); + taskNode.setApprovalTime(record.getCreateTime()); + } + return taskNode; + } + + /** + * 刷新最终实例状态 + * + * @param instance 审批实例 + * @param currentUserId 当前用户ID + */ + public void rejectApprovalInstance(ApprovalInstance instance, String currentUserId) { + instance.setApprovalStatus(ApprovalStatus.UNAPPROVED.name()); + instance.setApprovalTime(System.currentTimeMillis()); + instance.setUpdateUser(currentUserId); + instance.setUpdateTime(System.currentTimeMillis()); + approvalInstanceMapper.updateById(instance); + } + + /** + * 清除审批中的实例数据 (审批流删除时调用) + * @param flowId 审批流ID + */ + public void clearApprovingInstanceOfFlow(String flowId) { + ApprovalFlowVersion versionCriteria = new ApprovalFlowVersion(); + versionCriteria.setFlowId(flowId); + List versions = flowVersionMapper.select(versionCriteria); + List flowVersionIds = versions.stream().map(ApprovalFlowVersion::getId).toList(); + // 筛选出审批中的实例 + LambdaQueryWrapper instanceLambdaQueryWrapper = new LambdaQueryWrapper() + .in(ApprovalInstance::getFlowVersionId, flowVersionIds) + .eq(ApprovalInstance::getApprovalStatus, ApprovalStatus.APPROVING.name()); + List instances = approvalInstanceMapper.selectListByLambda(instanceLambdaQueryWrapper); + instances.forEach(instance -> { + ApprovalResourceService resourceService = CommonBeanFactory.getBean(ApprovalResourceService.class); + if (resourceService != null) { + // 系统操作,不记录日志 + resourceService.updateResourceApprovalStatus(FormKey.ofKey(instance.getType()), instance.getResourceId(), ApprovalStatus.NONE.name(), null, null); + } + }); + approvalInstanceMapper.deleteByLambda(instanceLambdaQueryWrapper); + // 删除审批实例相关数据: 待办任务、加签任务、退回记录、执行记录、实例附件等 + List instanceIds = instances.stream().map(ApprovalInstance::getId).toList(); + if (CollectionUtils.isEmpty(instanceIds)) { + return; + } + List approvalTasks = approvalTaskMapper.selectListByLambda(new LambdaQueryWrapper().in(ApprovalTask::getInstanceId, instanceIds)); + List taskIds = approvalTasks.stream().map(ApprovalTask::getId).toList(); + if (CollectionUtils.isNotEmpty(taskIds)) { + approvalTaskMapper.deleteByIds(taskIds); + approvalAddSignTaskMapper.deleteByLambda(new LambdaQueryWrapper().in(ApprovalAddSignTask::getTaskId, taskIds)); + approvalReturnBackRecordMapper.deleteByLambda(new LambdaQueryWrapper().in(ApprovalReturnBackRecord::getTaskId, taskIds)); + } + if (CollectionUtils.isNotEmpty(taskIds)) { + approvalRecordMapper.deleteByLambda(new LambdaQueryWrapper().in(ApprovalRecord::getInstanceId, instanceIds)); + List instanceAttachments = approvalInstanceAttachmentMapper.selectListByLambda(new LambdaQueryWrapper().in(ApprovalInstanceAttachment::getInstanceId, instanceIds)); + if (CollectionUtils.isNotEmpty(instanceAttachments)) { + approvalInstanceAttachmentMapper.deleteByIds(instanceAttachments.stream().map(ApprovalInstanceAttachment::getId).toList()); + List attachmentIds = instanceAttachments.stream().map(ApprovalInstanceAttachment::getAttachmentId).toList(); + attachmentIds.forEach(id -> attachmentService.delete(id)); + if (CollectionUtils.isNotEmpty(attachmentIds)) { + attachmentMapper.deleteByIds(attachmentIds); + } + } + } + } + + /** + * 过滤出同一个节点内相同审批人最新的一条待办 + * @param nodeTasks 节点任务 + * @return 审批人最新的一条待办 + */ + private List filterNodeLatestTasks(List nodeTasks) { + Map taskMap = new HashMap<>(nodeTasks.size()); + nodeTasks.forEach(nodeTask -> { + boolean exist = taskMap.containsKey(nodeTask.getApproverId()); + if (!exist || nodeTask.getCreateTime() > taskMap.get(nodeTask.getApproverId()).getCreateTime()) { + taskMap.put(nodeTask.getApproverId(), nodeTask); + } + }); + return taskMap.values().stream().sorted(Comparator.comparing(ApprovalTask::getCreateTime)).toList(); + } + + private ApprovalTaskNode buildAutoTaskNode() { + return ApprovalTaskNode.builder().taskId("auto").approverId("Cbot").approver("Cbot").build(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalResourceService.java b/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalResourceService.java new file mode 100644 index 0000000..baaa949 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalResourceService.java @@ -0,0 +1,942 @@ +package cn.cordys.crm.approval.service; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.common.resolver.field.ModuleFieldResolverFactory; +import cn.cordys.common.service.SSRFValidationService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.approval.aspect.HitApprovalAspect; +import cn.cordys.crm.approval.constants.ApprovalNodeTypeEnum; +import cn.cordys.crm.approval.constants.ApprovalStatus; +import cn.cordys.crm.approval.constants.ExecuteTimingEnum; +import cn.cordys.crm.approval.domain.ApprovalFlow; +import cn.cordys.crm.approval.domain.ApprovalInstance; +import cn.cordys.crm.approval.domain.ApprovalRecord; +import cn.cordys.crm.approval.domain.ApprovalTask; +import cn.cordys.crm.approval.dto.*; +import cn.cordys.crm.approval.dto.response.ApprovalNodeApproverResponse; +import cn.cordys.crm.approval.dto.response.ApprovalNodeResponse; +import cn.cordys.crm.approval.dto.response.ResourceApprovalResponse; +import cn.cordys.crm.approval.handler.ApprovalResourceHandler; +import cn.cordys.crm.approval.mapper.ExtApprovalInstanceMapper; +import cn.cordys.crm.approval.mapper.ExtApprovalTaskMapper; +import cn.cordys.crm.integration.common.utils.HttpClientUtils; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.field.PriceSubField; +import cn.cordys.crm.system.dto.field.ProductSubField; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.SubField; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import cn.cordys.security.UserApprovalDTO; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class ApprovalResourceService { + + @Resource + private BaseMapper approvalInstanceMapper; + @Resource + private BaseMapper approvalTaskMapper; + @Resource + private BaseMapper approvalRecordMapper; + @Resource + private BaseMapper userMapper; + @Resource + private ExtApprovalInstanceMapper extApprovalInstanceMapper; + @Resource + private ExtApprovalTaskMapper extApprovalTaskMapper; + @Resource + private ApprovalFlowService approvalFlowService; + @Resource + private ApprovalInstanceService instanceService; + @Resource + private ApprovalActionService approvalActionService; + @Resource + private LogService logService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private SSRFValidationService ssrfValidationService; + + @Resource + private List approvalResourceHandlers; + + @PostConstruct + private void initFormService() { + for (ApprovalResourceHandler handler : approvalResourceHandlers) { + FORM_SERVICE.put(handler.getFormKey(), handler); + } + } + + /** + * 开启的审批流表单表格映射 + */ + public static final Map FORM_APPROVAL_TABLE = new HashMap<>(4); + public static final Map FORM_SERVICE = new HashMap<>(4); + public static final String NULL_POST_CONFIG = "null"; + + static { + FORM_APPROVAL_TABLE.put(FormKey.QUOTATION.getKey(), "opportunity_quotation"); + FORM_APPROVAL_TABLE.put(FormKey.CONTRACT.getKey(), "contract"); + FORM_APPROVAL_TABLE.put(FormKey.INVOICE.getKey(), "contract_invoice"); + FORM_APPROVAL_TABLE.put(FormKey.ORDER.getKey(), "sales_order"); + } + + public ResourceApprovalResponse resourceDetail(String resourceId) { + // 初始化响应对象,默认返回空审核人列表。 + ResourceApprovalResponse response = new ResourceApprovalResponse(); + response.setResourceId(resourceId); + response.setApproveUserList(Collections.emptyList()); + if (StringUtils.isBlank(resourceId)) { + return response; + } + + // 查询资源最近一次审批实例(由SQL保证倒序并限制1条)。 + ApprovalInstance latestInstance = extApprovalTaskMapper.selectLatestInstanceByResourceId(resourceId); + if (latestInstance == null) { + return response; + } + + // 设置审批状态并校验当前审批节点。 + response.setApproveStatus(latestInstance.getApprovalStatus()); + if (StringUtils.isBlank(latestInstance.getCurrentNodeId())) { + return response; + } + + // 只查询当前实例当前节点对应的审批任务。 + LambdaQueryWrapper taskWrapper = new LambdaQueryWrapper<>(); + taskWrapper.eq(ApprovalTask::getInstanceId, latestInstance.getId()) + .eq(ApprovalTask::getStatus, ApprovalStatus.UNAPPROVED.name()) + .eq(ApprovalTask::getNodeId, latestInstance.getCurrentNodeId()); + List tasks = approvalTaskMapper.selectListByLambda(taskWrapper); + LambdaQueryWrapper autoRecordWrapper = new LambdaQueryWrapper<>(); + autoRecordWrapper.eq(ApprovalRecord::getInstanceId, latestInstance.getId()) + .eq(ApprovalRecord::getNodeId, latestInstance.getCurrentNodeId()); + List allRecords = approvalRecordMapper.selectListByLambda(autoRecordWrapper); + List autoRecords = allRecords.stream().filter(record -> StringUtils.isBlank(record.getTaskId()) + && Strings.CI.equalsAny(record.getResult(), ApprovalStatus.AUTO_APPROVED.name(), ApprovalStatus.AUTO_UNAPPROVED.name())).toList(); + if (tasks.isEmpty() && autoRecords.isEmpty()) { + return response; + } + + // 批量加载审批人基础信息。 + List approverIds = tasks.stream() + .map(ApprovalTask::getApproverId) + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + Map userMap = approverIds.isEmpty() + ? Collections.emptyMap() + : userMapper.selectByIds(approverIds).stream() + .collect(Collectors.toMap(User::getId, Function.identity(), (prev, next) -> prev)); + + // 批量加载任务对应审批记录(用于审批意见)。 + List taskIds = tasks.stream() + .map(ApprovalTask::getId) + .filter(StringUtils::isNotBlank) + .toList(); + Map taskRecordMap = Collections.emptyMap(); + if (!taskIds.isEmpty()) { + LambdaQueryWrapper recordWrapper = new LambdaQueryWrapper<>(); + recordWrapper.in(ApprovalRecord::getTaskId, taskIds) + .orderByAsc(ApprovalRecord::getCreateTime); + List records = approvalRecordMapper.selectListByLambda(recordWrapper); + taskRecordMap = records.stream() + .filter(record -> StringUtils.isNotBlank(record.getTaskId())) + .collect(Collectors.toMap(ApprovalRecord::getTaskId, Function.identity(), (prev, next) -> next)); + } + + // 组装审核人明细并按审批人去重返回。 + Map approveUserMap = new LinkedHashMap<>(); + for (ApprovalTask task : tasks) { + String approverId = task.getApproverId(); + if (StringUtils.isBlank(approverId)) { + continue; + } + UserApprovalDTO userApprove = new UserApprovalDTO(); + userApprove.setId(approverId); + User user = userMap.get(approverId); + if (user != null) { + userApprove.setName(user.getName()); + userApprove.setEmail(user.getEmail()); + } + + userApprove.setApproveResult(StringUtils.defaultIfBlank(task.getStatus(), ApprovalStatus.PENDING.name())); + ApprovalRecord record = taskRecordMap.get(task.getId()); + if (record != null) { + userApprove.setApproveReason(record.getComment()); + } + approveUserMap.put(approverId, userApprove); + } + + // 追加自动审批的节点 + for (ApprovalRecord autoRecord : autoRecords) { + UserApprovalDTO userApprove = new UserApprovalDTO(); + userApprove.setId("Cbot"); + userApprove.setName("Cbot"); + userApprove.setApproveResult(autoRecord.getResult()); + approveUserMap.put("auto", userApprove); + } + + // 回填最终审核人列表。 + response.setApproveUserList(new ArrayList<>(approveUserMap.values())); + return response; + } + + /** + * 更新业务表及快照的审批状态 + * + * @param formKey 表单类型 + * @param resourceId 资源ID + * @param approvalStatus 审批状态 + * @param userId 操作人ID + * @param orgId 组织ID + */ + public void updateResourceApprovalStatus(FormKey formKey, String resourceId, String approvalStatus, String userId, String orgId) { + if (formKey == null) { + throw new GenericException(Translator.get("module.form.illegal")); + } + String tableName = FORM_APPROVAL_TABLE.get(formKey.getKey()); + if (StringUtils.isBlank(tableName)) { + throw new GenericException(Translator.get("module.form.illegal")); + } + // 查询旧的审批状态用于日志记录 + String oldApprovalStatus = extApprovalInstanceMapper.selectApprovalStatus(tableName, resourceId); + if (Strings.CS.equals(approvalStatus, oldApprovalStatus)) { + return; + } + extApprovalInstanceMapper.updateApprovalStatus(tableName, resourceId, approvalStatus); + if (Strings.CS.equals(approvalStatus, ApprovalStatus.APPROVED.name())) { + extApprovalInstanceMapper.setApproved(tableName, resourceId); + } + // 存在快照表, 需要同步刷新审批状态 + if (formKey.hasSnapshot()) { + updateSnapshotApprovalStatus(formKey, resourceId, approvalStatus); + } + // 记录审批状态变更日志 + saveApprovalStatusLog(formKey, resourceId, oldApprovalStatus, approvalStatus, userId, orgId); + } + + /** + * 保存审批状态变更日志 + * + * @param formKey 表单类型 + * @param resourceId 资源ID + * @param oldApprovalStatus 旧审批状态 + * @param newApprovalStatus 新审批状态 + * @param userId 操作人ID + * @param orgId 组织ID + */ + private void saveApprovalStatusLog(FormKey formKey, String resourceId, String oldApprovalStatus, String newApprovalStatus, String userId, String orgId) { + if (StringUtils.isBlank(userId) || StringUtils.isBlank(orgId)) { + return; + } + String resourceName = getInstanceResourceName(formKey, resourceId); + if (StringUtils.isBlank(resourceName)) { + return; + } + String logModule = getLogModuleOfFormKey(formKey); + if (StringUtils.isBlank(logModule)) { + return; + } + LogDTO logDTO = new LogDTO(orgId, resourceId, userId, LogType.UPDATE, logModule, resourceName); + Map oldMap = new HashMap<>(1); + oldMap.put("approvalStatus", StringUtils.defaultString(oldApprovalStatus)); + logDTO.setOriginalValue(oldMap); + Map newMap = new HashMap<>(1); + newMap.put("approvalStatus", StringUtils.defaultString(newApprovalStatus)); + logDTO.setModifiedValue(newMap); + logService.add(logDTO); + } + + /** + * 表单类型 => 日志模块 + * + * @param formKey 表单Key + * @return 日志模块 + */ + private String getLogModuleOfFormKey(FormKey formKey) { + if (formKey == null) { + return null; + } + return switch (formKey) { + case QUOTATION -> LogModule.OPPORTUNITY_QUOTATION; + case CONTRACT -> LogModule.CONTRACT_INDEX; + case INVOICE -> LogModule.CONTRACT_INVOICE; + case ORDER -> LogModule.ORDER_INDEX; + default -> null; + }; + } + + /** + * 检查资源是否历史上审批通过过 + * + * @param formKey 表单类型 + * @param resourceId 资源ID + * @return 是否审批通过过 + */ + public boolean isResourceApproved(FormKey formKey, String resourceId) { + if (formKey == null) { + return false; + } + String tableName = FORM_APPROVAL_TABLE.get(formKey.getKey()); + if (StringUtils.isBlank(tableName)) { + return false; + } + Boolean approved = extApprovalInstanceMapper.selectApproved(tableName, resourceId); + return Boolean.TRUE.equals(approved); + } + + /** + * 获取审批实例资源名称 + * + * @param formKey 表单类型 + * @param resourceId 资源ID + */ + public String getInstanceResourceName(FormKey formKey, String resourceId) { + if (formKey == null) { + throw new GenericException(Translator.get("module.form.illegal")); + } + String tableName = FORM_APPROVAL_TABLE.get(formKey.getKey()); + if (StringUtils.isBlank(tableName)) { + throw new GenericException(Translator.get("module.form.illegal")); + } + return extApprovalInstanceMapper.selectBusinessName(tableName, resourceId); + } + + /** + * 更新业务快照审批状态值 + * + * @param formKey 表单类型 + * @param resourceId 资源ID + * @param approvalStatus 审批状态 + */ + private void updateSnapshotApprovalStatus(FormKey formKey, String resourceId, String approvalStatus) { + if (formKey == null || !FORM_SERVICE.containsKey(formKey)) { + return; + } + try { + ApprovalResourceHandler handler = FORM_SERVICE.get(formKey); + ResourceSnapshotApprovalParam param = ResourceSnapshotApprovalParam.builder().resourceId(resourceId).approvalStatus(approvalStatus).build(); + handler.updateSnapshotApprovalStatus(param); + } catch (Exception e) { + log.error("更新业务数据快照失败", e); + } + } + + /** + * 审批后置字段更新 + * @param formKey 表单Key + * @param resourceId 资源ID + * @param postConfig 后置配置 + */ + public void updateApprovalPostField(FormKey formKey, String resourceId, String postConfig, String currentUserId) { + if (formKey == null || !FORM_SERVICE.containsKey(formKey)) { + return; + } + List fields = getUpdateFieldOfPostConfig(postConfig); + fields = fields.stream().filter(ResourceApprovalFieldUpdateParam::isEnable).filter(f -> f.getFieldValue() != null).toList(); + if (CollectionUtils.isEmpty(fields)) { + return; + } + try { + ApprovalResourceHandler handler = FORM_SERVICE.get(formKey); + ResourceApprovalPostUpdateParam postUpdateParam = ResourceApprovalPostUpdateParam.builder().fields(fields).resourceId(resourceId).operator(currentUserId).build(); + handler.updateApprovalPostField(postUpdateParam); + } catch (Exception e) { + log.error("更新业务数据失败", e); + } + } + + /** + * 获取审批流字段更新配置 + * + * @param postConfig 配置 + * @return 更新配置 + */ + private List getUpdateFieldOfPostConfig(String postConfig) { + if (StringUtils.isBlank(postConfig) || Strings.CI.equals(NULL_POST_CONFIG, postConfig)) { + return new ArrayList<>(); + } + Map postConfigMap = JSON.parseToMap(postConfig); + return JSON.parseArray(JSON.toJSONString(postConfigMap.get("fieldUpdateConfigs")), ResourceApprovalFieldUpdateParam.class); + } + + /** + * 手动提审 + * + * @param param 提审参数 + */ + public void push(ApprovalPushParam param) { + String currentOrgId = param.getOrgId(); + String currentUserId = param.getUserId(); + ApprovalFlow approvalFlow = approvalFlowService.getEnabledFlow(param.getFormKey(), currentOrgId); + if (approvalFlow == null) { + throw new GenericException(Translator.get("approval_flow.not.exist")); + } + // 初始化审批实例 + ApprovalInstance instance = initInstance(approvalFlow, param); + // 获取第一个节点 + ApprovalNodeResponse firstApprovalNode = approvalFlowService.getResourceApprovalInstanceFirstNode(instance, currentOrgId); + instance.setCurrentNodeId(firstApprovalNode.getId()); + if (ApprovalNodeTypeEnum.valueOf(firstApprovalNode.getNodeType()) == ApprovalNodeTypeEnum.EXCEPTION) { + // 异常节点, 目前只有自动拒绝的场景, 直接驳回 + updateResourceApprovalStatus(FormKey.ofKey(param.getFormKey()), param.getResourceId(), ApprovalStatus.UNAPPROVED.name(), currentUserId, currentOrgId); + instance.setApprovalStatus(ApprovalStatus.UNAPPROVED.name()); + instance.setApprovalTime(System.currentTimeMillis()); + approvalInstanceMapper.insert(instance); + return; + } + if (ApprovalNodeTypeEnum.valueOf(firstApprovalNode.getNodeType()) == ApprovalNodeTypeEnum.END) { + // 直接结束 + updateResourceApprovalStatus(FormKey.ofKey(param.getFormKey()), param.getResourceId(), ApprovalStatus.APPROVED.name(), currentUserId, currentOrgId); + instance.setApprovalStatus(ApprovalStatus.APPROVED.name()); + instance.setApprovalTime(System.currentTimeMillis()); + approvalInstanceMapper.insert(instance); + // DELETE审批通过后,执行实际的删除操作 + if (Strings.CI.equals(instance.getExecuteTime(), ExecuteTimingEnum.DELETE.name())) { + executeDeleteAction(instance.getType(), instance.getResourceId(), currentUserId, currentOrgId); + } + String resourceName = getInstanceResourceName(FormKey.ofKey(instance.getType()), instance.getResourceId()); + if (StringUtils.isBlank(resourceName)) { + return; + } + approvalActionService.sendFinishNotice(instance, resourceName, currentUserId, currentOrgId); + return; + } + /* + * 正常审批流程 + * 1. 更新业务表审批状态为审批中 + * 2. 插入审批实例, 审批待办任务 + */ + updateResourceApprovalStatus(FormKey.ofKey(param.getFormKey()), param.getResourceId(), ApprovalStatus.APPROVING.name(), currentUserId, currentOrgId); + approvalInstanceMapper.insert(instance); + ApprovalNodeApproverResponse approverNode = (ApprovalNodeApproverResponse) firstApprovalNode; + approvalActionService.handlerNextNodeApproverTasks(approverNode, instance, null, currentUserId, null, currentOrgId); + } + + /** + * 撤回审批 + * + * @param param 参数 + * @param currentUserId 当前用户ID + * @param currentOrgId 当前组织ID + */ + public void revoke(ApprovalResourceBaseParam param, String currentUserId, String currentOrgId) { + // 更新业务资源审批状态 + updateResourceApprovalStatus(FormKey.ofKey(param.getFormKey()), param.getResourceId(), ApprovalStatus.REVOKED.name(), currentUserId, currentOrgId); + // 更新审批实例状态 + ApprovalInstance instance = instanceService.getLatestInstance(param.getResourceId()); + if (instance == null) { + throw new GenericException(Translator.get("no.approval.instance")); + } + instance.setApprovalStatus(ApprovalStatus.REVOKED.name()); + instance.setApprovalTime(System.currentTimeMillis()); + instance.setUpdateUser(currentUserId); + instance.setUpdateTime(System.currentTimeMillis()); + approvalInstanceMapper.updateById(instance); + approvalActionService.loseCurrentNode(instance.getId(), instance.getCurrentNodeId()); + } + + /** + * 初始化审批实例 + * + * @param flow 审批流 + * @param param 参数 + * @return 审批实例 + */ + private ApprovalInstance initInstance(ApprovalFlow flow, ApprovalPushParam param) { + String currentUserId = param.getUserId(); + ApprovalInstance instance = new ApprovalInstance(); + instance.setId(IDGenerator.nextStr()); + instance.setFlowVersionId(flow.getCurrentVersionId()); + instance.setType(param.getFormKey()); + instance.setApprovalStatus(ApprovalStatus.APPROVING.name()); + instance.setResourceId(param.getResourceId()); + instance.setSubmitterId(currentUserId); + instance.setSubmitTime(System.currentTimeMillis()); + instance.setCreateUser(currentUserId); + instance.setCreateTime(System.currentTimeMillis()); + instance.setUpdateUser(currentUserId); + instance.setUpdateTime(System.currentTimeMillis()); + instance.setComment(param.getComment()); + instance.setExecuteTime(param.getExecuteTimingEnum().name()); + instance.setUpdateFields(param.getUpdateFields()); + return instance; + } + + /** + * DELETE审批通过后,执行实际的删除操作 + * + * @param formType 表单类型 + * @param resourceId 资源ID + * @param userId 操作人ID + * @param orgId 组织ID + */ + public void executeDeleteAction(String formType, String resourceId, String userId, String orgId) { + FormKey formKey = FormKey.ofKey(formType); + if (formKey == null || !FORM_SERVICE.containsKey(formKey)) { + return; + } + ApprovalResourceHandler handler = FORM_SERVICE.get(formKey); + HitApprovalAspect.executeDeleteSkipApproval(() -> handler.deleteForResource(resourceId, userId, orgId)); + } + + /** + * 批量编辑触发审批流 + * 用于批量编辑多个业务资源时,判断是否开启了编辑触发审批流, + * 如果资源历史上审批通过过,直接提审(UPDATE时机);未通过过则设为待提审(CREATE时机) + * + * @param resourceIds 资源ID集合 + * @param fieldId 字段ID + * @param formKey 业务模块(表单类型) + * @param organizationId 组织ID + * @param userId 操作人ID + * @param fieldName 字段显示名称 + * @param fieldValue 字段值 + */ + public void batchEditTriggerApproval(List resourceIds, String fieldId, FormKey formKey, String organizationId, String userId, String fieldName, Object fieldValue) { + if (CollectionUtils.isEmpty(resourceIds) || formKey == null || StringUtils.isBlank(organizationId)) { + return; + } + + // 检查是否命中审批流 + if (!checkHitApprovalFlowEditTrigger(formKey, organizationId)) { + return; + } + + String valueName = fieldValue instanceof List ? JSON.toJSONString(fieldValue) : fieldValue.toString(); + for (BaseField field : moduleFormCacheService.getConfig(formKey.getKey(), organizationId).getFields()) { + if (Strings.CS.equals(field.getId(), fieldId) || Strings.CS.equals(field.getBusinessKey(), fieldId)) { + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(field.getType()); + // 将数据库中的字符串值,转换为对应的对象值 + valueName = customFieldResolver.transformToValue(field, valueName).toString(); + } + } + + for (String resourceId : resourceIds) { + boolean approved = isResourceApproved(formKey, resourceId); + if (approved) { + // 已审批通过过:UPDATE时机,直接提审 + ApprovalPushParam pushParam = ApprovalPushParam.builder() + .orgId(organizationId) + .userId(userId) + .resourceId(resourceId) + .formKey(formKey.getKey()) + .executeTimingEnum(ExecuteTimingEnum.UPDATE) + .updateFields(JSON.toJSONString(List.of(fieldId))) + .comment(Translator.getWithArgs("approval.update.field", fieldName, valueName)) + .build(); + push(pushParam); + } else { + // 未审批通过过:CREATE时机,设为待提审 + updateResourceApprovalStatus(formKey, resourceId, ApprovalStatus.PENDING.name(), userId, organizationId); + } + } + } + + /** + * 批量删除触发审批流 + * 用于批量删除多个业务资源时,判断是否开启了删除触发审批流, + * 如果开启则直接提审(DELETE时机),跳过待提审状态 + * + * @param resourceIds 资源ID集合 + * @param formKey 业务模块(表单类型) + * @param organizationId 组织ID + * @param userId 操作人ID + * @return 命中审批流的资源ID集合 + */ + public List batchDeleteTriggerApproval(List resourceIds, FormKey formKey, String organizationId, String userId, Map resourceNameMap) { + if (CollectionUtils.isEmpty(resourceIds) || formKey == null || StringUtils.isBlank(organizationId)) { + return Collections.emptyList(); + } + + // 检查是否命中删除审批流 + ApprovalFlow flow = approvalFlowService.getEnabledFlow(formKey.getKey(), organizationId); + if (flow == null || !Boolean.TRUE.equals(flow.getDeleteExecute())) { + return Collections.emptyList(); + } + + List approvalResourceIds = new ArrayList<>(); + for (String resourceId : resourceIds) { + ApprovalPushParam pushParam = ApprovalPushParam.builder() + .orgId(organizationId) + .userId(userId) + .resourceId(resourceId) + .formKey(formKey.getKey()) + .executeTimingEnum(ExecuteTimingEnum.DELETE) + .comment(Translator.getWithArgs("approval.delete.resource", getFormKeyDisplayName(formKey), resourceNameMap.get(resourceId))) + .build(); + push(pushParam); + approvalResourceIds.add(resourceId); + } + return approvalResourceIds; + } + + public String getFormKeyDisplayName(FormKey formKey) { + if (formKey == null) { + return ""; + } + return switch (formKey) { + case QUOTATION -> Translator.get("module.resource_type.quotation"); + case CONTRACT -> Translator.get("module.resource_type.contract"); + case INVOICE -> Translator.get("module.resource_type.invoice"); + case ORDER -> Translator.get("module.resource_type.order"); + default -> ""; + }; + } + + /** + * 检查是否命中审批流触发时机 + * + * @param formKey 表单类型 + * @param organizationId 组织ID + * @return 是否命中审批流 + */ + private boolean checkHitApprovalFlowEditTrigger(FormKey formKey, String organizationId) { + try { + // 查询当前组织表单审批流配置 + ApprovalFlow flow = approvalFlowService.getEnabledFlow(formKey.getKey(), organizationId); + if (flow == null) { + return false; + } + return flow.getUpdateExecute(); + } catch (Exception e) { + log.error("检查是否命中审批流失败, formKey:{}, error:{}", formKey, e.getMessage(), e); + return false; + } + } + + + /** + * 异步发送webhook + * + * @param postConfig + */ + @Async + public void sendWebHook(String postConfig, List fieldValues, ApprovalInstance instance) { + ModuleFormConfigDTO formConfig = moduleFormCacheService.getBusinessFormConfig(instance.getType(), OrganizationContext.getOrganizationId()); + ObjectMapper mapper = new ObjectMapper(); + WebHookConfig webHookConfig; + try { + webHookConfig = mapper.readTree(postConfig) + .get("webHookConfig") + .traverse(mapper) + .readValueAs(WebHookConfig.class); + } catch (Exception e) { + throw new GenericException(e.getMessage()); + } + if (webHookConfig != null && webHookConfig.getWebHookEnable()) { + switch (webHookConfig.getWebHookMethod()) { + case "POST": + sendPost(webHookConfig, formConfig.getFields(), fieldValues, false); + case "GET": + sendGet(webHookConfig, formConfig.getFields(), fieldValues, false); + default: + break; + } + } + } + + + /** + * 发送get 请求 + * + * @param webHookConfig + * @param moduleFields + * @param fieldValues + * @param isTest + * @return + */ + private HashMap sendGet(WebHookConfig webHookConfig, List moduleFields, List fieldValues, Boolean isTest) { + Map headers = new HashMap<>(); + String url = ""; + try { + if (StringUtils.isNotBlank(webHookConfig.getWebHookHeader())) { + headers = JSON.parseMap(webHookConfig.getWebHookHeader()); + } + if (!isTest) { + url = getDataParse(webHookConfig, moduleFields, fieldValues); + } + log.info("GET请求原始参数:{}", webHookConfig.getWebHookUrl()); + log.info("GET请求解析参数:{}", url); + String body = HttpClientUtils.sendGetRequest(StringUtils.isNotBlank(url) ? url : webHookConfig.getWebHookUrl(), headers); + return JSON.parseObject(body, new TypeReference>() { + }); + } catch (Exception e) { + log.error("GET调用WebHook异常", e); + throw new GenericException(e.getMessage()); + } + } + + /** + * 解析get请求 url参数/query参数 + * + * @param webHookConfig + * @param moduleFields + * @param fieldValues + * @return + */ + private String getDataParse(WebHookConfig webHookConfig, List moduleFields, List fieldValues) { + Pattern pattern = Pattern.compile("\\$\\{([^}]+)}"); + Matcher matcher = pattern.matcher(webHookConfig.getWebHookUrl()); + StringBuffer sb = new StringBuffer(); + while (matcher.find()) { + String placeholder = matcher.group(1); + Object value = getFieldValueRecursive( + placeholder.split("\\."), + 1, + moduleFields, + fieldValues + ); + + String param = URLEncoder.encode(String.valueOf(value), StandardCharsets.UTF_8); + matcher.appendReplacement(sb, Matcher.quoteReplacement(param)); + } + + matcher.appendTail(sb); + return sb.toString(); + + + } + + + /** + * 发送post请求 + * + * @param webHookConfig + * @param moduleFields + * @param fieldValues + * @param isTest + * @return + */ + private HashMap sendPost(WebHookConfig webHookConfig, List moduleFields, List fieldValues, Boolean isTest) { + Map headers = new HashMap<>(); + String queryBody = ""; + try { + if (StringUtils.isNotBlank(webHookConfig.getWebHookHeader())) { + headers = JSON.parseMap(webHookConfig.getWebHookHeader()); + } + if (!isTest) { + queryBody = postDataParse(webHookConfig, moduleFields, fieldValues); + } + log.info("POST请求原始参数:{}", webHookConfig.getWebHookBody()); + log.info("POST请求解析参数:{}", queryBody); + String body = HttpClientUtils.sendPostRequest(webHookConfig.getWebHookUrl(), queryBody, headers); + return JSON.parseObject(body, new TypeReference>() { + }); + } catch (Exception e) { + log.error("POST调用WebHook异常:{}", e); + throw new GenericException(e.getMessage()); + } + } + + /** + * post请求体数据解析 + * + * @param webHookConfig + * @param moduleFields + * @param fieldValues + * @throws Exception + */ + private String postDataParse(WebHookConfig webHookConfig, List moduleFields, List fieldValues) throws Exception { + String webHookBody = webHookConfig.getWebHookBody(); + ObjectMapper mapper = new ObjectMapper(); + JsonNode rootNode = mapper.readTree(webHookBody); + replacePlaceholders(rootNode, moduleFields, fieldValues); + String resultJson = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(rootNode); + return resultJson; + } + + + /** + * 映射${aa.bb}具体值的类型 + * + * @param node + * @param moduleFields + * @param fieldValues + */ + private static void replacePlaceholders(JsonNode node, List moduleFields, List fieldValues) { + if (node.isObject()) { + ObjectNode objNode = (ObjectNode) node; + Iterator> fields = objNode.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + JsonNode childNode = entry.getValue(); + if (childNode.isTextual()) { + String text = childNode.asText(); + Object value = replaceText(text, moduleFields, fieldValues); + if (value instanceof Integer) { + objNode.put(entry.getKey(), (Integer) value); + } else if (value instanceof Long) { + objNode.put(entry.getKey(), (Long) value); + } else if (value instanceof Boolean) { + objNode.put(entry.getKey(), (Boolean) value); + } else { + objNode.put(entry.getKey(), String.valueOf(value)); + } + } + } + } + } + + + /** + * 获取${aa.bb}中的具体值并替换 + * + * @param text + * @param moduleFields + * @param fieldValues + * @return + */ + private static Object replaceText(String text, List moduleFields, List fieldValues) { + Pattern pattern = Pattern.compile("\\$\\{([^}]+)\\}"); + Matcher matcher = pattern.matcher(text); + while (matcher.find()) { + String placeholder = matcher.group(1); + String[] parts = placeholder.split("\\."); + return getFieldValueRecursive(parts, 1, moduleFields, fieldValues); + } + return null; + } + + + /** + * 递归获取${xx.xx}中的具体值 + * + * @param parts + * @param index + * @param moduleFields + * @param fieldValues + * @return + */ + private static Object getFieldValueRecursive(String[] parts, int index, List moduleFields, List fieldValues) { + if (index >= parts.length || moduleFields == null || moduleFields.isEmpty()) { + return null; + } + String key = parts[index]; + // 找到当前层级的字段 + BaseField field = moduleFields.stream() + .filter(f -> Strings.CI.equals(f.getName(), key)) + .findFirst() + .orElse(null); + + if (field == null) { + return null; + } + + // 如果还有下一层,递归去 subFields + if (index < parts.length - 1) { + SubField subField = Strings.CI.equals(field.getType(), FieldType.SUB_PRICE.name()) ? (PriceSubField) field : (ProductSubField) field; + BaseModuleFieldValue baseModuleFieldValue = fieldValues.stream().filter(baseField -> Strings.CI.equals(baseField.getFieldId(), subField.getId())).findFirst().orElse(null); + return getFieldValueRecursiveSub(parts, index + 1, subField.getSubFields(), baseModuleFieldValue); + } else { + // 最后一层,取对应 fieldValue + BaseModuleFieldValue moduleFieldValue; + if (field.hasBusinessKey()) { + moduleFieldValue = fieldValues.stream() + .filter(fv -> Strings.CI.equals(fv.getFieldId(), field.getBusinessKey())) + .findFirst().orElse(null); + } else { + moduleFieldValue = fieldValues.stream() + .filter(fv -> Strings.CI.equals(fv.getFieldId(), field.getId())) + .findFirst().orElse(null); + } + if (moduleFieldValue != null) { + return moduleFieldValue.getFieldValue(); + } + } + + return null; + } + + private static Object getFieldValueRecursiveSub(String[] parts, int index, List subFields, BaseModuleFieldValue baseModuleFieldValue) { + if (baseModuleFieldValue == null) { + return null; + } + String key = parts[index]; + + // 找到当前层级的字段 + BaseField field = subFields.stream() + .filter(f -> Strings.CI.equals(f.getName(), key)) + .findFirst() + .orElse(null); + + if (field == null) { + return null; + } + + if (baseModuleFieldValue.getFieldValue() instanceof List list) { + for (Object item : list) { + if (item instanceof Map map) { + if (map.containsKey(field.getId())) { + return map.get(field.getId()); + } + } + } + } + return null; + } + + /** + * 测试连接 + * + * @param webHookConfig + */ + public void testConnect(WebHookConfig webHookConfig) { + if (webHookConfig != null && webHookConfig.getWebHookEnable()) { + HashMap resultObj = new HashMap<>(); + // SSRF安全校验 + ssrfValidationService.validate(webHookConfig.getWebHookUrl()); + switch (webHookConfig.getWebHookMethod()) { + case "POST": + resultObj = sendPost(webHookConfig, null, null, true); + if (!Strings.CI.equals(resultObj.get("code").toString(), "200")) { + throw new GenericException("测试连接不通过"); + } + case "GET": + resultObj = sendGet(webHookConfig, null, null, true); + if (!Strings.CI.equals(resultObj.get("code").toString(), "200")) { + throw new GenericException("测试连接不通过"); + } + default: + break; + } + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalTodoService.java b/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalTodoService.java new file mode 100644 index 0000000..f691c0b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/approval/service/ApprovalTodoService.java @@ -0,0 +1,388 @@ +package cn.cordys.crm.approval.service; + +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.crm.approval.constants.ApprovalFormTypeEnum; +import cn.cordys.crm.approval.constants.ApprovalState; +import cn.cordys.crm.approval.domain.ApprovalInstance; +import cn.cordys.crm.approval.domain.ApprovalTask; +import cn.cordys.crm.approval.dto.request.ApprovalTodoPageRequest; +import cn.cordys.crm.approval.dto.response.ApprovalTodoCountResponse; +import cn.cordys.crm.approval.dto.response.ApprovalTodoItemResponse; +import cn.cordys.crm.approval.mapper.ExtApprovalTaskMapper; +import cn.cordys.crm.contract.domain.Contract; +import cn.cordys.crm.contract.domain.ContractInvoice; +import cn.cordys.crm.opportunity.domain.OpportunityQuotation; +import cn.cordys.crm.order.domain.Order; +import cn.cordys.crm.system.domain.User; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class ApprovalTodoService { + + @Resource + private BaseMapper approvalTaskMapper; + @Resource + private BaseMapper approvalInstanceMapper; + @Resource + private BaseMapper userMapper; + @Resource + private BaseMapper quotationMapper; + @Resource + private BaseMapper contractMapper; + @Resource + private BaseMapper orderMapper; + @Resource + private BaseMapper invoiceMapper; + @Resource + private ExtApprovalTaskMapper extApprovalTaskMapper; + + public Pager> getTodoPage(ApprovalTodoPageRequest request, String userId) { + // 在未登录场景下直接返回空分页数据。 + if (StringUtils.isBlank(userId)) { + return new Pager<>(Collections.emptyList(), 0, request.getPageSize(), request.getCurrent()); + } + + // 解析资源类型过滤参数,支持 ALL 或具体类型。 + ApprovalFormTypeEnum filterType = parseFilterType(request.getResourceType()); + if (!isAllType(request.getResourceType()) && filterType == null) { + return new Pager<>(Collections.emptyList(), 0, request.getPageSize(), request.getCurrent()); + } + + // 分页查询当前用户待审批任务,并在数据库侧按创建时间倒序返回。 + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List items = extApprovalTaskMapper.selectPendingTasks( + userId, + ApprovalState.APPROVING.getId(), + filterType == null ? null : filterType.name().toLowerCase(), + StringUtils.trimToNull(request.getKeyword()) + ); + if (items.isEmpty()) { + return PageUtils.setPageInfo(page, Collections.emptyList()); + } + + // 返回分页待办列表。 + return PageUtils.setPageInfo(page, items); + } + + public ApprovalTodoCountResponse getPendingCount(String userId) { + // 初始化统计响应对象并设置默认值。 + ApprovalTodoCountResponse response = new ApprovalTodoCountResponse(); + response.setTotal(0); + response.setQuotation(0); + response.setContract(0); + response.setOrder(0); + response.setInvoice(0); + // 未登录用户直接返回空统计。 + if (StringUtils.isBlank(userId)) { + return response; + } + // 通过聚合SQL统计待我审批总数及资源类型分布。 + ApprovalTodoCountResponse count = extApprovalTaskMapper.countPendingByApprover(userId, ApprovalState.APPROVING.getId()); + if (count == null) { + return response; + } + response.setTotal(Optional.ofNullable(count.getTotal()).orElse(0)); + response.setQuotation(Optional.ofNullable(count.getQuotation()).orElse(0)); + response.setContract(Optional.ofNullable(count.getContract()).orElse(0)); + response.setOrder(Optional.ofNullable(count.getOrder()).orElse(0)); + response.setInvoice(Optional.ofNullable(count.getInvoice()).orElse(0)); + return response; + } + + public Pager> getProcessedPage(ApprovalTodoPageRequest request, String userId) { + // 在未登录场景下直接返回空分页数据。 + if (StringUtils.isBlank(userId)) { + return new Pager<>(Collections.emptyList(), 0, request.getPageSize(), request.getCurrent()); + } + // 解析资源类型过滤参数,支持 ALL 或具体类型。 + ApprovalFormTypeEnum filterType = parseFilterType(request.getResourceType()); + if (!isAllType(request.getResourceType()) && filterType == null) { + return new Pager<>(Collections.emptyList(), 0, request.getPageSize(), request.getCurrent()); + } + // 分页查询当前用户已处理任务,并按更新时间倒序返回最新处理记录。 + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + String keyword = StringUtils.trimToNull(request.getKeyword()); + List items = extApprovalTaskMapper.selectProcessedTasks( + userId, + filterType == null ? null : filterType.name().toLowerCase(), + keyword + ); + return PageUtils.setPageInfo(page, items); + } + + public Pager> getInitiatedPage(ApprovalTodoPageRequest request, String userId) { + // 在未登录场景下直接返回空分页数据。 + if (StringUtils.isBlank(userId)) { + return new Pager<>(Collections.emptyList(), 0, request.getPageSize(), request.getCurrent()); + } + // 解析资源类型过滤参数,支持 ALL 或具体类型。 + ApprovalFormTypeEnum filterType = parseFilterType(request.getResourceType()); + if (!isAllType(request.getResourceType()) && filterType == null) { + return new Pager<>(Collections.emptyList(), 0, request.getPageSize(), request.getCurrent()); + } + + // 分页查询当前用户发起的审批实例,并按更新时间倒序返回。 + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + String keyword = StringUtils.trimToNull(request.getKeyword()); + List items = extApprovalTaskMapper.selectInitiatedTasks( + userId, + filterType == null ? null : filterType.name().toLowerCase(), + keyword + ); + // 返回分页结果,分页元信息沿用 PageHelper 查询结果。 + return PageUtils.setPageInfo(page, items); + } + + public Pager> getCcPage(ApprovalTodoPageRequest request, String userId) { + // 在未登录场景下直接返回空分页数据。 + if (StringUtils.isBlank(userId)) { + return new Pager<>(Collections.emptyList(), 0, request.getPageSize(), request.getCurrent()); + } + // 解析资源类型过滤参数,支持 ALL 或具体类型。 + ApprovalFormTypeEnum filterType = parseFilterType(request.getResourceType()); + if (!isAllType(request.getResourceType()) && filterType == null) { + return new Pager<>(Collections.emptyList(), 0, request.getPageSize(), request.getCurrent()); + } + + // 分页查询抄送给当前用户的任务记录,并按更新时间倒序返回。 + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + String keyword = StringUtils.trimToNull(request.getKeyword()); + List items = extApprovalTaskMapper.selectCcTasks( + userId, + filterType == null ? null : filterType.name().toLowerCase(), + keyword + ); + return PageUtils.setPageInfo(page, items); + } + + public void deleteApprovalTaskByInstanceId(String approvalInstanceId) { + // 实例ID为空时不执行删除。 + if (StringUtils.isBlank(approvalInstanceId)) { + return; + } + // 按审批实例ID删除审批任务。 + approvalTaskMapper.deleteByLambda(new LambdaQueryWrapper() + .eq(ApprovalTask::getInstanceId, approvalInstanceId)); + } + + public void deleteApprovalTaskByInstanceIds(List approvalInstanceIds) { + // 实例ID集合为空时不执行删除。 + if (approvalInstanceIds == null || approvalInstanceIds.isEmpty()) { + return; + } + // 过滤空白实例ID并批量删除审批任务。 + List validInstanceIds = approvalInstanceIds.stream() + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + if (validInstanceIds.isEmpty()) { + return; + } + approvalTaskMapper.deleteByLambda(new LambdaQueryWrapper() + .in(ApprovalTask::getInstanceId, validInstanceIds)); + } + + private Map loadSubmitterNameMap(List instances) { + // 提取申请人ID并去重。 + List submitterIds = instances.stream() + .map(ApprovalInstance::getSubmitterId) + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + if (submitterIds.isEmpty()) { + return Collections.emptyMap(); + } + // 批量查询申请人名称映射。 + return userMapper.selectByIds(submitterIds).stream() + .collect(Collectors.toMap(User::getId, User::getName, (prev, next) -> prev)); + } + + private Map> loadResourceNameMap(List instances) { + // 先按资源类型分组收集资源ID。 + Map> typeResourceIds = new EnumMap<>(ApprovalFormTypeEnum.class); + for (ApprovalInstance instance : instances) { + ApprovalFormTypeEnum formType = parseFormType(instance.getType()); + if (formType == null || StringUtils.isBlank(instance.getResourceId())) { + continue; + } + typeResourceIds.computeIfAbsent(formType, key -> new ArrayList<>()).add(instance.getResourceId()); + } + + // 按类型批量查询名称并构造统一映射。 + Map> resourceNameMap = new EnumMap<>(ApprovalFormTypeEnum.class); + typeResourceIds.forEach((formType, ids) -> { + List distinctIds = ids.stream().distinct().toList(); + switch (formType) { + case QUOTATION -> resourceNameMap.put(formType, quotationMapper.selectByIds(distinctIds).stream() + .collect(Collectors.toMap(OpportunityQuotation::getId, OpportunityQuotation::getName, (prev, next) -> prev))); + case CONTRACT -> resourceNameMap.put(formType, contractMapper.selectByIds(distinctIds).stream() + .collect(Collectors.toMap(Contract::getId, Contract::getName, (prev, next) -> prev))); + case ORDER -> resourceNameMap.put(formType, orderMapper.selectByIds(distinctIds).stream() + .collect(Collectors.toMap(Order::getId, Order::getName, (prev, next) -> prev))); + case INVOICE -> resourceNameMap.put(formType, invoiceMapper.selectByIds(distinctIds).stream() + .collect(Collectors.toMap(ContractInvoice::getId, ContractInvoice::getName, (prev, next) -> prev))); + } + }); + return resourceNameMap; + } + + private ApprovalFormTypeEnum parseFormType(String type) { + if (StringUtils.isBlank(type)) { + return null; + } + // 优先按枚举名匹配(如 QUOTATION/CONTRACT/ORDER/INVOICE)。 + for (ApprovalFormTypeEnum formType : ApprovalFormTypeEnum.values()) { + if (Strings.CI.equals(formType.name(), type)) { + return formType; + } + } + // 兼容旧值或别名写法。 + return switch (type.toLowerCase()) { + case "quote", "quotation" -> ApprovalFormTypeEnum.QUOTATION; + case "contract" -> ApprovalFormTypeEnum.CONTRACT; + case "order" -> ApprovalFormTypeEnum.ORDER; + case "invoice" -> ApprovalFormTypeEnum.INVOICE; + default -> null; + }; + } + + private boolean isAllType(String resourceType) { + return StringUtils.isBlank(resourceType) || Strings.CS.equals(resourceType, "ALL"); + } + + private ApprovalFormTypeEnum parseFilterType(String resourceType) { + if (isAllType(resourceType)) { + return null; + } + return parseFormType(resourceType); + } + + private Set loadInstanceIdsByResourceName(String resourceName) { + if (StringUtils.isBlank(resourceName)) { + return null; + } + // 按资源名称模糊匹配资源表,构造资源类型到资源ID的映射。 + Map> resourceIdsByType = new EnumMap<>(ApprovalFormTypeEnum.class); + LambdaQueryWrapper quotationWrapper = new LambdaQueryWrapper<>(); + quotationWrapper.like(OpportunityQuotation::getName, resourceName); + List quotationIds = quotationMapper.selectListByLambda(quotationWrapper).stream() + .map(OpportunityQuotation::getId).filter(StringUtils::isNotBlank).toList(); + if (!quotationIds.isEmpty()) { + resourceIdsByType.put(ApprovalFormTypeEnum.QUOTATION, quotationIds); + } + LambdaQueryWrapper contractWrapper = new LambdaQueryWrapper<>(); + contractWrapper.like(Contract::getName, resourceName); + List contractIds = contractMapper.selectListByLambda(contractWrapper).stream() + .map(Contract::getId).filter(StringUtils::isNotBlank).toList(); + if (!contractIds.isEmpty()) { + resourceIdsByType.put(ApprovalFormTypeEnum.CONTRACT, contractIds); + } + LambdaQueryWrapper orderWrapper = new LambdaQueryWrapper<>(); + orderWrapper.like(Order::getName, resourceName); + List orderIds = orderMapper.selectListByLambda(orderWrapper).stream() + .map(Order::getId).filter(StringUtils::isNotBlank).toList(); + if (!orderIds.isEmpty()) { + resourceIdsByType.put(ApprovalFormTypeEnum.ORDER, orderIds); + } + LambdaQueryWrapper invoiceWrapper = new LambdaQueryWrapper<>(); + invoiceWrapper.like(ContractInvoice::getName, resourceName); + List invoiceIds = invoiceMapper.selectListByLambda(invoiceWrapper).stream() + .map(ContractInvoice::getId).filter(StringUtils::isNotBlank).toList(); + if (!invoiceIds.isEmpty()) { + resourceIdsByType.put(ApprovalFormTypeEnum.INVOICE, invoiceIds); + } + if (resourceIdsByType.isEmpty()) { + return Collections.emptySet(); + } + + // 按资源类型与资源ID反查审批实例ID。 + Set instanceIds = new HashSet<>(); + resourceIdsByType.forEach((formType, resourceIds) -> { + List aliases = switch (formType) { + case QUOTATION -> List.of("quotation", "quote"); + case CONTRACT -> List.of("contract"); + case ORDER -> List.of("order"); + case INVOICE -> List.of("invoice"); + }; + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(ApprovalInstance::getType, aliases) + .in(ApprovalInstance::getResourceId, resourceIds); + instanceIds.addAll(approvalInstanceMapper.selectListByLambda(wrapper).stream() + .map(ApprovalInstance::getId) + .filter(StringUtils::isNotBlank) + .toList()); + }); + return instanceIds; + } + + private Pager> buildTaskPageResult(Page page, List tasks) { + // 无任务时直接返回空分页数据。 + if (tasks.isEmpty()) { + return PageUtils.setPageInfo(page, Collections.emptyList()); + } + + // 批量加载任务对应审批实例,避免循环查询实例数据。 + List instanceIds = tasks.stream() + .map(ApprovalTask::getInstanceId) + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + Map instanceMap = approvalInstanceMapper.selectByIds(instanceIds).stream() + .collect(Collectors.toMap(ApprovalInstance::getId, Function.identity(), (prev, next) -> prev)); + if (instanceMap.isEmpty()) { + return PageUtils.setPageInfo(page, Collections.emptyList()); + } + + // 预加载申请人名称和资源名称映射,减少组装阶段的重复计算。 + List instances = tasks.stream() + .map(task -> instanceMap.get(task.getInstanceId())) + .filter(Objects::nonNull) + .toList(); + Map submitterMap = loadSubmitterNameMap(instances); + Map> resourceNameMap = loadResourceNameMap(instances); + + // 逐条组装审批任务分页结果并回填审批操作和数据结果字段。 + List list = new ArrayList<>(tasks.size()); + for (ApprovalTask task : tasks) { + ApprovalInstance instance = instanceMap.get(task.getInstanceId()); + if (instance == null) { + continue; + } + ApprovalFormTypeEnum formType = parseFormType(instance.getType()); + if (formType == null) { + continue; + } + String resourceName = Optional.ofNullable(resourceNameMap.get(formType)) + .map(map -> map.get(instance.getResourceId())) + .orElse(StringUtils.EMPTY); + ApprovalTodoItemResponse item = new ApprovalTodoItemResponse(); + item.setResourceId(instance.getResourceId()); + item.setResourceName(resourceName); + item.setResourceType(formType.name()); + item.setApplicant(submitterMap.get(instance.getSubmitterId())); + item.setSubmitTime(instance.getSubmitTime()); + item.setApprovalOperation(StringUtils.defaultIfBlank(task.getAction(), task.getStatus())); + item.setDataResult(instance.getApprovalStatus()); + item.setApprovalTaskId(task.getId()); + item.setApprovalNodeId(task.getNodeId()); + item.setApprovalInstanceId(task.getInstanceId()); + item.setApprovalId(task.getApproverId()); + list.add(item); + } + // 返回分页结果,分页元信息沿用 PageHelper 查询结果。 + return PageUtils.setPageInfo(page, list); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/constants/ClueStatus.java b/backend/crm/src/main/java/cn/cordys/crm/clue/constants/ClueStatus.java new file mode 100644 index 0000000..857cca8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/constants/ClueStatus.java @@ -0,0 +1,56 @@ +package cn.cordys.crm.clue.constants; + +import org.apache.commons.lang3.Strings; + +/** + * @Author: jianxing + * @CreateTime: 2025-03-11 15:53 + */ +public enum ClueStatus { + + /** + * 新建 + */ + NEW("NEW", "新建"), + /** + * 跟进中 + */ + FOLLOWING("FOLLOWING", "跟进中"), + /** + * 感兴趣 + */ + INTERESTED("INTERESTED", "感兴趣"), + /** + * 成功 + */ + SUCCESS("SUCCESS", "成功"), + /** + * 失败 + */ + FAIL("FAIL", "失败"); + + private final String key; + private final String name; + + ClueStatus(String key, String name) { + this.key = key; + this.name = name; + } + + public static String getByKey(String key) { + for (ClueStatus status : ClueStatus.values()) { + if (Strings.CS.equals(key, status.key)) { + return status.name; + } + } + return null; + } + + public String getKey() { + return key; + } + + public String getName() { + return name; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueCapacityController.java b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueCapacityController.java new file mode 100644 index 0000000..b221518 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueCapacityController.java @@ -0,0 +1,54 @@ +package cn.cordys.crm.clue.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.clue.dto.ClueCapacityDTO; +import cn.cordys.crm.clue.service.ClueCapacityService; +import cn.cordys.crm.system.dto.request.CapacityAddRequest; +import cn.cordys.crm.system.dto.request.CapacityUpdateRequest; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/lead-capacity") +@Tag(name = "线索库容设置") +public class ClueCapacityController { + + @Resource + private ClueCapacityService clueCapacityService; + + @GetMapping("/get") + @Operation(summary = "获取线索库容设置") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public List list() { + return clueCapacityService.list(OrganizationContext.getOrganizationId()); + } + + @PostMapping("/add") + @Operation(summary = "添加线索库容设置") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void add(@Validated @RequestBody CapacityAddRequest request) { + clueCapacityService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @Operation(summary = "修改线索库容设置") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void update(@Validated @RequestBody CapacityUpdateRequest request) { + clueCapacityService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/delete/{id}") + @Operation(summary = "删除线索库容设置") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void delete(@PathVariable("id") @Validated String id) { + clueCapacityService.delete(id); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueController.java b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueController.java new file mode 100644 index 0000000..dea95b8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueController.java @@ -0,0 +1,239 @@ +package cn.cordys.crm.clue.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.ChartAnalysisRequest; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.CsBatchPermission; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.clue.domain.Clue; +import cn.cordys.crm.clue.dto.request.*; +import cn.cordys.crm.clue.dto.response.ClueGetResponse; +import cn.cordys.crm.clue.dto.response.ClueListResponse; +import cn.cordys.crm.clue.service.ClueExportService; +import cn.cordys.crm.clue.service.ClueService; +import cn.cordys.crm.customer.dto.request.BatchReTransitionCustomerRequest; +import cn.cordys.crm.customer.dto.request.ClueTransformRequest; +import cn.cordys.crm.customer.dto.request.CustomerPageRequest; +import cn.cordys.crm.customer.dto.response.CustomerListResponse; +import cn.cordys.crm.customer.service.CustomerService; +import cn.cordys.crm.system.dto.request.BatchPoolReasonRequest; +import cn.cordys.crm.system.dto.request.PoolReasonRequest; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.BatchAffectResponse; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * @author song-cc-rock + */ +@RestController +@Tag(name = "线索") +@RequestMapping("/lead") +public class ClueController { + + @Resource + private ClueService clueService; + @Resource + private ClueExportService clueExportService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private DataScopeService dataScopeService; + @Resource + private CustomerService customerService; + + @GetMapping("/module/form") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CLUE_MANAGEMENT_POOL_READ}, logical = Logical.OR) + @Operation(summary = "获取表单配置") + public ModuleFormConfigDTO getModuleFormConfig() { + return moduleFormCacheService.getBusinessFormConfig(FormKey.CLUE.getKey(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/page") + @CsPermission(PermissionConstants.CLUE_MANAGEMENT_READ) + @Operation(summary = "线索列表") + public PagerWithOption> list(@Validated @RequestBody CluePageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CLUE.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CLUE_MANAGEMENT_READ); + return clueService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission, false); + } + + @GetMapping("/get/{id}") + @CsPermission(value = PermissionConstants.CLUE_MANAGEMENT_READ, resourceId = "{#id}", formType = FormKeyConstants.CLUE) + @Operation(summary = "线索详情") + public ClueGetResponse get(@PathVariable String id) { + return clueService.get(id); + } + + @PostMapping("/add") + @CsPermission(PermissionConstants.CLUE_MANAGEMENT_ADD) + @Operation(summary = "添加线索") + public Clue add(@Validated @RequestBody ClueAddRequest request) { + return clueService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @CsPermission(value = PermissionConstants.CLUE_MANAGEMENT_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.CLUE) + @Operation(summary = "更新线索") + public Clue update(@Validated @RequestBody ClueUpdateRequest request) { + return clueService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/status/update") + @CsPermission(value = PermissionConstants.CLUE_MANAGEMENT_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.CLUE) + @Operation(summary = "更新线索状态") + public void updateStatus(@Validated @RequestBody ClueStatusUpdateRequest request) { + clueService.updateStatus(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/delete/{id}") + @CsPermission(value = PermissionConstants.CLUE_MANAGEMENT_DELETE, resourceId = "{#id}", formType = FormKeyConstants.CLUE) + @Operation(summary = "删除线索") + public void delete(@PathVariable String id) { + clueService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/transition/account") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_ADD) + @Operation(summary = "转为客户") + public void transitionCustomer(@Validated @RequestBody ClueTransitionCustomerRequest request) { + clueService.transitionCustomer(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch/transfer") + @CsBatchPermission(value = PermissionConstants.CLUE_MANAGEMENT_TRANSFER, resourceId = "{#request.ids}", formType = FormKeyConstants.CLUE) + @Operation(summary = "批量转移线索") + public void batchTransfer(@RequestBody ClueBatchTransferRequest request) { + clueService.batchTransfer(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch/update") + @CsBatchPermission(value = PermissionConstants.CLUE_MANAGEMENT_UPDATE, resourceId = "{#request.ids}", formType = FormKeyConstants.CLUE) + @Operation(summary = "批量更新线索") + public void batchUpdate(@Validated @RequestBody ResourceBatchEditRequest request) { + clueService.batchUpdate(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/batch/delete") + @CsBatchPermission(value = PermissionConstants.CLUE_MANAGEMENT_DELETE, resourceId = "{#ids}", formType = FormKeyConstants.CLUE) + @Operation(summary = "批量删除线索") + public void batchDelete(@RequestBody @NotNull List ids) { + clueService.batchDelete(ids, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch/to-pool") + @CsBatchPermission(value = PermissionConstants.CLUE_MANAGEMENT_RECYCLE, resourceId = "{#request.ids}", formType = FormKeyConstants.CLUE) + @Operation(summary = "批量移入线索池") + public BatchAffectResponse batchToPool(@RequestBody BatchPoolReasonRequest request) { + return clueService.batchToPool(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/to-pool") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_RECYCLE, resourceId = "{#request.id}", formType = FormKeyConstants.CLUE) + @Operation(summary = "移入线索池") + public BatchAffectResponse toPool(@Validated @RequestBody PoolReasonRequest request) { + return clueService.toPool(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/tab") + @CsPermission(PermissionConstants.CLUE_MANAGEMENT_READ) + @Operation(summary = "所有线索和部门线索tab是否显示") + public ResourceTabEnableDTO getTabEnableConfig() { + return clueService.getTabEnableConfig(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/export") + @CsPermission(PermissionConstants.CLUE_MANAGEMENT_EXPORT) + @Operation(summary = "导出全部") + public String exportAll(@Validated @RequestBody ClueExportRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CLUE.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CLUE_MANAGEMENT_READ); + return clueExportService.exportAll(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission, LocaleContextHolder.getLocale()); + } + + @PostMapping("/export-select") + @CsBatchPermission(value = PermissionConstants.CLUE_MANAGEMENT_EXPORT, resourceId = "{#request.ids}", formType = FormKeyConstants.CLUE) + @Operation(summary = "导出选中") + public String exportSelect(@Validated @RequestBody ExportSelectRequest request) { + return clueExportService.exportSelect(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), LocaleContextHolder.getLocale()); + } + + @PostMapping("/transition/account/page") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.AND) + @Operation(summary = "客户转移分页查询") + public PagerWithOption> transitionCustomerPage(@Validated @RequestBody CustomerPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CUSTOMER.getKey()); + return customerService.transitionList(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/re-transition/account") + @CsBatchPermission(value = PermissionConstants.CLUE_MANAGEMENT_UPDATE, resourceId = "{#request.clueIds}", formType = FormKeyConstants.CLUE) + @Operation(summary = "批量关联已有客户") + public void batchTransition(@Validated @RequestBody BatchReTransitionCustomerRequest request) { + clueService.batchTransition(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/transform") + @CsPermission(value = PermissionConstants.CLUE_MANAGEMENT_UPDATE, resourceId = "{#request.clueId}", formType = FormKeyConstants.CLUE) + @Operation(summary = "转换") + public String transform(@Validated @RequestBody ClueTransformRequest request) { + return clueService.transform(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/template/download") + @CsPermission(PermissionConstants.CLUE_MANAGEMENT_IMPORT) + @Operation(summary = "下载导入模板") + public void downloadImportTpl(HttpServletResponse response) { + clueService.downloadImportTpl(response, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/import/pre-check") + @Operation(summary = "导入检查") + @CsPermission(PermissionConstants.CLUE_MANAGEMENT_IMPORT) + public ImportResponse preCheck(@RequestPart(value = "file") MultipartFile file) { + return clueService.importPreCheck(file, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/import") + @Operation(summary = "导入") + @CsPermission(PermissionConstants.CLUE_MANAGEMENT_IMPORT) + public ImportResponse realImport(@RequestPart(value = "file") MultipartFile file) { + return clueService.realImport(file, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/chart") + @CsPermission(PermissionConstants.CLUE_MANAGEMENT_READ) + @Operation(summary = "客户图表生成") + public List chart(@Validated @RequestBody ChartAnalysisRequest request) { + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CLUE_MANAGEMENT_READ); + return clueService.chart(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueFollowPlanController.java b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueFollowPlanController.java new file mode 100644 index 0000000..2ef9186 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueFollowPlanController.java @@ -0,0 +1,91 @@ +package cn.cordys.crm.clue.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.follow.domain.FollowUpPlan; +import cn.cordys.crm.follow.dto.CustomerDataDTO; +import cn.cordys.crm.follow.dto.request.FollowUpPlanAddRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanPageRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanStatusRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanUpdateRequest; +import cn.cordys.crm.follow.dto.response.FollowUpPlanDetailResponse; +import cn.cordys.crm.follow.dto.response.FollowUpPlanListResponse; +import cn.cordys.crm.follow.service.FollowUpPlanService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "线索跟进计划") +@RestController +@RequestMapping("/lead/follow/plan") +public class ClueFollowPlanController { + + @Resource + private FollowUpPlanService followUpPlanService; + + @PostMapping("/add") + @CsPermission(PermissionConstants.CLUE_MANAGEMENT_UPDATE) + @Operation(summary = "添加线索跟进计划") + public FollowUpPlan add(@Validated @RequestBody FollowUpPlanAddRequest request) { + return followUpPlanService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/update") + @CsPermission(value = PermissionConstants.CLUE_MANAGEMENT_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.FOLLOW_PLAN) + @Operation(summary = "更新线索跟进计划") + public FollowUpPlan update(@Validated @RequestBody FollowUpPlanUpdateRequest request) { + return followUpPlanService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/page") + @CsPermission(PermissionConstants.CLUE_MANAGEMENT_READ) + @Operation(summary = "线索跟进计划列表") + public PagerWithOption> list(@Validated @RequestBody FollowUpPlanPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.FOLLOW_PLAN.getKey()); + return followUpPlanService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), "CLUE", "CLUE"); + } + + + @GetMapping("/get/{id}") + @CsPermission(value = PermissionConstants.CLUE_MANAGEMENT_READ, resourceId = "{#id}", formType = FormKeyConstants.FOLLOW_PLAN) + @Operation(summary = "线索跟进计划详情") + public FollowUpPlanDetailResponse get(@PathVariable String id) { + return followUpPlanService.get(id, OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/cancel/{id}") + @CsPermission(value = PermissionConstants.CLUE_MANAGEMENT_UPDATE, resourceId = "{#id}", formType = FormKeyConstants.FOLLOW_PLAN) + @Operation(summary = "取消线索跟进计划") + public void cancelPlan(@PathVariable String id) { + followUpPlanService.cancelPlan(id, SessionUtils.getUserId()); + } + + + @GetMapping("/delete/{id}") + @Operation(summary = "线索删除跟进计划") + @CsPermission(value = PermissionConstants.CLUE_MANAGEMENT_UPDATE, resourceId = "{#id}", formType = FormKeyConstants.FOLLOW_PLAN) + public void deletePlan(@PathVariable String id) { + followUpPlanService.delete(id); + } + + + @PostMapping("/status/update") + @CsPermission(value = PermissionConstants.CLUE_MANAGEMENT_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.FOLLOW_PLAN) + @Operation(summary = "线索更新跟进计划状态") + public void updateStatus(@Validated @RequestBody FollowUpPlanStatusRequest request) { + followUpPlanService.updateStatus(request, SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueFollowRecordController.java b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueFollowRecordController.java new file mode 100644 index 0000000..1c3b40d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueFollowRecordController.java @@ -0,0 +1,82 @@ +package cn.cordys.crm.clue.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.follow.domain.FollowUpRecord; +import cn.cordys.crm.follow.dto.request.FollowUpRecordAddRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordPageRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordUpdateRequest; +import cn.cordys.crm.follow.dto.response.FollowUpRecordDetailResponse; +import cn.cordys.crm.follow.dto.response.FollowUpRecordListResponse; +import cn.cordys.crm.follow.service.FollowUpRecordService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "线索跟进记录") +@RestController +@RequestMapping("/lead/follow/record") +public class ClueFollowRecordController { + + @Resource + private FollowUpRecordService followUpRecordService; + + @PostMapping("/add") + @CsPermission(PermissionConstants.CLUE_MANAGEMENT_UPDATE) + @Operation(summary = "添加线索跟进记录") + public FollowUpRecord add(@Validated @RequestBody FollowUpRecordAddRequest request) { + return followUpRecordService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/update") + @CsPermission(value = PermissionConstants.CLUE_MANAGEMENT_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.FOLLOW_RECORD) + @Operation(summary = "更新线索跟进记录") + public FollowUpRecord update(@Validated @RequestBody FollowUpRecordUpdateRequest request) { + return followUpRecordService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/pool/page") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_POOL_READ) + @Operation(summary = "线索池跟进记录列表") + public PagerWithOption> poolList(@Validated @RequestBody FollowUpRecordPageRequest request) { + return followUpRecordService.poolList(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), "CLUE", "CLUE"); + } + + @PostMapping("/page") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_READ) + @Operation(summary = "线索跟进记录列表") + public PagerWithOption> list(@Validated @RequestBody FollowUpRecordPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.FOLLOW_RECORD.getKey()); + return followUpRecordService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), "CLUE", "CLUE"); + } + + + @GetMapping("/get/{id}") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CLUE_MANAGEMENT_POOL_READ}, logical = Logical.OR) + @Operation(summary = "线索跟进记录详情") + public FollowUpRecordDetailResponse get(@PathVariable String id) { + return followUpRecordService.get(id, OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @Operation(summary = "线索删除跟进记录") + @CsPermission(value = PermissionConstants.CLUE_MANAGEMENT_UPDATE, resourceId = "{#id}", formType = FormKeyConstants.FOLLOW_RECORD) + public void deleteRecord(@PathVariable String id) { + followUpRecordService.delete(id); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueOwnerHistoryController.java b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueOwnerHistoryController.java new file mode 100644 index 0000000..2df772e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueOwnerHistoryController.java @@ -0,0 +1,36 @@ +package cn.cordys.crm.clue.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.clue.dto.response.ClueOwnerListResponse; +import cn.cordys.crm.clue.service.ClueOwnerHistoryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * @author jianxing + * @date 2025-02-08 17:42:41 + */ +@Tag(name = "线索责任人历史") +@RestController +@RequestMapping("/lead/owner/history") +public class ClueOwnerHistoryController { + @Resource + private ClueOwnerHistoryService clueOwnerHistoryService; + + @GetMapping("/list/{clueId}") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CLUE_MANAGEMENT_POOL_READ}, logical = Logical.OR) + @Operation(summary = "线索责任人历史列表") + public List list(@PathVariable String clueId) { + return clueOwnerHistoryService.list(clueId, OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/controller/CluePoolController.java b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/CluePoolController.java new file mode 100644 index 0000000..296e956 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/CluePoolController.java @@ -0,0 +1,80 @@ +package cn.cordys.crm.clue.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.clue.dto.CluePoolDTO; +import cn.cordys.crm.clue.dto.request.CluePoolAddRequest; +import cn.cordys.crm.clue.dto.request.CluePoolUpdateRequest; +import cn.cordys.crm.clue.service.CluePoolService; +import cn.cordys.security.SessionUtils; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/lead-pool") +@Tag(name = "线索池设置") +public class CluePoolController { + + @Resource + private CluePoolService cluePoolService; + + @PostMapping("/page") + @Operation(summary = "分页获取线索池") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public Pager> page(@Validated @RequestBody BasePageRequest request) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, cluePoolService.page(request, OrganizationContext.getOrganizationId())); + } + + @PostMapping("/add") + @Operation(summary = "新增线索池") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void add(@Validated @RequestBody CluePoolAddRequest request) { + cluePoolService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @Operation(summary = "编辑线索池") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void update(@Validated @RequestBody CluePoolUpdateRequest request) { + cluePoolService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/quick-update") + @Operation(summary = "快捷保存线索池") + public void quickUpdate(@Validated @RequestBody CluePoolUpdateRequest request) { + cluePoolService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/no-pick/{id}") + @Operation(summary = "线索池是否存在未领取线索") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public boolean checkNoPick(@PathVariable String id) { + return cluePoolService.checkNoPick(id); + } + + @GetMapping("/delete/{id}") + @Operation(summary = "删除线索池") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void delete(@PathVariable String id) { + cluePoolService.delete(id); + } + + @GetMapping("/switch/{id}") + @Operation(summary = "启用/禁用线索池") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void switchStatus(@PathVariable String id) { + cluePoolService.switchStatus(id, SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueUserViewController.java b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueUserViewController.java new file mode 100644 index 0000000..52953a7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/ClueUserViewController.java @@ -0,0 +1,95 @@ +package cn.cordys.crm.clue.controller; + + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.UserViewResourceType; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import cn.cordys.crm.system.dto.response.UserViewListResponse; +import cn.cordys.crm.system.dto.response.UserViewResponse; +import cn.cordys.crm.system.service.UserViewService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "线索视图") +@RestController +@RequestMapping("/lead/view") +public class ClueUserViewController { + + @Resource + private UserViewService userViewService; + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_READ) + @Operation(summary = "添加线索视图") + public UserView add(@Validated @RequestBody UserViewAddRequest request) { + return userViewService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), UserViewResourceType.CLUE.name()); + } + + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_READ) + @Operation(summary = "编辑线索视图") + public UserView update(@Validated @RequestBody UserViewUpdateRequest request) { + return userViewService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_READ) + @Operation(summary = "删除线索视图") + public void delete(@PathVariable String id) { + userViewService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/detail/{id}") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_READ) + @Operation(summary = "线索视图详情") + public UserViewResponse viewDetail(@PathVariable String id) { + return userViewService.getViewDetail(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), FormKey.CLUE.getKey()); + } + + + @GetMapping("/list") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_READ) + @Operation(summary = "线索视图列表") + public List queryList() { + return userViewService.list(UserViewResourceType.CLUE.name(), SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/fixed/{id}") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_READ) + @Operation(summary = "线索视图固定/取消固定") + public void fixed(@PathVariable String id) { + userViewService.fixed(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/edit/pos") + @Operation(summary = "线索视图-拖拽排序") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_READ) + public void editPos(@Validated @RequestBody PosRequest request) { + userViewService.editPos(request, SessionUtils.getUserId(), UserViewResourceType.CLUE.name()); + } + + + @GetMapping("/enable/{id}") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_READ) + @Operation(summary = "线索视图-启用/禁用") + public void enable(@PathVariable String id) { + userViewService.enable(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/controller/PoolClueController.java b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/PoolClueController.java new file mode 100644 index 0000000..65b0128 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/PoolClueController.java @@ -0,0 +1,140 @@ +package cn.cordys.crm.clue.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.clue.dto.CluePoolDTO; +import cn.cordys.crm.clue.dto.request.ClueExportRequest; +import cn.cordys.crm.clue.dto.request.CluePageRequest; +import cn.cordys.crm.clue.dto.request.PoolClueAssignRequest; +import cn.cordys.crm.clue.dto.request.PoolCluePickRequest; +import cn.cordys.crm.clue.dto.response.ClueGetResponse; +import cn.cordys.crm.clue.dto.response.ClueListResponse; +import cn.cordys.crm.clue.service.CluePoolExportService; +import cn.cordys.crm.clue.service.ClueService; +import cn.cordys.crm.clue.service.PoolClueService; +import cn.cordys.crm.customer.dto.request.PoolClueChartAnalysisRequest; +import cn.cordys.crm.system.dto.request.PoolBatchAssignRequest; +import cn.cordys.crm.system.dto.request.PoolBatchPickRequest; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.NotEmpty; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "线索池") +@RestController +@RequestMapping("/pool/lead") +public class PoolClueController { + + @Resource + private PoolClueService poolClueService; + @Resource + private ClueService clueService; + @Resource + private CluePoolExportService cluePoolExportService; + + @GetMapping("/options") + @Operation(summary = "获取当前用户线索池选项") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_POOL_READ}) + public List getPoolOptions() { + return poolClueService.getPoolOptions(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/page") + @Operation(summary = "线索列表") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_POOL_READ}) + public PagerWithOption> list(@Validated @RequestBody CluePageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CLUE.getKey()); + return clueService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), null, false); + } + + @PostMapping("/pick") + @Operation(summary = "领取线索") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_POOL_PICK}) + public void pick(@Validated @RequestBody PoolCluePickRequest request) { + poolClueService.pick(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/assign") + @Operation(summary = "分配线索") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_POOL_ASSIGN}) + public void assign(@Validated @RequestBody PoolClueAssignRequest request) { + poolClueService.assign(request.getClueId(), request.getAssignUserId(), OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @GetMapping("/delete/{id}") + @Operation(summary = "删除线索") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_POOL_DELETE}) + public void delete(@PathVariable String id) { + poolClueService.delete(id); + } + + @GetMapping("/get/{id}") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_POOL_READ) + @Operation(summary = "线索详情") + public ClueGetResponse get(@PathVariable String id) { + return clueService.get(id); + } + + @PostMapping("/batch-pick") + @Operation(summary = "批量领取线索") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_POOL_PICK}) + public void batchPick(@Validated @RequestBody PoolBatchPickRequest request) { + poolClueService.batchPick(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch-assign") + @Operation(summary = "批量分配线索") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_POOL_ASSIGN}) + public void batchAssign(@Validated @RequestBody PoolBatchAssignRequest request) { + poolClueService.batchAssign(request, request.getAssignUserId(), OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/batch-update") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_POOL_UPDATE) + @Operation(summary = "批量更新线索") + public void batchUpdate(@Validated @RequestBody ResourceBatchEditRequest request) { + poolClueService.batchUpdate(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch-delete") + @Operation(summary = "批量删除线索") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_POOL_DELETE}) + public void batchDelete(@RequestBody @NotEmpty List ids) { + poolClueService.batchDelete(ids, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/export-all") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_POOL_EXPORT) + @Operation(summary = "导出全部") + public String exportAll(@Validated @RequestBody ClueExportRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CLUE.getKey()); + return cluePoolExportService.exportCrossPage(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), null, LocaleContextHolder.getLocale()); + } + + @PostMapping("/export-select") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_POOL_EXPORT) + @Operation(summary = "导出选中") + public String exportSelect(@Validated @RequestBody ExportSelectRequest request) { + return cluePoolExportService.exportSelect(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), LocaleContextHolder.getLocale()); + } + + @PostMapping("/chart") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_POOL_READ) + @Operation(summary = "客户图表生成") + public List chart(@Validated @RequestBody PoolClueChartAnalysisRequest request) { + return cluePoolExportService.chart(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), null); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/controller/PoolClueUserViewController.java b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/PoolClueUserViewController.java new file mode 100644 index 0000000..508b8d8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/controller/PoolClueUserViewController.java @@ -0,0 +1,94 @@ +package cn.cordys.crm.clue.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.UserViewResourceType; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import cn.cordys.crm.system.dto.response.UserViewListResponse; +import cn.cordys.crm.system.dto.response.UserViewResponse; +import cn.cordys.crm.system.service.UserViewService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "线索池视图") +@RestController +@RequestMapping("/pool/lead/view") +public class PoolClueUserViewController { + + @Resource + private UserViewService userViewService; + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_POOL_READ) + @Operation(summary = "添加线索池视图") + public UserView add(@Validated @RequestBody UserViewAddRequest request) { + return userViewService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), UserViewResourceType.CLUE_POOL.name()); + } + + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_POOL_READ) + @Operation(summary = "编辑线索池视图") + public UserView update(@Validated @RequestBody UserViewUpdateRequest request) { + return userViewService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_POOL_READ) + @Operation(summary = "删除线索池视图") + public void delete(@PathVariable String id) { + userViewService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/detail/{id}") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_POOL_READ) + @Operation(summary = "线索池视图详情") + public UserViewResponse viewDetail(@PathVariable String id) { + return userViewService.getViewDetail(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), FormKey.CLUE.getKey()); + } + + + @GetMapping("/list") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_POOL_READ) + @Operation(summary = "线索池视图列表") + public List queryList() { + return userViewService.list(UserViewResourceType.CLUE_POOL.name(), SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/fixed/{id}") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_POOL_READ) + @Operation(summary = "线索池视图固定/取消固定") + public void fixed(@PathVariable String id) { + userViewService.fixed(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/edit/pos") + @Operation(summary = "线索池视图-拖拽排序") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_POOL_READ) + public void editPos(@Validated @RequestBody PosRequest request) { + userViewService.editPos(request, SessionUtils.getUserId(), UserViewResourceType.CLUE_POOL.name()); + } + + + @GetMapping("/enable/{id}") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_POOL_READ) + @Operation(summary = "线索池视图-启用/禁用") + public void enable(@PathVariable String id) { + userViewService.enable(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/domain/Clue.java b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/Clue.java new file mode 100644 index 0000000..64cd1a4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/Clue.java @@ -0,0 +1,68 @@ +package cn.cordys.crm.clue.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.util.List; + + +/** + * 线索 + * + * @author jianxing + * @date 2025-03-10 11:57:28 + */ +@Data +@Table(name = "clue") +public class Clue extends BaseModel { + + @Schema(description = "客户名称") + private String name; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "阶段") + private String stage; + + @Schema(description = "上次修改前的线索阶段") + private String lastStage; + + @Schema(description = "联系人名称") + private String contact; + + @Schema(description = "联系人电话") + private String phone; + + @Schema(description = "意向产品id") + private List products; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "创建时间") + private Long collectionTime; + + @Schema(description = "是否在线索池") + private Boolean inSharedPool; + + @Schema(description = "转移成客户或者线索") + private String transitionType; + + @Schema(description = "客户id或者线索id") + private String transitionId; + + @Schema(description = "最新跟进人") + private String follower; + + @Schema(description = "最新跟进时间") + private Long followTime; + + @Schema(description = "线索池ID") + private String poolId; + + @Schema(description = "线索池原因ID") + private String reasonId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/domain/ClueCapacity.java b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/ClueCapacity.java new file mode 100644 index 0000000..37d79f5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/ClueCapacity.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.clue.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "clue_capacity") +public class ClueCapacity extends BaseModel { + + @Schema(description = "组织ID") + private String organizationId; + + @Schema(description = "范围ID") + private String scopeId; + + @Schema(description = "库容") + private Integer capacity; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/domain/ClueField.java b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/ClueField.java new file mode 100644 index 0000000..5779676 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/ClueField.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.clue.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 线索自定义属性 + * + * @author jianxing + * @date 2025-02-10 18:12:46 + */ +@Data +@Table(name = "clue_field") +public class ClueField extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/domain/ClueFieldBlob.java b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/ClueFieldBlob.java new file mode 100644 index 0000000..e2de441 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/ClueFieldBlob.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.clue.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 线索自定义属性大文本 + * + * @author jianxing + * @date 2025-02-27 14:43:46 + */ +@Data +@Table(name = "clue_field_blob") +public class ClueFieldBlob extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/domain/ClueOwner.java b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/ClueOwner.java new file mode 100644 index 0000000..1a57315 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/ClueOwner.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.clue.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 线索历史责任人 + * + * @author jianxing + * @date 2025-03-12 15:46:27 + */ +@Data +@Table(name = "clue_owner") +public class ClueOwner { + + @Schema(description = "id") + private String id; + + @Schema(description = "线索id") + private String clueId; + + @Schema(description = "责任人") + private String owner; + + @Schema(description = "领取时间") + private Long collectionTime; + + @Schema(description = "结束时间") + private Long endTime; + + @Schema(description = "操作人") + private String operator; + + @Schema(description = "线索池原因ID") + private String reasonId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/domain/CluePool.java b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/CluePool.java new file mode 100644 index 0000000..172ecad --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/CluePool.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.clue.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "clue_pool") +public class CluePool extends BaseModel { + + @Schema(description = "线索池名称") + private String name; + + @Schema(description = "成员ID") + private String scopeId; + + @Schema(description = "组织ID") + private String organizationId; + + @Schema(description = "管理员ID") + private String ownerId; + + @Schema(description = "启用/禁用") + private Boolean enable; + + @Schema(description = "自动回收") + private Boolean auto; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/domain/CluePoolHiddenField.java b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/CluePoolHiddenField.java new file mode 100644 index 0000000..5a9c2ff --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/CluePoolHiddenField.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.clue.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "clue_pool_hidden_field") +public class CluePoolHiddenField { + + @Schema(description = "线索池ID") + private String poolId; + + @Schema(description = "字段ID") + private String fieldId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/domain/CluePoolPickRule.java b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/CluePoolPickRule.java new file mode 100644 index 0000000..e9f240b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/CluePoolPickRule.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.clue.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "clue_pool_pick_rule") +public class CluePoolPickRule extends BaseModel { + + @Schema(description = "线索池ID") + private String poolId; + + @Schema(description = "是否限制每日领取数量") + private Boolean limitOnNumber; + + @Schema(description = "领取数量") + private Integer pickNumber; + + @Schema(description = "是否限制前归属人领取") + private Boolean limitPreOwner; + + @Schema(description = "领取间隔天数") + private Integer pickIntervalDays; + + @Schema(description = "是否限制新数据") + private Boolean limitNew; + + @Schema(description = "新数据领取间隔") + private Integer newPickInterval; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/domain/CluePoolRecycleRule.java b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/CluePoolRecycleRule.java new file mode 100644 index 0000000..f2f2779 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/domain/CluePoolRecycleRule.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.clue.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "clue_pool_recycle_rule") +public class CluePoolRecycleRule extends BaseModel { + + @Schema(description = "线索池ID") + private String poolId; + + @Schema(description = "操作符") + private String operator; + + @Schema(description = "回收条件") + private String condition; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/ClueCapacityDTO.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/ClueCapacityDTO.java new file mode 100644 index 0000000..bf4b28c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/ClueCapacityDTO.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.clue.dto; + +import cn.cordys.crm.clue.domain.ClueCapacity; +import cn.cordys.crm.system.dto.ScopeNameDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class ClueCapacityDTO extends ClueCapacity { + + @Schema(description = "成员集合") + private List members; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/ClueFollowDTO.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/ClueFollowDTO.java new file mode 100644 index 0000000..b5ee509 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/ClueFollowDTO.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.clue.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ClueFollowDTO { + + /** + * 最新跟进人 + */ + private String follower; + /** + * 最新跟进时间 + */ + private Long followerTime; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/CluePoolDTO.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/CluePoolDTO.java new file mode 100644 index 0000000..406b981 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/CluePoolDTO.java @@ -0,0 +1,30 @@ +package cn.cordys.crm.clue.dto; + +import cn.cordys.crm.clue.domain.CluePool; +import cn.cordys.crm.system.dto.ScopeNameDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class CluePoolDTO extends CluePool { + + @Schema(description = "成员集合") + private List members; + @Schema(description = "管理员集合") + private List owners; + @Schema(description = "创建人名称") + private String createUserName; + @Schema(description = "更新人名称") + private String updateUserName; + @Schema(description = "领取规则") + private CluePoolPickRuleDTO pickRule; + @Schema(description = "回收规则") + private CluePoolRecycleRuleDTO recycleRule; + @Schema(description = "字段配置") + private List fieldConfigs; + + @Schema(description = "是否可编辑") + private Boolean editable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/CluePoolFieldConfigDTO.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/CluePoolFieldConfigDTO.java new file mode 100644 index 0000000..b15d67b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/CluePoolFieldConfigDTO.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.clue.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class CluePoolFieldConfigDTO { + @Schema(description = "字段ID") + private String fieldId; + @Schema(description = "字段名称") + private String fieldName; + @Schema(description = "是否启用") + private Boolean enable; + @Schema(description = "是否可编辑") + private Boolean editable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/CluePoolPickRuleDTO.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/CluePoolPickRuleDTO.java new file mode 100644 index 0000000..36e9a63 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/CluePoolPickRuleDTO.java @@ -0,0 +1,36 @@ +package cn.cordys.crm.clue.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CluePoolPickRuleDTO { + + @NotNull + @Schema(description = "是否限制领取数量") + private Boolean limitOnNumber; + + @Schema(description = "领取数量") + private Integer pickNumber; + + @NotNull + @Schema(description = "是否限制前归属人领取") + private Boolean limitPreOwner; + + @Schema(description = "领取间隔天数") + private Integer pickIntervalDays; + + @NotNull + @Schema(description = "是否限制新数据") + private Boolean limitNew; + + @Schema(description = "新数据领取间隔") + private Integer newPickInterval; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/CluePoolRecycleRuleDTO.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/CluePoolRecycleRuleDTO.java new file mode 100644 index 0000000..3674438 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/CluePoolRecycleRuleDTO.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.clue.dto; + +import cn.cordys.crm.system.dto.RuleConditionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CluePoolRecycleRuleDTO { + + @Schema(description = "操作符") + private String operator; + + @Schema(description = "规则条件集合") + private List conditions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/TransformCsAssociateDTO.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/TransformCsAssociateDTO.java new file mode 100644 index 0000000..a8add7f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/TransformCsAssociateDTO.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.clue.dto; + +import lombok.Data; + +@Data +public class TransformCsAssociateDTO { + + /** + * 联系人ID + */ + private String contactId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueAddRequest.java new file mode 100644 index 0000000..9e05a4b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueAddRequest.java @@ -0,0 +1,41 @@ +package cn.cordys.crm.clue.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class ClueAddRequest { + + @NotBlank + @Size(max = 255) + @Schema(description = "客户名称") + private String name; + + @Size(max = 32) + @Schema(description = "负责人") + private String owner; + + @Size(max = 255) + @Schema(description = "联系人名称") + private String contact; + + @Size(max = 30) + @Schema(description = "联系人电话") + private String phone; + + @Schema(description = "意向产品", requiredMode = Schema.RequiredMode.REQUIRED) + private List products; + + @Schema(description = "模块字段值") + private List moduleFields; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueBatchTransferRequest.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueBatchTransferRequest.java new file mode 100644 index 0000000..66ae2c7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueBatchTransferRequest.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.clue.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class ClueBatchTransferRequest { + + @NotEmpty + @Schema(description = "ids", requiredMode = Schema.RequiredMode.REQUIRED) + private List ids; + + @NotBlank + @Size(max = 32) + @Schema(description = "修改负责人", requiredMode = Schema.RequiredMode.REQUIRED) + private String owner; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueExportRequest.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueExportRequest.java new file mode 100644 index 0000000..26fd82c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueExportRequest.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.clue.dto.request; + +import cn.cordys.common.dto.ExportHeadDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * @author song-cc-rock + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ClueExportRequest extends CluePageRequest { + + @NotBlank + @Schema(description = "文件名", requiredMode = Schema.RequiredMode.REQUIRED) + private String fileName; + + @Schema(description = "表头信息", requiredMode = Schema.RequiredMode.REQUIRED) + private List headList; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/CluePageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/CluePageRequest.java new file mode 100644 index 0000000..31dc405 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/CluePageRequest.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.clue.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class CluePageRequest extends BasePageRequest { + @Schema(description = "线索池ID{线索池-线索列表时传参}") + private String poolId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/CluePoolAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/CluePoolAddRequest.java new file mode 100644 index 0000000..b123ae7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/CluePoolAddRequest.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.clue.dto.request; + +import cn.cordys.crm.clue.dto.CluePoolPickRuleDTO; +import cn.cordys.crm.clue.dto.CluePoolRecycleRuleDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; +import java.util.Set; + +@Data +public class CluePoolAddRequest { + + @NotBlank + @Size(max = 255) + @Schema(description = "线索池名称") + private String name; + + @NotNull + @Schema(description = "范围ID集合") + private List scopeIds; + + @NotNull + @Schema(description = "管理员ID集合") + private List ownerIds; + + @NotNull + @Schema(description = "启用/禁用") + private Boolean enable; + + @NotNull + @Schema(description = "自动回收") + private Boolean auto; + + @Schema(description = "领取规则") + private CluePoolPickRuleDTO pickRule; + + @Schema(description = "回收规则") + private CluePoolRecycleRuleDTO recycleRule; + + @Schema(description = "隐藏字段ID集合") + private Set<@NotBlank String> hiddenFieldIds; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/CluePoolUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/CluePoolUpdateRequest.java new file mode 100644 index 0000000..397b788 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/CluePoolUpdateRequest.java @@ -0,0 +1,51 @@ +package cn.cordys.crm.clue.dto.request; + +import cn.cordys.crm.clue.dto.CluePoolPickRuleDTO; +import cn.cordys.crm.clue.dto.CluePoolRecycleRuleDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; +import java.util.Set; + +@Data +public class CluePoolUpdateRequest { + + @NotBlank + @Size(max = 32) + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @NotBlank + @Size(max = 255) + @Schema(description = "线索池名称") + private String name; + + @NotNull + @Schema(description = "范围ID集合") + private List scopeIds; + + @NotNull + @Schema(description = "管理员ID集合") + private List ownerIds; + + @NotNull + @Schema(description = "启用/禁用") + private Boolean enable; + + @NotNull + @Schema(description = "自动回收") + private Boolean auto; + + @Schema(description = "领取规则") + private CluePoolPickRuleDTO pickRule; + + @Schema(description = "回收规则") + private CluePoolRecycleRuleDTO recycleRule; + + @Schema(description = "隐藏字段ID集合") + private Set<@NotBlank String> hiddenFieldIds; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueStatusUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueStatusUpdateRequest.java new file mode 100644 index 0000000..a2295ff --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueStatusUpdateRequest.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.clue.dto.request; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.crm.clue.constants.ClueStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class ClueStatusUpdateRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 32) + private String id; + + @Size(max = 30) + @EnumValue(enumClass = ClueStatus.class) + @Schema(description = "线索状态", requiredMode = Schema.RequiredMode.REQUIRED) + private String stage; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueTransitionCustomerRequest.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueTransitionCustomerRequest.java new file mode 100644 index 0000000..1a1242d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueTransitionCustomerRequest.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.clue.dto.request; + +import cn.cordys.crm.customer.dto.request.CustomerAddRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * @author song-cc-rock + */ +@Data +public class ClueTransitionCustomerRequest extends CustomerAddRequest { + + @NotBlank + @Schema(description = "线索ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String clueId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueTransitionOpportunityRequest.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueTransitionOpportunityRequest.java new file mode 100644 index 0000000..2d044ce --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueTransitionOpportunityRequest.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.clue.dto.request; + +import cn.cordys.crm.opportunity.dto.request.OpportunityAddRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * @author song-cc-rock + */ +@Data +public class ClueTransitionOpportunityRequest extends OpportunityAddRequest { + + @Schema(description = "线索ID") + private String clueId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueUpdateRequest.java new file mode 100644 index 0000000..e047689 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ClueUpdateRequest.java @@ -0,0 +1,47 @@ +package cn.cordys.crm.clue.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class ClueUpdateRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 32) + private String id; + + @Size(max = 255) + @Schema(description = "客户名称") + private String name; + + @Size(max = 32) + @Schema(description = "负责人") + private String owner; + + @Size(max = 255) + @Schema(description = "联系人名称") + private String contact; + + @Size(max = 30) + @Schema(description = "联系人电话") + private String phone; + + @Schema(description = "意向产品", requiredMode = Schema.RequiredMode.REQUIRED) + private List products; + + @Schema(description = "自定义字段") + private List moduleFields; + + private Boolean agentInvoke = false; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ContractDetailPaymentPlanPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ContractDetailPaymentPlanPageRequest.java new file mode 100644 index 0000000..49ee82c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/ContractDetailPaymentPlanPageRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.clue.dto.request; + +import cn.cordys.crm.contract.dto.request.ContractPaymentPlanPageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * @author song-cc-rock + */ +@Data +public class ContractDetailPaymentPlanPageRequest extends ContractPaymentPlanPageRequest { + + @NotBlank + @Schema(description = "合同ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String contractId; + + @Override + public String getContractId() { + return contractId; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/PoolClueAssignRequest.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/PoolClueAssignRequest.java new file mode 100644 index 0000000..80b9abd --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/PoolClueAssignRequest.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.clue.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class PoolClueAssignRequest { + + @NotBlank + @Schema(description = "客户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String clueId; + + @NotBlank + @Schema(description = "分配用户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String assignUserId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/PoolCluePickRequest.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/PoolCluePickRequest.java new file mode 100644 index 0000000..2ce614e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/request/PoolCluePickRequest.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.clue.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class PoolCluePickRequest { + + @NotBlank + @Schema(description = "线索ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String clueId; + + @NotBlank + @Schema(description = "线索池ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String poolId; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/response/ClueGetResponse.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/response/ClueGetResponse.java new file mode 100644 index 0000000..2cc6824 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/response/ClueGetResponse.java @@ -0,0 +1,111 @@ +package cn.cordys.crm.clue.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class ClueGetResponse { + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "客户名称") + private String name; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "阶段") + private String stage; + + @Schema(description = "上次修改前的线索阶段") + private String lastStage; + + @Schema(description = "负责人姓名") + private String ownerName; + + @Schema(description = "联系人名称") + private String contact; + + @Schema(description = "联系人电话") + private String phone; + + @Schema(description = "意向产品") + private List products; + + @Schema(description = "归属部门") + private String departmentId; + + @Schema(description = "归属部门名称") + private String departmentName; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "领取时间") + private Long collectionTime; + + @Schema(description = "剩余归属天数") + private Integer reservedDays; + + @Schema(description = "最新跟进人") + private String follower; + + @Schema(description = "最新跟进人名称") + private String followerName; + + @Schema(description = "最新跟进日期") + private Long followTime; + + @Schema(description = "线索池ID") + private String poolId; + + @Schema(description = "默认回收公海名称") + private String recyclePoolName; + + @Schema(description = "转移类型") + private String transitionType; + + @Schema(description = "失败原因ID") + private String reasonId; + + @Schema(description = "失败原因ID") + private String reasonName; + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "选项集合") + private Map> optionMap; + + /** + * 附件集合 + */ + @Schema(description = "附件集合") + private Map> attachmentMap; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/response/ClueListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/response/ClueListResponse.java new file mode 100644 index 0000000..aa5c45a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/response/ClueListResponse.java @@ -0,0 +1,105 @@ +package cn.cordys.crm.clue.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class ClueListResponse { + @Schema(description = "ID") + private String id; + + @Schema(description = "线索名称") + private String name; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "阶段") + private String stage; + + @Schema(description = "上次修改前的线索阶段") + private String lastStage; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "联系人名称") + private String contact; + + @Schema(description = "联系人电话") + private String phone; + + @Schema(description = "意向产品") + private List products; + + @Schema(description = "是否在线索池") + private Boolean inSharedPool; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "归属部门") + private String departmentId; + + @Schema(description = "归属部门名称") + private String departmentName; + + @Schema(description = "最近跟进时间") + private Long latestFollowUpTime; + + @Schema(description = "创建时间") + private Long collectionTime; + + @Schema(description = "剩余归属天数") + private Integer reservedDays; + + @Schema(description = "最新跟进人") + private String follower; + + @Schema(description = "最新跟进人名称") + private String followerName; + + @Schema(description = "最新跟进日期") + private Long followTime; + + @Schema(description = "线索池ID") + private String poolId; + + @Schema(description = "默认回收公海名称") + private String recyclePoolName; + + @Schema(description = "转移类型") + private String transitionType; + + @Schema(description = "失败原因ID") + private String reasonId; + + @Schema(description = "失败原因ID") + private String reasonName; + + @Schema(description = "自定义字段集合") + private List moduleFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/dto/response/ClueOwnerListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/response/ClueOwnerListResponse.java new file mode 100644 index 0000000..d2e235a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/dto/response/ClueOwnerListResponse.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.clue.dto.response; + +import cn.cordys.crm.clue.domain.ClueOwner; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class ClueOwnerListResponse extends ClueOwner { + @Schema(description = "ID") + private String id; + + @Schema(description = "操作人名称") + private String operatorName; + + @Schema(description = "责任人名称") + private String ownerName; + + @Schema(description = "归属部门") + private String departmentId; + + @Schema(description = "归属部门名称") + private String departmentName; + + @Schema(description = "回收原因名称") + private String reasonName; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueCapacityMapper.java b/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueCapacityMapper.java new file mode 100644 index 0000000..3c293d2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueCapacityMapper.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.clue.mapper; + +import cn.cordys.crm.clue.domain.ClueCapacity; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtClueCapacityMapper { + + /** + * 获取容量 + * + * @param scopeIds 范围ID集合 + * @param organizationId 组织ID + * + * @return 容量 + */ + Integer getCapacityByScopeIds(@Param("ids") List scopeIds, @Param("orgId") String organizationId); + + /** + * 更新容量 + * + * @param capacity 容量 + */ + void updateCapacity(@Param("capacity") ClueCapacity capacity); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueCapacityMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueCapacityMapper.xml new file mode 100644 index 0000000..4053ded --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueCapacityMapper.xml @@ -0,0 +1,18 @@ + + + + + + + + update clue_capacity set scope_id = #{capacity.scopeId}, capacity = #{capacity.capacity}, + update_user = #{capacity.updateUser}, update_time = #{capacity.updateTime} + where id = #{capacity.id} + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueMapper.java b/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueMapper.java new file mode 100644 index 0000000..2abc321 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueMapper.java @@ -0,0 +1,130 @@ +package cn.cordys.crm.clue.mapper; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.BatchUpdateDbParam; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.crm.clue.domain.Clue; +import cn.cordys.crm.clue.dto.request.ClueBatchTransferRequest; +import cn.cordys.crm.clue.dto.request.CluePageRequest; +import cn.cordys.crm.clue.dto.response.ClueListResponse; +import cn.cordys.crm.customer.dto.request.ClueChartAnalysisDbRequest; +import cn.cordys.crm.home.dto.request.HomeStatisticSearchWrapperRequest; +import cn.cordys.crm.search.response.advanced.AdvancedCluePoolResponse; +import cn.cordys.crm.search.response.advanced.AdvancedClueResponse; +import cn.cordys.crm.search.response.global.GlobalCluePoolResponse; +import cn.cordys.crm.search.response.global.GlobalClueResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * @author jianxing + * @date 2025-02-08 17:42:41 + */ +public interface ExtClueMapper { + + List list(@Param("request") CluePageRequest request, @Param("orgId") String orgId, + @Param("userId") String userId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission, @Param("source") boolean source); + + List selectOptionByIds(@Param("ids") List ids); + + void batchTransfer(@Param("request") ClueBatchTransferRequest request); + + int countByOwner(@Param("owner") String owner); + + /** + * 移入线索池 + * + * @param clue 线索池 + */ + void moveToPool(@Param("clue") Clue clue); + + /** + * 获取线索重复数据数量 + * + * @param customerNames 客户名称列表 + * + * @return 重复数据数量 + */ + List getRepeatCountMap(@Param("customerNames") List customerNames); + + /** + * 获取相似线索列表 + * + * @param customerName 客户名称 + * + * @return 相似线索列表 + */ + List getSimilarClueList(@Param("customerName") String customerName, @Param("orgId") String orgId); + + /** + * 获取重复线索列表 + * + * @param customerName 客户名称 + * + * @return 重复线索列表 + */ + List getRepeatClueList(@Param("customerName") String customerName, @Param("orgId") String orgId); + + /** + * 查询用户负责的线索条数 + * + * @param ownerId 负责用户ID + * + * @return 数量 + */ + long getOwnerCount(@Param("ownerId") String ownerId); + + List getListByIds(@Param("ids") List ids); + + Long selectClueCount(@Param("request") HomeStatisticSearchWrapperRequest request, @Param("unfollowed") boolean unfollowed); + + List cluePoolList(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + List getClueOptions(@Param("keyword") String keyword, @Param("orgId") String orgId); + + List globalSearchList(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + long globalSearchListCount(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + + List globalPoolSearchList(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + long globalPoolSearchListCount(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + List searchColumnsByIds(@Param("columns") List columns, @Param("ids") List ids); + + + /** + * 根据ID全量更新线索 + * + * @param clue 线索 + */ + void updateIncludeNullById(@Param("clue") Clue clue); + + /** + * 检查字段值是否唯一 + * + * @param field 字段值 + * + * @return bool + */ + boolean checkFieldValueRepeat(@Param("field") BaseModuleFieldValue field); + + void batchUpdate(@Param("request") BatchUpdateDbParam request); + + /** + * 查询客户的转移线索ID集合 + * + * @param customerId 客户ID + * + * @return 线索ID集合 + */ + List getTransitionClueIds(@Param("customerId") String customerId); + + List chart(@Param("request") ClueChartAnalysisDbRequest request, @Param("userId") String userId, @Param("orgId") String orgId, + @Param("dataPermission") DeptDataPermissionDTO dataPermission); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueMapper.xml new file mode 100644 index 0000000..d53e864 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueMapper.xml @@ -0,0 +1,748 @@ + + + + + + update `clue` + set owner = #{request.owner}, collection_time = UNIX_TIMESTAMP() * 1000, stage = 'FOLLOWING' + where owner != #{request.owner} and id in + + #{id} + + + + + + + + + + + + order by + + + c.`name` ${sortType}, c.id ASC + + + c.`contact` ${sortType}, c.id ASC + + + c.`phone` ${sortType}, c.id ASC + + + c.`owner` ${sortType}, c.id ASC + + + c.`collection_time` ${sortType}, c.id ASC + + + c.`follow_time` ${sortType}, c.id ASC + + + c.`follower` ${sortType}, c.id ASC + + + c.`create_time` ${sortType}, c.id ASC + + + c.`update_time` ${sortType}, c.id ASC + + + souc_sort.`id` ${sortType}, c.id ASC + + + c.`create_user` ${sortType}, c.id ASC + + + c.`update_user` ${sortType}, c.id ASC + + + c.`reason_id` ${sortType}, c.id ASC + + + CASE + WHEN module_table.type = 'INPUT_NUMBER' OR module_table.type = 'FORMULA' + THEN sort_table.field_value+0 + END ${sortType}, + sort_table.field_value ${sortType}, + c.id ${sortType} + + + + + order by c.create_time desc, c.id asc + + + + + + + + + + + + + + + + + + + + left join clue_field_blob ${tablePrefix}_${i} + on c.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + left join clue_field ${tablePrefix}_${i} + on c.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + + left join sys_organization_user ${tablePrefix}_${i} + on ${tablePrefix}_${i}.user_id = c.owner + + + + + + + + + + + left join clue_field sort_table + on c.id = sort_table.resource_id and sort_table.field_id = #{sortName} + left join sys_module_field module_table on sort_table.field_id = module_table.id + + + left join sys_organization_user souc_sort on souc_sort.user_id = c.owner + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + update `clue` set pool_id = #{clue.poolId}, owner = #{clue.owner}, collection_time = #{clue.collectionTime}, reason_id = #{clue.reasonId}, + in_shared_pool = #{clue.inSharedPool}, update_user = #{clue.updateUser}, update_time = #{clue.updateTime} + where id = #{clue.id} + + + + + + + + + + + + + + + + + + + + + + + + + + + update clue set pool_id = #{clue.poolId}, in_shared_pool = #{clue.inSharedPool}, owner = #{clue.owner}, + collection_time = #{clue.collectionTime}, update_time = #{clue.updateTime}, stage = #{clue.stage} + where id = #{clue.id} + + + UPDATE clue + + + owner = #{request.fieldValue}, + + + name = #{request.fieldValue}, + + + contact = #{request.fieldValue}, + + + clue.phone = #{request.fieldValue}, + + + products = #{request.fieldValue}, + + update_time = #{request.updateTime}, + update_user = #{request.updateUser} + + WHERE id IN + + #{id} + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueOwnerMapper.java b/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueOwnerMapper.java new file mode 100644 index 0000000..9b686b8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueOwnerMapper.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.clue.mapper; + +import cn.cordys.crm.clue.dto.request.ClueBatchTransferRequest; +import org.apache.ibatis.annotations.Param; + +/** + * @author jianxing + * @date 2025-02-24 11:06:10 + */ +public interface ExtClueOwnerMapper { + + void batchAdd(@Param("request") ClueBatchTransferRequest transferRequest, @Param("userId") String userId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueOwnerMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueOwnerMapper.xml new file mode 100644 index 0000000..d95cad7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtClueOwnerMapper.xml @@ -0,0 +1,15 @@ + + + + + + + insert into clue_owner (id, owner, operator, clue_id, collection_time, end_time) + select UUID_SHORT() as id, clue.owner, '${userId}' as operator, clue.id as clue_id, clue.collection_time, UNIX_TIMESTAMP() * 1000 as end_time + from clue + where clue.owner != #{request.owner} and id in + + #{id} + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtCluePoolMapper.java b/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtCluePoolMapper.java new file mode 100644 index 0000000..2f7b086 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtCluePoolMapper.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.clue.mapper; + +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.crm.clue.domain.CluePool; +import cn.cordys.crm.clue.domain.CluePoolPickRule; +import cn.cordys.crm.clue.domain.CluePoolRecycleRule; +import cn.cordys.crm.clue.dto.CluePoolDTO; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtCluePoolMapper { + + /** + * 查询线索池列表 + * + * @param request 请求参数 + * @param orgId 组织ID + * + * @return 线索池列表 + */ + List list(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + /** + * 更新线索池领取规则 + * + * @param rule 领取规则 + */ + void updatePickRule(@Param("rule") CluePoolPickRule rule); + + /** + * 更新线索池回收规则 + * + * @param rule 回收规则 + */ + void updateRecycleRule(@Param("rule") CluePoolRecycleRule rule); + + /** + * 获取公海池集合 + * + * @param orgId 组织ID + * + * @return 公海池集合 + */ + List getAllPool(@Param("orgId") String orgId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtCluePoolMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtCluePoolMapper.xml new file mode 100644 index 0000000..e508525 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/mapper/ExtCluePoolMapper.xml @@ -0,0 +1,53 @@ + + + + + + + update clue_pool_pick_rule set limit_on_number = #{rule.limitOnNumber}, pick_number = #{rule.pickNumber}, limit_pre_owner = #{rule.limitPreOwner}, + pick_interval_days = #{rule.pickIntervalDays}, update_user = #{rule.updateUser}, update_time = #{rule.updateTime}, + limit_new = #{rule.limitNew}, new_pick_interval = #{rule.newPickInterval} + where pool_id = #{rule.poolId} + + + + update clue_pool_recycle_rule set operator = #{rule.operator}, `condition` = #{rule.condition}, update_user = #{rule.updateUser}, update_time = #{rule.updateTime} + where pool_id = #{rule.poolId} + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueCapacityService.java b/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueCapacityService.java new file mode 100644 index 0000000..45ba72d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueCapacityService.java @@ -0,0 +1,167 @@ +package cn.cordys.crm.clue.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.clue.domain.ClueCapacity; +import cn.cordys.crm.clue.dto.ClueCapacityDTO; +import cn.cordys.crm.clue.mapper.ExtClueCapacityMapper; +import cn.cordys.crm.system.dto.request.CapacityAddRequest; +import cn.cordys.crm.system.dto.request.CapacityUpdateRequest; +import cn.cordys.crm.system.service.UserExtendService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@Transactional(rollbackFor = Exception.class) +public class ClueCapacityService { + + @Resource + private BaseMapper clueCapacityMapper; + @Resource + private UserExtendService userExtendService; + @Resource + private ExtClueCapacityMapper extClueCapacityMapper; + + /** + * 分页获取线索库容设置(按创建时间升序) + */ + public List list(String currentOrgId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ClueCapacity::getOrganizationId, currentOrgId) + .orderByAsc(ClueCapacity::getCreateTime); // 直接数据库升序,避免内存排序 + + List capacities = clueCapacityMapper.selectListByLambda(wrapper); + if (CollectionUtils.isEmpty(capacities)) { + return new ArrayList<>(); + } + + // 转换DTO,注意 scopeId 可能为空的情况 + return capacities.stream().map(capacity -> { + ClueCapacityDTO dto = new ClueCapacityDTO(); + dto.setId(capacity.getId()); + dto.setCapacity(capacity.getCapacity()); + List scopeIds = JSON.parseArray(capacity.getScopeId(), String.class); + dto.setMembers(CollectionUtils.isEmpty(scopeIds) ? new ArrayList<>() : userExtendService.getScope(scopeIds)); + return dto; + }).collect(Collectors.toList()); + } + + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.ADD) + public void add(CapacityAddRequest request, String currentUserId, String currentOrgId) { + // 仅查询当前组织下的库容,避免原 selectAll(null) 导致的全表扫描 + List existingCapacities = getByOrgId(currentOrgId); + List existingScopeIds = flatMapScopeIds(existingCapacities); + + if (userExtendService.hasDuplicateScopeObj(request.getScopeIds(), existingScopeIds, currentOrgId)) { + throw new GenericException(Translator.get("capacity.scope.duplicate")); + } + + ClueCapacity capacity = new ClueCapacity(); + capacity.setId(IDGenerator.nextStr()); + capacity.setOrganizationId(currentOrgId); + capacity.setCapacity(request.getCapacity()); + capacity.setScopeId(JSON.toJSONString(request.getScopeIds())); + long now = System.currentTimeMillis(); + capacity.setCreateTime(now); + capacity.setCreateUser(currentUserId); + capacity.setUpdateTime(now); + capacity.setUpdateUser(currentUserId); + clueCapacityMapper.insert(capacity); + + OperationLogContext.setContext(LogContextInfo.builder() + .modifiedValue(capacity) + .resourceId(capacity.getId()) + .resourceName(Translator.get("module.clue.capacity.setting")) + .build()); + } + + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE) + public void update(CapacityUpdateRequest request, String currentUserId, String currentOrgId) { + ClueCapacity oldCapacity = clueCapacityMapper.selectByPrimaryKey(request.getId()); + if (oldCapacity == null) { + throw new GenericException(Translator.get("capacity.not.exist")); + } + + // 克隆原始值用于日志,避免后续修改导致原始值丢失 + ClueCapacity originalValue = new ClueCapacity(); + BeanUtils.copyProperties(oldCapacity, originalValue); + + // 查询除当前记录外,同组织下的其他库容 + List otherCapacities = getByOrgIdExclude(currentOrgId, request.getId()); + List otherScopeIds = flatMapScopeIds(otherCapacities); + + if (userExtendService.hasDuplicateScopeObj(request.getScopeIds(), otherScopeIds, currentOrgId)) { + throw new GenericException(Translator.get("capacity.scope.duplicate")); + } + + oldCapacity.setScopeId(JSON.toJSONString(request.getScopeIds())); + oldCapacity.setCapacity(request.getCapacity()); + oldCapacity.setUpdateTime(System.currentTimeMillis()); + oldCapacity.setUpdateUser(currentUserId); + extClueCapacityMapper.updateCapacity(oldCapacity); + + // 重新查询作为修改后的值 + ClueCapacity modifiedValue = clueCapacityMapper.selectByPrimaryKey(request.getId()); + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(request.getId()) + .resourceName(Translator.get("module.clue.capacity.setting")) + .originalValue(originalValue) + .modifiedValue(modifiedValue) + .build() + ); + } + + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + clueCapacityMapper.deleteByPrimaryKey(id); + OperationLogContext.setResourceName(Translator.get("module.clue.capacity.setting")); + } + + private List getByOrgId(String orgId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ClueCapacity::getOrganizationId, orgId); + return clueCapacityMapper.selectListByLambda(wrapper); + } + + private List getByOrgIdExclude(String orgId, String excludeId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ClueCapacity::getOrganizationId, orgId) + .nq(ClueCapacity::getId, excludeId); + return clueCapacityMapper.selectListByLambda(wrapper); + } + + /** + * 将所有库容记录中的 scopeId 平铺为字符串列表,且过滤 null 元素 + */ + private List flatMapScopeIds(List capacities) { + if (CollectionUtils.isEmpty(capacities)) { + return new ArrayList<>(); + } + return capacities.stream() + .flatMap(c -> { + List ids = JSON.parseArray(c.getScopeId(), String.class); + return ids != null ? ids.stream() : Stream.empty(); + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueExportService.java b/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueExportService.java new file mode 100644 index 0000000..371b255 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueExportService.java @@ -0,0 +1,224 @@ +package cn.cordys.crm.clue.service; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ExportHeadDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.service.BaseExportService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.SubListUtils; +import cn.cordys.crm.clue.dto.request.ClueExportRequest; +import cn.cordys.crm.clue.dto.response.ClueListResponse; +import cn.cordys.crm.clue.mapper.ExtClueMapper; +import cn.cordys.crm.clue.utils.PoolClueFieldUtils; +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.domain.ExportTask; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.service.ExportTaskService; +import cn.cordys.registry.ExportThreadRegistry; +import cn.idev.excel.EasyExcel; +import cn.idev.excel.ExcelWriter; +import cn.idev.excel.support.ExcelTypeEnum; +import cn.idev.excel.write.metadata.WriteSheet; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.File; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +/** + * @author song-cc-rock + */ +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class ClueExportService extends BaseExportService { + + @Resource + private ClueService clueService; + @Resource + private ExtClueMapper extClueMapper; + @Resource + private ExportTaskService exportTaskService; + + public String exportAll(ClueExportRequest request, String userId, String orgId, DeptDataPermissionDTO dataPermission, Locale locale) { + checkFileName(request.getFileName()); + //用户导出数量 限制 + exportTaskService.checkUserTaskLimit(userId, ExportConstants.ExportType.CLUE.name()); + + String fileId = IDGenerator.nextStr(); + ExportTask exportTask = exportTaskService.saveTask(orgId, fileId, userId, ExportConstants.ExportType.CLUE.toString(), request.getFileName()); + + runExport(orgId, userId, LogModule.CLUE_INDEX, locale, exportTask, request.getFileName(), + () -> exportData(fileId, exportTask, userId, request, orgId, dataPermission)); + + return exportTask.getId(); + } + + private void exportData(String fileId, ExportTask exportTask, + String userId, ClueExportRequest request, + String orgId, DeptDataPermissionDTO dataPermission) throws InterruptedException { + //表头信息 + List> headList = request.getHeadList().stream() + .map(head -> Collections.singletonList(head.getTitle())) + .toList(); + + //分批查询数据并写入文件 + batchHandleData(fileId, + headList, + exportTask, + request.getFileName(), + request, + t -> getExportData(request, userId, orgId, dataPermission, exportTask.getId())); + } + + public String exportSelect(ExportSelectRequest request, String userId, String orgId, Locale locale) { + checkFileName(request.getFileName()); + // 用户导出数量限制 + exportTaskService.checkUserTaskLimit(userId, ExportConstants.ExportType.CLUE.name()); + + String fileId = IDGenerator.nextStr(); + ExportTask exportTask = exportTaskService.saveTask(orgId, fileId, userId, ExportConstants.ExportType.CLUE.toString(), request.getFileName()); + + runExport(orgId, userId, LogModule.CLUE_INDEX, locale, exportTask, request.getFileName(), + () -> exportData(exportTask, userId, request, orgId, fileId)); + + return exportTask.getId(); + } + + private void exportData(ExportTask exportTask, String userId, ExportSelectRequest request, String orgId, String fileId) { + //表头信息 + List> headList = request.getHeadList().stream() + .map(head -> Collections.singletonList(head.getTitle())) + .toList(); + + // 准备导出文件 + File file = prepareExportFile(fileId, request.getFileName(), exportTask.getOrganizationId()); + try (ExcelWriter writer = EasyExcel.write(file) + .head(headList) + .excelType(ExcelTypeEnum.XLSX) + .build()) { + WriteSheet sheet = EasyExcel.writerSheet("导出数据").build(); + + SubListUtils.dealForSubList(request.getIds(), SubListUtils.DEFAULT_EXPORT_BATCH_SIZE, (subIds) -> { + List> data = new ArrayList<>(); + try { + data = getExportDataBySelect(request.getHeadList(), subIds, orgId, exportTask.getId()); + } catch (InterruptedException e) { + log.error("任务停止中断", e); + exportTaskService.update(exportTask.getId(), ExportConstants.ExportStatus.STOP.toString(), userId); + } + writer.write(data, sheet); + }); + } + } + + /** + * 导出选中线索数据 + * + * @param headList + * @param ids + * @param orgId + * @param taskId + * + * @return + * + * @throws InterruptedException + */ + public List> getExportDataBySelect( + List headList, + List ids, + String orgId, + String taskId) throws InterruptedException { + + // 获取原始数据 + List rawList = extClueMapper.getListByIds(ids); + + return buildExportResult( + headList, + rawList, + orgId, + taskId); + } + + /** + * 导出全部线索数据 + * + * @param request + * @param userId + * @param orgId + * @param deptDataPermission + * @param taskId + * + * @return + * + * @throws InterruptedException + */ + public List> getExportData( + ClueExportRequest request, + String userId, + String orgId, + DeptDataPermissionDTO deptDataPermission, + String taskId) throws InterruptedException { + + // 分页 + PageHelper.startPage(request.getCurrent(), request.getPageSize()); + + // 获取原始数据 + List rawList = extClueMapper.list(request, orgId, userId, deptDataPermission, false); + + return buildExportResult( + request.getHeadList(), + rawList, + orgId, + taskId + ); + } + + private List> buildExportResult( + List headList, + List rawList, + String orgId, + String taskId) throws InterruptedException { + + // 构建业务数据 + List dataList = clueService.buildListData(rawList, orgId); + + // 构建选项映射 + Map> optionMap = clueService.buildOptionMap(orgId, rawList, dataList); + + // 字段配置 + Map fieldConfigMap = getFieldConfigMap(FormKey.CLUE.getKey(), orgId); + + // 构建导出结果(预分配容量) + List> result = new ArrayList<>(dataList.size()); + for (ClueListResponse response : dataList) { + if (ExportThreadRegistry.isInterrupted(taskId)) { + throw new InterruptedException("线程已被中断,主动退出"); + } + result.add(buildData(headList, response, optionMap, fieldConfigMap)); + } + + return result; + } + + private List buildData(List headList, ClueListResponse data, Map> optionMap, Map fieldConfigMap) { + List dataList = new ArrayList<>(); + //固定字段map + LinkedHashMap systemFieldMap = PoolClueFieldUtils.getSystemFieldMap(data, optionMap); + //自定义字段map + AtomicReference> moduleFieldMap = new AtomicReference<>(new LinkedHashMap<>()); + Optional.ofNullable(data.getModuleFields()).ifPresent(moduleFields -> moduleFieldMap.set(moduleFields.stream().collect(Collectors.toMap(BaseModuleFieldValue::getFieldId, BaseModuleFieldValue::getFieldValue)))); + //处理数据转换 + transModuleFieldValue(headList, systemFieldMap, moduleFieldMap.get(), dataList, fieldConfigMap); + return dataList; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueFieldService.java b/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueFieldService.java new file mode 100644 index 0000000..bb61845 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueFieldService.java @@ -0,0 +1,52 @@ +package cn.cordys.crm.clue.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.clue.domain.ClueField; +import cn.cordys.crm.clue.domain.ClueFieldBlob; +import cn.cordys.crm.clue.mapper.ExtClueMapper; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class ClueFieldService extends BaseResourceFieldService { + @Resource + private BaseMapper clueFieldMapper; + @Resource + private BaseMapper clueFieldBlobMapper; + @Resource + private ExtClueMapper extClueMapper; + + @Override + protected String getFormKey() { + return FormKey.CLUE.getKey(); + } + + @Override + protected BaseMapper getResourceFieldMapper() { + return clueFieldMapper; + } + + @Override + protected BaseMapper getResourceFieldBlobMapper() { + return clueFieldBlobMapper; + } + + @Override + protected void checkUnique(BaseModuleFieldValue fieldValue, BaseField field) { + if (extClueMapper.checkFieldValueRepeat(fieldValue)) { + throw new GenericException(Translator.getWithArgs("common.field_value.repeat", field.getName())); + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueLogService.java b/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueLogService.java new file mode 100644 index 0000000..0837fbb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueLogService.java @@ -0,0 +1,43 @@ +package cn.cordys.crm.clue.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.clue.constants.ClueStatus; +import cn.cordys.crm.system.service.BaseModuleLogService; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(rollbackFor = Exception.class) +public class ClueLogService extends BaseModuleLogService { + + + @Override + public List handleLogField(List differences, String orgId) { + differences = super.handleModuleLogField(differences, orgId, FormKey.CLUE.getKey()); + + for (JsonDifferenceDTO difference : differences) { + if (Strings.CS.equals(difference.getColumn(), BusinessModuleField.CLUE_OWNER.getBusinessKey())) { + setUserFieldName(difference); + continue; + } + + if (Strings.CS.equals(difference.getColumn(), BusinessModuleField.OPPORTUNITY_PRODUCTS.getBusinessKey())) { + setProductName(difference); + } + + if (Strings.CS.equals(difference.getColumn(), "stage")) { + difference.setColumnName(Translator.get("clue.stage")); + difference.setNewValueName(ClueStatus.getByKey((String) difference.getNewValue())); + difference.setOldValueName(ClueStatus.getByKey((String) difference.getOldValue())); + } + } + + return differences; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueOwnerHistoryService.java b/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueOwnerHistoryService.java new file mode 100644 index 0000000..363092a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueOwnerHistoryService.java @@ -0,0 +1,126 @@ +package cn.cordys.crm.clue.service; + +import cn.cordys.common.dto.UserDeptDTO; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.crm.clue.domain.Clue; +import cn.cordys.crm.clue.domain.ClueOwner; +import cn.cordys.crm.clue.dto.request.ClueBatchTransferRequest; +import cn.cordys.crm.clue.dto.response.ClueOwnerListResponse; +import cn.cordys.crm.clue.mapper.ExtClueOwnerMapper; +import cn.cordys.crm.system.constants.DictModule; +import cn.cordys.crm.system.domain.Dict; +import cn.cordys.crm.system.domain.DictConfig; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class ClueOwnerHistoryService { + @Resource + private BaseMapper clueOwnerMapper; + @Resource + private BaseService baseService; + @Resource + private ExtClueOwnerMapper extClueOwnerMapper; + @Resource + private BaseMapper dictConfigMapper; + @Resource + private BaseMapper dictMapper; + + public List list(String clueId, String orgId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ClueOwner::getClueId, clueId); + wrapper.orderByDesc(ClueOwner::getEndTime); + List owners = clueOwnerMapper.selectListByLambda(wrapper); + return buildListData(orgId, owners); + } + + private List buildListData(String orgId, List owners) { + if (CollectionUtils.isEmpty(owners)) { + return List.of(); + } + Set userIds = new HashSet<>(); + Set ownerIds = new HashSet<>(); + Set reasonIds = new HashSet<>(); + + for (ClueOwner owner : owners) { + userIds.add(owner.getOwner()); + userIds.add(owner.getOperator()); + ownerIds.add(owner.getOwner()); + reasonIds.add(owner.getReasonId()); + } + + LambdaQueryWrapper configLambdaQueryWrapper = new LambdaQueryWrapper<>(); + configLambdaQueryWrapper.eq(DictConfig::getModule, DictModule.CLUE_POOL_RS.name()).eq(DictConfig::getOrganizationId, orgId); + List configs = dictConfigMapper.selectListByLambda(configLambdaQueryWrapper); + boolean showReason = CollectionUtils.isNotEmpty(configs) && configs.getFirst().getEnabled(); + + Map userDeptMap = baseService.getUserDeptMapByUserIds(new ArrayList<>(ownerIds), orgId); + + Map userNameMap = baseService.getUserNameMap(userIds); + + List dictList = dictMapper.selectByIds(new ArrayList<>(reasonIds)); + Map dictNameMap = dictList.stream().collect(Collectors.toMap(Dict::getId, Dict::getName)); + + return owners + .stream() + .map(item -> { + ClueOwnerListResponse clueOwner = + BeanUtils.copyBean(new ClueOwnerListResponse(), item); + UserDeptDTO userDeptDTO = userDeptMap.get(clueOwner.getOwner()); + if (userDeptDTO != null) { + clueOwner.setDepartmentId(userDeptDTO.getDeptId()); + clueOwner.setDepartmentName(userDeptDTO.getDeptName()); + } + if (!showReason) { + clueOwner.setReasonId(null); + clueOwner.setReasonName(null); + } else { + clueOwner.setReasonName(dictNameMap.get(clueOwner.getReasonId())); + } + clueOwner.setOwnerName(userNameMap.get(clueOwner.getOwner())); + clueOwner.setOperatorName(userNameMap.get(clueOwner.getOperator())); + return clueOwner; + }).toList(); + } + + public ClueOwner add(Clue clue, String userId, Boolean addReason) { + ClueOwner clueOwner = new ClueOwner(); + clueOwner.setOwner(clue.getOwner()); + clueOwner.setOperator(userId); + clueOwner.setClueId(clue.getId()); + clueOwner.setCollectionTime(clue.getCollectionTime()); + clueOwner.setEndTime(System.currentTimeMillis()); + clueOwner.setId(IDGenerator.nextStr()); + if (addReason && StringUtils.isNotBlank(clue.getReasonId()) && !Strings.CS.equals(clue.getReasonId(), "system")) { + clueOwner.setReasonId(clue.getReasonId()); + } + clueOwnerMapper.insert(clueOwner); + return clueOwner; + } + + public void batchAdd(ClueBatchTransferRequest transferRequest, String userId) { + extClueOwnerMapper.batchAdd(transferRequest, userId); + } + + public void deleteByClueIds(List clueIds) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(ClueOwner::getClueId, clueIds); + clueOwnerMapper.deleteByLambda(wrapper); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/service/CluePoolExportService.java b/backend/crm/src/main/java/cn/cordys/crm/clue/service/CluePoolExportService.java new file mode 100644 index 0000000..c3d8b06 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/service/CluePoolExportService.java @@ -0,0 +1,130 @@ +package cn.cordys.crm.clue.service; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.common.dto.ChartAnalysisDbRequest; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.common.service.BaseChartService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.SubListUtils; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.clue.dto.request.ClueExportRequest; +import cn.cordys.crm.clue.mapper.ExtClueMapper; +import cn.cordys.crm.customer.dto.request.ClueChartAnalysisDbRequest; +import cn.cordys.crm.customer.dto.request.PoolClueChartAnalysisRequest; +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.domain.ExportTask; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ExportTaskService; +import cn.idev.excel.EasyExcel; +import cn.idev.excel.ExcelWriter; +import cn.idev.excel.support.ExcelTypeEnum; +import cn.idev.excel.write.metadata.WriteSheet; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class CluePoolExportService extends ClueExportService { + + @Resource + private ClueService clueService; + @Resource + private ExtClueMapper extClueMapper; + @Resource + private ExportTaskService exportTaskService; + @Resource + private BaseChartService baseChartService; + + + public String exportCrossPage(ClueExportRequest request, String userId, String orgId, DeptDataPermissionDTO dataPermission, Locale locale) { + checkFileName(request.getFileName()); + exportTaskService.checkUserTaskLimit(userId, ExportConstants.ExportType.CLUE.name()); + + String fileId = IDGenerator.nextStr(); + ExportTask exportTask = exportTaskService.saveTask(orgId, fileId, userId, ExportConstants.ExportType.CLUE_POOL.toString(), request.getFileName()); + + runExport(orgId, userId, LogModule.CLUE_POOL_INDEX, locale, exportTask, request.getFileName(), + () -> exportData(fileId, exportTask, userId, request, orgId, dataPermission)); + + return exportTask.getId(); + } + + private void exportData(String fileId, ExportTask exportTask, String userId, ClueExportRequest request, String orgId, DeptDataPermissionDTO dataPermission) throws InterruptedException { + //表头信息 + List> headList = request.getHeadList().stream() + .map(head -> Collections.singletonList(head.getTitle())) + .toList(); + + batchHandleData(fileId, + headList, + exportTask, + request.getFileName(), + request, + t -> getExportData(request, userId, orgId, dataPermission, exportTask.getId())); + } + + @Override + public String exportSelect(ExportSelectRequest request, String userId, String orgId, Locale locale) { + checkFileName(request.getFileName()); + exportTaskService.checkUserTaskLimit(userId, ExportConstants.ExportType.CLUE_POOL.name()); + + String fileId = IDGenerator.nextStr(); + ExportTask exportTask = exportTaskService.saveTask(orgId, fileId, userId, ExportConstants.ExportType.CLUE_POOL.toString(), request.getFileName()); + + runExport(orgId, userId, LogModule.CLUE_POOL_INDEX, locale, exportTask, request.getFileName(), + () -> exportData(fileId, exportTask, userId, request, orgId)); + + return exportTask.getId(); + } + + private void exportData(String fileId, ExportTask exportTask, + String userId, ExportSelectRequest request, + String orgId) { + //表头信息 + List> headList = request.getHeadList().stream() + .map(head -> Collections.singletonList(head.getTitle())) + .toList(); + + // 准备导出文件 + File file = prepareExportFile(fileId, request.getFileName(), exportTask.getOrganizationId()); + try (ExcelWriter writer = EasyExcel.write(file) + .head(headList) + .excelType(ExcelTypeEnum.XLSX) + .build()) { + WriteSheet sheet = EasyExcel.writerSheet("导出数据").build(); + + SubListUtils.dealForSubList(request.getIds(), SubListUtils.DEFAULT_EXPORT_BATCH_SIZE, (subIds) -> { + List> data = new ArrayList<>(); + try { + data = getExportDataBySelect(request.getHeadList(), subIds, orgId, exportTask.getId()); + } catch (InterruptedException e) { + log.error("任务停止中断", e); + exportTaskService.update(exportTask.getId(), ExportConstants.ExportStatus.STOP.toString(), userId); + } + writer.write(data, sheet); + }); + } + } + + public List chart(PoolClueChartAnalysisRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { + ModuleFormConfigDTO formConfig = clueService.getFormConfig(orgId); + formConfig.getFields().addAll(BaseChartService.getChartBaseFields()); + ChartAnalysisDbRequest chartAnalysisDbRequest = ConditionFilterUtils.parseChartAnalysisRequest(request, formConfig); + ClueChartAnalysisDbRequest clueChartAnalysisDbRequest = BeanUtils.copyBean(new ClueChartAnalysisDbRequest(), chartAnalysisDbRequest); + clueChartAnalysisDbRequest.setPoolId(request.getPoolId()); + List chartResults = extClueMapper.chart(clueChartAnalysisDbRequest, userId, orgId, deptDataPermission); + return baseChartService.translateAxisName(formConfig, chartAnalysisDbRequest, chartResults); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/service/CluePoolService.java b/backend/crm/src/main/java/cn/cordys/crm/clue/service/CluePoolService.java new file mode 100644 index 0000000..3bea44f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/service/CluePoolService.java @@ -0,0 +1,515 @@ +package cn.cordys.crm.clue.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.condition.CombineSearch; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.common.utils.RecycleConditionUtils; +import cn.cordys.crm.clue.domain.*; +import cn.cordys.crm.clue.dto.CluePoolDTO; +import cn.cordys.crm.clue.dto.CluePoolFieldConfigDTO; +import cn.cordys.crm.clue.dto.CluePoolPickRuleDTO; +import cn.cordys.crm.clue.dto.CluePoolRecycleRuleDTO; +import cn.cordys.crm.clue.dto.request.CluePoolAddRequest; +import cn.cordys.crm.clue.dto.request.CluePoolUpdateRequest; +import cn.cordys.crm.clue.mapper.ExtCluePoolMapper; +import cn.cordys.crm.system.constants.RecycleConditionColumnKey; +import cn.cordys.crm.system.constants.RecycleConditionOperator; +import cn.cordys.crm.system.constants.RecycleConditionScopeKey; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.RuleConditionDTO; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.UserExtendService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@Transactional(rollbackFor = Exception.class) +public class CluePoolService { + + @Resource + private BaseMapper clueMapper; + @Resource + private BaseMapper userMapper; + @Resource + private BaseMapper cluePoolMapper; + @Resource + private BaseMapper cluePoolHiddenFieldMapper; + @Resource + private BaseMapper cluePoolPickRuleMapper; + @Resource + private BaseMapper cluePoolRecycleRuleMapper; + @Resource + private ExtCluePoolMapper extCluePoolMapper; + @Resource + private UserExtendService userExtendService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + + /** + * 分页获取线索池 + * + * @param request 分页参数 + * + * @return 线索池列表 + */ + public List page(BasePageRequest request, String organizationId) { + List pools = extCluePoolMapper.list(request, organizationId); + if (CollectionUtils.isEmpty(pools)) { + return new ArrayList<>(); + } + + List userIds = pools.stream().flatMap(pool -> Stream.of(pool.getCreateUser(), pool.getUpdateUser())).toList(); + List createOrUpdateUsers = userMapper.selectByIds(userIds.toArray(new String[0])); + Map userMap = createOrUpdateUsers.stream() + .collect(Collectors.toMap(User::getId, User::getName)); + + List poolIds = pools.stream() + .map(CluePoolDTO::getId) + .toList(); + + LambdaQueryWrapper pickRuleWrapper = new LambdaQueryWrapper<>(); + pickRuleWrapper.in(CluePoolPickRule::getPoolId, poolIds); + + List pickRules = cluePoolPickRuleMapper.selectListByLambda(pickRuleWrapper); + Map pickRuleMap = pickRules.stream() + .collect(Collectors.toMap(CluePoolPickRule::getPoolId, pickRule -> pickRule)); + + LambdaQueryWrapper recycleRuleWrapper = new LambdaQueryWrapper<>(); + recycleRuleWrapper.in(CluePoolRecycleRule::getPoolId, poolIds); + + List recycleRules = cluePoolRecycleRuleMapper.selectListByLambda(recycleRuleWrapper); + Map recycleRuleMap = recycleRules.stream() + .collect(Collectors.toMap(CluePoolRecycleRule::getPoolId, recycleRule -> recycleRule)); + + Map> hiddenFieldMap = getCluePoolHiddenFieldsByPoolIds(poolIds) + .stream() + .collect(Collectors.groupingBy(CluePoolHiddenField::getPoolId)); + + List fields = moduleFormCacheService.getBusinessFormConfig(FormKey.CLUE.getKey(), organizationId).getFields(); + + pools.forEach(pool -> { + pool.setMembers(userExtendService.getScope(JSON.parseArray(pool.getScopeId(), String.class))); + pool.setOwners(userExtendService.getScope(JSON.parseArray(pool.getOwnerId(), String.class))); + pool.setCreateUserName(userMap.get(pool.getCreateUser())); + pool.setUpdateUserName(userMap.get(pool.getUpdateUser())); + + CluePoolPickRuleDTO pickRule = new CluePoolPickRuleDTO(); + BeanUtils.copyBean(pickRule, pickRuleMap.get(pool.getId())); + CluePoolRecycleRuleDTO recycleRule = new CluePoolRecycleRuleDTO(); + CluePoolRecycleRule cluePoolRecycleRule = recycleRuleMap.get(pool.getId()); + BeanUtils.copyBean(recycleRule, cluePoolRecycleRule); + recycleRule.setConditions(JSON.parseArray(cluePoolRecycleRule.getCondition(), RuleConditionDTO.class)); + delOldTime(recycleRule); + pool.setPickRule(pickRule); + pool.setRecycleRule(recycleRule); + + Set hiddenFieldIds; + if (hiddenFieldMap.get(pool.getId()) != null) { + hiddenFieldIds = hiddenFieldMap.get(pool.getId()).stream() + .map(CluePoolHiddenField::getFieldId) + .collect(Collectors.toSet()); + } else { + hiddenFieldIds = Set.of(); + } + + pool.setFieldConfigs(getCluePoolFieldConfigs(fields, hiddenFieldIds)); + }); + + return pools; + } + + private void delOldTime(CluePoolRecycleRuleDTO recycleRule) { + recycleRule.getConditions().forEach(condition -> { + if (Strings.CS.equals(condition.getColumn(), RecycleConditionColumnKey.STORAGE_TIME) + && Strings.CS.equals(condition.getOperator(), RecycleConditionOperator.DYNAMICS.name())) { + String[] split = condition.getValue().split(","); + if (StringUtils.isNotBlank(condition.getValue()) && split.length == 2) { + String dateValue = split[0]; + String dateUnit = split[1]; + dateUnit = switch (dateUnit) { + case "day" -> "BEFORE_DAY"; + case "month" -> "BEFORE_MONTH"; + case "week" -> "BEFORE_WEEK"; + default -> dateUnit; + }; + condition.setValue("CUSTOM," + dateValue + "," + dateUnit); + } + } + }); + } + + public List getCluePoolFieldConfigs(List fields, Set hiddenFieldIds) { + return fields.stream().map(field -> { + CluePoolFieldConfigDTO hiddenFieldDTO = new CluePoolFieldConfigDTO(); + hiddenFieldDTO.setFieldId(field.getId()); + hiddenFieldDTO.setFieldName(field.getName()); + hiddenFieldDTO.setEnable(!hiddenFieldIds.contains(field.getId())); + hiddenFieldDTO.setEditable(!Strings.CS.equals(field.getInternalKey(), BusinessModuleField.CLUE_NAME.getKey())); + return hiddenFieldDTO; + }).toList(); + } + + public List getCluePoolHiddenFieldsByPoolIds(List poolIds) { + LambdaQueryWrapper hiddenFieldWrapper = new LambdaQueryWrapper<>(); + hiddenFieldWrapper.in(CluePoolHiddenField::getPoolId, poolIds); + return cluePoolHiddenFieldMapper.selectListByLambda(hiddenFieldWrapper); + } + + /** + * 添加线索池 + * + * @param request 添加参数 + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.ADD) + public void add(CluePoolAddRequest request, String currentUserId, String currentOrgId) { + CluePool pool = new CluePool(); + BeanUtils.copyBean(pool, request); + pool.setId(IDGenerator.nextStr()); + pool.setOrganizationId(currentOrgId); + pool.setOwnerId(JSON.toJSONString(request.getOwnerIds())); + pool.setScopeId(JSON.toJSONString(request.getScopeIds())); + pool.setCreateTime(System.currentTimeMillis()); + pool.setCreateUser(currentUserId); + pool.setUpdateTime(System.currentTimeMillis()); + pool.setUpdateUser(currentUserId); + + cluePoolMapper.insert(pool); + + CluePoolPickRule pickRule = new CluePoolPickRule(); + BeanUtils.copyBean(pickRule, request.getPickRule()); + pickRule.setId(IDGenerator.nextStr()); + pickRule.setPoolId(pool.getId()); + pickRule.setCreateTime(System.currentTimeMillis()); + pickRule.setCreateUser(currentUserId); + pickRule.setUpdateTime(System.currentTimeMillis()); + pickRule.setUpdateUser(currentUserId); + + cluePoolPickRuleMapper.insert(pickRule); + + CluePoolRecycleRule recycleRule = new CluePoolRecycleRule(); + BeanUtils.copyBean(recycleRule, request.getRecycleRule()); + recycleRule.setId(IDGenerator.nextStr()); + recycleRule.setPoolId(pool.getId()); + recycleRule.setCreateTime(System.currentTimeMillis()); + recycleRule.setCreateUser(currentUserId); + try { + recycleRule.setCondition(JSON.toJSONString(request.getRecycleRule().getConditions())); + } catch (Exception e) { + throw new GenericException(Translator.get("customer_rule_condition_error")); + } + recycleRule.setUpdateTime(System.currentTimeMillis()); + recycleRule.setUpdateUser(currentUserId); + + cluePoolRecycleRuleMapper.insert(recycleRule); + + batchInsertCluePoolHiddenFields(pool.getId(), request.getHiddenFieldIds()); + + // 添加日志上下文 + OperationLogContext.setContext(LogContextInfo.builder() + .modifiedValue(pool) + .resourceId(pool.getId()) + .resourceName(Translator.get("module.clue.pool.setting")) + .build()); + } + + /** + * 修改线索池 + * + * @param request 修改参数 + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE) + public void update(CluePoolUpdateRequest request, String currentUserId, String currentOrgId) { + CluePool originPool = checkPoolExist(request.getId()); + + CluePool pool = new CluePool(); + BeanUtils.copyBean(pool, request); + pool.setOrganizationId(currentOrgId); + pool.setOwnerId(JSON.toJSONString(request.getOwnerIds())); + pool.setScopeId(JSON.toJSONString(request.getScopeIds())); + pool.setUpdateTime(System.currentTimeMillis()); + pool.setUpdateUser(currentUserId); + + cluePoolMapper.update(pool); + + CluePoolPickRule pickRule = new CluePoolPickRule(); + BeanUtils.copyBean(pickRule, request.getPickRule()); + pickRule.setPoolId(pool.getId()); + pickRule.setUpdateTime(System.currentTimeMillis()); + pickRule.setUpdateUser(currentUserId); + + extCluePoolMapper.updatePickRule(pickRule); + + CluePoolRecycleRule recycleRule = new CluePoolRecycleRule(); + BeanUtils.copyBean(recycleRule, request.getRecycleRule()); + recycleRule.setPoolId(pool.getId()); + try { + recycleRule.setCondition(JSON.toJSONString(request.getRecycleRule().getConditions())); + } catch (Exception e) { + throw new GenericException(Translator.get("customer_rule_condition_error")); + } + recycleRule.setUpdateTime(System.currentTimeMillis()); + recycleRule.setUpdateUser(currentUserId); + + extCluePoolMapper.updateRecycleRule(recycleRule); + + if (request.getHiddenFieldIds() != null) { + deleteCluePoolHiddenFieldByPoolId(pool.getId()); + batchInsertCluePoolHiddenFields(pool.getId(), request.getHiddenFieldIds()); + } + + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(pool.getId()) + .resourceName(Translator.get("module.clue.pool.setting")) + .originalValue(originPool) + .modifiedValue(cluePoolMapper.selectByPrimaryKey(request.getId())) + .build() + ); + } + + + private void batchInsertCluePoolHiddenFields(String poolId, Set fieldIds) { + if (CollectionUtils.isEmpty(fieldIds)) { + return; + } + List cluePoolHiddenFields = fieldIds.stream() + .map(fieldId -> { + CluePoolHiddenField cluePoolHiddenField = new CluePoolHiddenField(); + cluePoolHiddenField.setFieldId(fieldId); + cluePoolHiddenField.setPoolId(poolId); + return cluePoolHiddenField; + }).toList(); + cluePoolHiddenFieldMapper.batchInsert(cluePoolHiddenFields); + } + + private void deleteCluePoolHiddenFieldByPoolId(String poolId) { + CluePoolHiddenField cluePoolHiddenField = new CluePoolHiddenField(); + cluePoolHiddenField.setPoolId(poolId); + cluePoolHiddenFieldMapper.delete(cluePoolHiddenField); + } + + /** + * 线索池是否存在未领取线索 + * + * @param id 线索池ID + */ + public boolean checkNoPick(String id) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Clue::getPoolId, id) + .eq(Clue::getInSharedPool, true); + List relations = clueMapper.selectListByLambda(wrapper); + return CollectionUtils.isNotEmpty(relations); + } + + /** + * 删除线索池 + * + * @param id 线索池ID + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + checkPoolExist(id); + cluePoolMapper.deleteByPrimaryKey(id); + CluePoolPickRule pickRule = new CluePoolPickRule(); + pickRule.setPoolId(id); + cluePoolPickRuleMapper.delete(pickRule); + CluePoolRecycleRule recycleRule = new CluePoolRecycleRule(); + recycleRule.setPoolId(id); + cluePoolRecycleRuleMapper.delete(recycleRule); + deleteCluePoolHiddenFieldByPoolId(id); + + // 设置操作对象 + OperationLogContext.setResourceName(Translator.get("module.clue.pool.setting")); + } + + /** + * 启用/禁用线索池 + * + * @param id 线索池ID + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE, resourceId = "{#id}") + public void switchStatus(String id, String currentUserId) { + CluePool pool = checkPoolExist(id); + + pool.setEnable(!pool.getEnable()); + pool.setUpdateTime(System.currentTimeMillis()); + pool.setUpdateUser(currentUserId); + + cluePoolMapper.updateById(pool); + + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceName(Translator.get("module.clue.pool.setting")) + .originalValue(pool) + .modifiedValue(cluePoolMapper.selectByPrimaryKey(id)) + .build() + ); + } + + /** + * 获取负责人默认线索池ID + * + * @param ownerIds 负责人ID集合 + * @param organizationId 组织ID + * + * @return 默认线索池 + */ + public Map getOwnersDefaultPoolMap(List ownerIds, String organizationId) { + Map poolMap = new HashMap<>(4); + List pools = extCluePoolMapper.getAllPool(organizationId); + Map> ownerScopeMap = userExtendService.getMultiScopeMap(ownerIds, organizationId); + ownerIds.forEach(ownerId -> { + List matchPools = matchMultiScope(ownerScopeMap.get(ownerId), pools); + if (CollectionUtils.isEmpty(matchPools)) { + // not found pool for owner + return; + } + poolMap.put(ownerId, matchPools.getFirst()); + }); + + return poolMap; + } + + /** + * 匹配多个范围的线索池 + * + * @param scopeIds 范围ID集合 + * @param pools 线索池列表 + * + * @return 命中范围的线索池列表 + */ + public List matchMultiScope(List scopeIds, List pools) { + /* + * 命中线索池任意范围即返回(默认按照创建时间作为优先级) + */ + if (CollectionUtils.isEmpty(scopeIds) || CollectionUtils.isEmpty(pools)) { + return new ArrayList<>(); + } + return pools.stream() + .filter(pool -> { + List poolScopes = JSON.parseArray(pool.getScopeId(), String.class); + return CollectionUtils.isNotEmpty(poolScopes) && CollectionUtils.containsAny(scopeIds, poolScopes); + }) + .sorted(Comparator.comparing(CluePool::getCreateTime).reversed()) + .collect(Collectors.toList()); + } + + /** + * 计算剩余归属天数 + * + * @param pool 线索池 + * @param collectionTime 领取时间 + * @param createTime 创建时间 + * + * @return 剩余归属天数 + */ + public Integer calcReservedDay(CluePool pool, CluePoolRecycleRule recycleRule, Long collectionTime, Long createTime) { + if (pool == null || !pool.getAuto() || recycleRule == null) { + return null; + } + + // 判断线索池是否存在入库条件 + List conditions = JSON.parseArray(recycleRule.getCondition(), RuleConditionDTO.class); + return RecycleConditionUtils.calcRecycleDays(conditions, Math.min(collectionTime, createTime)); + } + + /** + * 校验线索池是否存在 + * + * @param id 线索池ID + * + * @return 线索池 + */ + private CluePool checkPoolExist(String id) { + CluePool pool = cluePoolMapper.selectByPrimaryKey(id); + if (pool == null) { + throw new GenericException(Translator.get("clue_pool_not_exist")); + } + return pool; + } + + /** + * 获取负责人最佳匹配公海 + * + * @param pools 公海列表 + * + * @return 公海集合 + */ + public Map, CluePool> getOwnersBestMatchPoolMap(List pools) { + Map, CluePool> poolMap = new HashMap<>(4); + pools.sort(Comparator.comparing(CluePool::getCreateTime).reversed()); + for (CluePool pool : pools) { + List exitOwnerIds = poolMap.keySet().stream().flatMap(List::stream).toList(); + List scopeIds = JSON.parseArray(pool.getScopeId(), String.class); + List ownerIds = userExtendService.getScopeOwnerIds(scopeIds, pool.getOrganizationId()); + List defaultOwnerIds = ownerIds.stream().distinct().filter(ownerId -> !exitOwnerIds.contains(ownerId)).toList(); + if (CollectionUtils.isEmpty(defaultOwnerIds)) { + continue; + } + poolMap.put(defaultOwnerIds, pool); + } + return poolMap; + } + + /** + * 校验线索是否符合回收规则 + * + * @param clue 线索 + * @param recycleRule 回收规则 + * + * @return 是否符合回收规则 + */ + public boolean checkRecycled(Clue clue, CluePoolRecycleRule recycleRule) { + boolean allMatch = Strings.CS.equals(CombineSearch.SearchMode.AND.name(), recycleRule.getOperator()); + List conditions = JSON.parseArray(recycleRule.getCondition(), RuleConditionDTO.class); + if (allMatch) { + return conditions.stream().allMatch(condition -> matchTime(condition, clue)); + } else { + return conditions.stream().anyMatch(condition -> matchTime(condition, clue)); + } + } + + /** + * 是否匹配时间规则 + * + * @param condition 规则 + * @param clue 线索 + * + * @return 是否匹配 + */ + private boolean matchTime(RuleConditionDTO condition, Clue clue) { + if (Strings.CS.equals(condition.getColumn(), RecycleConditionColumnKey.STORAGE_TIME)) { + if (condition.getScope().contains(RecycleConditionScopeKey.CREATED)) { + return RecycleConditionUtils.matchTime(condition, clue.getCreateTime()); + } else if (condition.getScope().contains(RecycleConditionScopeKey.PICKED)) { + return RecycleConditionUtils.matchTime(condition, clue.getCollectionTime()); + } else { + return RecycleConditionUtils.matchTime(condition, clue.getCreateTime()) || RecycleConditionUtils.matchTime(condition, clue.getCollectionTime()); + } + } else { + return RecycleConditionUtils.matchTime(condition, clue.getFollowTime()); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueResourceAccessContextProvider.java b/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueResourceAccessContextProvider.java new file mode 100644 index 0000000..7fb1830 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueResourceAccessContextProvider.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.clue.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.permission.ResourceAccessContext; +import cn.cordys.common.permission.ResourceAccessContextProvider; +import cn.cordys.crm.clue.domain.Clue; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class ClueResourceAccessContextProvider implements ResourceAccessContextProvider { + + @Resource + private BaseMapper clueMapper; + + @Override + public String getFormType() { + return FormKey.CLUE.getKey(); + } + + @Override + public ResourceAccessContext getAccessContext(String resourceId, String orgId) { + Clue clue = clueMapper.selectByPrimaryKey(resourceId); + if (clue == null) { + return null; + } + ResourceAccessContext context = new ResourceAccessContext(); + context.setOwnerId(clue.getOwner()); + return context; + } + + @Override + public Map batchGetOwnerIds(List resourceIds, String orgId) { + if (resourceIds == null || resourceIds.isEmpty()) { + return Map.of(); + } + return clueMapper.selectByIds(resourceIds).stream() + .filter(clue -> clue.getOwner() != null) + .collect(Collectors.toMap(Clue::getId, Clue::getOwner, (a, b) -> a)); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueService.java b/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueService.java new file mode 100644 index 0000000..48f8d8a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueService.java @@ -0,0 +1,1307 @@ +package cn.cordys.crm.clue.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.LinkScenarioKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.domain.BaseResourceSubField; +import cn.cordys.common.dto.*; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.permission.PermissionUtils; +import cn.cordys.common.service.BaseChartService; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.clue.constants.ClueStatus; +import cn.cordys.crm.clue.domain.*; +import cn.cordys.crm.clue.dto.ClueFollowDTO; +import cn.cordys.crm.clue.dto.TransformCsAssociateDTO; +import cn.cordys.crm.clue.dto.request.*; +import cn.cordys.crm.clue.dto.response.ClueGetResponse; +import cn.cordys.crm.clue.dto.response.ClueListResponse; +import cn.cordys.crm.clue.mapper.ExtClueMapper; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.domain.CustomerContact; +import cn.cordys.crm.customer.dto.request.*; +import cn.cordys.crm.customer.service.CustomerCollaborationService; +import cn.cordys.crm.customer.service.CustomerContactService; +import cn.cordys.crm.customer.service.CustomerService; +import cn.cordys.crm.customer.service.PoolCustomerService; +import cn.cordys.crm.follow.constants.FollowUpPlanType; +import cn.cordys.crm.follow.domain.*; +import cn.cordys.crm.follow.service.FollowUpPlanService; +import cn.cordys.crm.follow.service.FollowUpRecordService; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.opportunity.dto.request.OpportunityAddRequest; +import cn.cordys.crm.opportunity.service.OpportunityService; +import cn.cordys.crm.product.mapper.ExtProductMapper; +import cn.cordys.crm.product.service.ProductService; +import cn.cordys.crm.system.constants.DictModule; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.constants.SheetKey; +import cn.cordys.crm.system.domain.Dict; +import cn.cordys.crm.system.dto.DictConfigDTO; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.form.FormLinkFill; +import cn.cordys.crm.system.dto.request.BatchPoolReasonRequest; +import cn.cordys.crm.system.dto.request.PoolReasonRequest; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.BatchAffectResponse; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.excel.CustomImportAfterDoConsumer; +import cn.cordys.crm.system.excel.handler.CustomHeadColWidthStyleStrategy; +import cn.cordys.crm.system.excel.handler.CustomTemplateWriteHandler; +import cn.cordys.crm.system.excel.listener.CustomFieldCheckEventListener; +import cn.cordys.crm.system.excel.listener.CustomFieldImportEventListener; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.crm.system.notice.CommonNoticeSendService; +import cn.cordys.crm.system.service.DictService; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.excel.utils.EasyExcelExporter; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import cn.idev.excel.FastExcelFactory; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class ClueService { + + @Resource + private BaseMapper clueMapper; + @Resource + private BaseMapper customerMapper; + @Resource + private ExtClueMapper extClueMapper; + @Resource + private BaseService baseService; + @Resource + private DictService dictService; + @Resource + private CustomerService customerService; + @Resource + private OpportunityService opportunityService; + @Resource + private ClueFieldService clueFieldService; + @Resource + private BaseChartService baseChartService; + @Resource + private CluePoolService cluePoolService; + @Resource + private BaseMapper recycleRuleMapper; + @Resource + private ClueOwnerHistoryService clueOwnerHistoryService; + @Resource + private FollowUpRecordService followUpRecordService; + @Resource + private FollowUpPlanService followUpPlanService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private CommonNoticeSendService commonNoticeSendService; + @Resource + private DataScopeService dataScopeService; + @Resource + private PoolClueService poolClueService; + @Resource + private LogService logService; + @Resource + private BaseMapper customerContactMapper; + @Resource + private PermissionCache permissionCache; + @Resource + private ExtProductMapper extProductMapper; + @Resource + private ProductService productService; + @Resource + private PoolCustomerService poolCustomerService; + @Resource + private CustomerCollaborationService customerCollaborationService; + @Resource + private CustomerContactService customerContactService; + @Resource + private ExtUserMapper extUserMapper; + @Resource + private BaseMapper clueFieldMapper; + @Resource + private BaseMapper clueFieldBlobMapper; + @Resource + private BaseMapper followUpRecordMapper; + @Resource + private BaseMapper followUpRecordFieldMapper; + @Resource + private BaseMapper followUpRecordFieldBlobMapper; + @Resource + private BaseMapper followUpPlanMapper; + @Resource + private BaseMapper followUpPlanFieldMapper; + @Resource + private BaseMapper followUpPlanFieldBlobMapper; + + public PagerWithOption> list(CluePageRequest request, String userId, String orgId, + DeptDataPermissionDTO deptDataPermission, Boolean source) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extClueMapper.list(request, orgId, userId, deptDataPermission, source); + List buildList = buildListData(list, orgId); + + Map> optionMap = buildOptionMap(orgId, list, buildList); + + return PageUtils.setPageInfoWithOption(page, buildList, optionMap); + } + + public Map> buildOptionMap(String orgId, List list, List buildList) { + // 处理自定义字段选项数据 + ModuleFormConfigDTO customerFormConfig = getFormConfig(orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, ClueListResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, moduleFieldValues); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(buildList, + ClueListResponse::getOwner, ClueListResponse::getOwnerName); + optionMap.put(BusinessModuleField.CLUE_OWNER.getBusinessKey(), ownerFieldOption); + + // 意向产品选项 + List productOption = extProductMapper.getOptions(orgId); + optionMap.put(BusinessModuleField.OPPORTUNITY_PRODUCTS.getBusinessKey(), productOption); + return optionMap; + } + + public ModuleFormConfigDTO getFormConfig(String orgId) { + return moduleFormCacheService.getBusinessFormConfig(FormKey.CLUE.getKey(), orgId); + } + + public List buildListData(List list, String orgId) { + if (CollectionUtils.isEmpty(list)) { + return list; + } + List clueIds = list.stream().map(ClueListResponse::getId) + .collect(Collectors.toList()); + + Map> caseCustomFiledMap = clueFieldService.getResourceFieldMap(clueIds, true); + + List ownerIds = list.stream() + .map(ClueListResponse::getOwner) + .distinct() + .toList(); + + List followerIds = list.stream() + .map(ClueListResponse::getFollower) + .distinct() + .toList(); + List createUserIds = list.stream() + .map(ClueListResponse::getCreateUser) + .distinct() + .toList(); + List updateUserIds = list.stream() + .map(ClueListResponse::getUpdateUser) + .distinct() + .toList(); + List userIds = Stream.of(ownerIds, followerIds, createUserIds, updateUserIds) + .flatMap(Collection::stream) + .distinct() + .toList(); + Map userNameMap = baseService.getUserNameMap(userIds); + + // 获取负责人线索池信息 + Map ownersDefaultPoolMap = cluePoolService.getOwnersDefaultPoolMap(ownerIds, orgId); + List poolIds = ownersDefaultPoolMap.values().stream().map(CluePool::getId).distinct().toList(); + Map recycleRuleMap; + if (CollectionUtils.isEmpty(poolIds)) { + recycleRuleMap = Map.of(); + } else { + LambdaQueryWrapper recycleRuleWrapper = new LambdaQueryWrapper<>(); + recycleRuleWrapper.in(CluePoolRecycleRule::getPoolId, poolIds); + List recycleRules = recycleRuleMapper.selectListByLambda(recycleRuleWrapper); + recycleRuleMap = recycleRules.stream().collect(Collectors.toMap(CluePoolRecycleRule::getPoolId, rule -> rule)); + } + + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + + // 线索池原因 + DictConfigDTO dictConf = dictService.getDictConf(DictModule.CLUE_POOL_RS.name(), orgId); + List dictList = dictConf.getDictList(); + Map dictMap = dictList.stream().collect(Collectors.toMap(Dict::getId, Dict::getName)); + + list.forEach(clueListResponse -> { + // 获取自定义字段 + List clueFields = caseCustomFiledMap.get(clueListResponse.getId()); + clueListResponse.setModuleFields(clueFields); + + // 设置回收公海 + CluePool reservePool = ownersDefaultPoolMap.get(clueListResponse.getOwner()); + clueListResponse.setRecyclePoolName(reservePool != null ? reservePool.getName() : null); + // 计算剩余归属天数 + clueListResponse.setReservedDays(cluePoolService.calcReservedDay(reservePool, + reservePool != null ? recycleRuleMap.get(reservePool.getId()) : null, + clueListResponse.getCollectionTime(), clueListResponse.getCreateTime())); + + UserDeptDTO userDeptDTO = userDeptMap.get(clueListResponse.getOwner()); + if (userDeptDTO != null) { + clueListResponse.setDepartmentId(userDeptDTO.getDeptId()); + clueListResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + clueListResponse.setFollowerName(userNameMap.get(clueListResponse.getFollower())); + clueListResponse.setCreateUserName(userNameMap.get(clueListResponse.getCreateUser())); + clueListResponse.setUpdateUserName(userNameMap.get(clueListResponse.getUpdateUser())); + clueListResponse.setOwnerName(userNameMap.get(clueListResponse.getOwner())); + if (StringUtils.isNotBlank(clueListResponse.getReasonId())) { + clueListResponse.setReasonName(dictMap.get(clueListResponse.getReasonId())); + } + }); + + return list; + } + + /** + * + * @param id 线索ID + * + * @return 线索详情 + */ + public ClueGetResponse get(String id) { + Clue clue = clueMapper.selectByPrimaryKey(id); + if (clue == null) { + return null; + } + ClueGetResponse clueGetResponse = BeanUtils.copyBean(new ClueGetResponse(), clue); + clueGetResponse = baseService.setCreateUpdateOwnerUserName(clueGetResponse); + + // 获取模块字段 + List clueFields = clueFieldService.getModuleFieldValuesByResourceId(id); + // 处理自定义字段选项数据 + ModuleFormConfigDTO customerFormConfig = getFormConfig(clue.getOrganizationId()); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, clueFields); + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(clueGetResponse, + ClueGetResponse::getOwner, ClueGetResponse::getOwnerName); + optionMap.put(BusinessModuleField.CLUE_OWNER.getBusinessKey(), ownerFieldOption); + + // 意向产品选项 + List productOption = extProductMapper.getOptions(clue.getOrganizationId()); + optionMap.put(BusinessModuleField.OPPORTUNITY_PRODUCTS.getBusinessKey(), productOption); + + + clueGetResponse.setOptionMap(optionMap); + clueGetResponse.setModuleFields(clueFields); + + + // 线索池原因 + DictConfigDTO dictConf = dictService.getDictConf(DictModule.CLUE_POOL_RS.name(), clue.getOrganizationId()); + List dictList = dictConf.getDictList(); + Map dictMap = dictList.stream().collect(Collectors.toMap(Dict::getId, Dict::getName)); + + if (clueGetResponse.getOwner() != null) { + // 获取负责人线索池信息 + Map ownersDefaultPoolMap = cluePoolService.getOwnersDefaultPoolMap(List.of(clueGetResponse.getOwner()), clue.getOrganizationId()); + List poolIds = ownersDefaultPoolMap.values().stream().map(CluePool::getId).distinct().toList(); + Map recycleRuleMap; + if (CollectionUtils.isEmpty(poolIds)) { + recycleRuleMap = Map.of(); + } else { + LambdaQueryWrapper recycleRuleWrapper = new LambdaQueryWrapper<>(); + recycleRuleWrapper.in(CluePoolRecycleRule::getPoolId, poolIds); + List recycleRules = recycleRuleMapper.selectListByLambda(recycleRuleWrapper); + recycleRuleMap = recycleRules.stream().collect(Collectors.toMap(CluePoolRecycleRule::getPoolId, rule -> rule)); + } + // 设置回收公海 + CluePool reservePool = ownersDefaultPoolMap.get(clueGetResponse.getOwner()); + clueGetResponse.setRecyclePoolName(reservePool != null ? reservePool.getName() : null); + // 计算剩余归属天数 + clueGetResponse.setReservedDays(cluePoolService.calcReservedDay(reservePool, + reservePool != null ? recycleRuleMap.get(reservePool.getId()) : null, + clueGetResponse.getCollectionTime(), clueGetResponse.getCreateTime())); + + UserDeptDTO userDeptDTO = baseService.getUserDeptMapByUserId(clueGetResponse.getOwner(), clue.getOrganizationId()); + if (userDeptDTO != null) { + clueGetResponse.setDepartmentId(userDeptDTO.getDeptId()); + clueGetResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + } + + if (clueGetResponse.getFollower() != null) { + Map userNameMap = baseService.getUserNameMap(List.of(clueGetResponse.getFollower())); + clueGetResponse.setFollowerName(userNameMap.get(clueGetResponse.getFollower())); + } + + if (StringUtils.isNotBlank(clueGetResponse.getReasonId())) { + clueGetResponse.setReasonName(dictMap.get(clueGetResponse.getReasonId())); + } + + // 附件信息 + clueGetResponse.setAttachmentMap(moduleFormService.getAttachmentMap(customerFormConfig, clueFields)); + + return clueGetResponse; + } + + /** + * 获取线索详情 (⚠️反射调用; 勿修改入参, 返回, 方法名!) + * @param id 线索ID + * @return 详情 + */ + public ClueGetResponse getSimple(String id) { + Clue clue = clueMapper.selectByPrimaryKey(id); + if (clue == null) { + return null; + } + ClueGetResponse clueGetResponse = BeanUtils.copyBean(new ClueGetResponse(), clue); + // 获取模块字段 + List clueFields = clueFieldService.getModuleFieldValuesByResourceId(id); + clueGetResponse.setModuleFields(clueFields); + return clueGetResponse; + } + + /** + * 批量获取线索详情 (用于数据源批量查询优化) + * @param ids 线索ID集合 + * @return 线索详情列表 + */ + public List batchGetSimpleByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return Collections.emptyList(); + } + // 批量查询资源基本信息 + List clues = clueMapper.selectByIds(ids); + if (CollectionUtils.isEmpty(clues)) { + return Collections.emptyList(); + } + // 批量查询自定义字段值 + Map> fieldValueMap = clueFieldService.getResourceFieldMap(ids, true); + + // 组装结果 + return clues.stream().map(clue -> { + ClueGetResponse response = BeanUtils.copyBean(new ClueGetResponse(), clue); + response.setModuleFields(fieldValueMap.get(clue.getId())); + return response; + }).toList(); + } + + @OperationLog(module = LogModule.CLUE_INDEX, type = LogType.ADD) + public Clue add(ClueAddRequest request, String userId, String orgId) { + productService.checkProductList(request.getProducts()); + Clue clue = BeanUtils.copyBean(new Clue(), request); + if (StringUtils.isBlank(request.getOwner())) { + clue.setOwner(userId); + } + poolClueService.validateCapacity(1, clue.getOwner(), orgId); + clue.setCreateTime(System.currentTimeMillis()); + clue.setUpdateTime(System.currentTimeMillis()); + clue.setCollectionTime(clue.getCreateTime()); + clue.setUpdateUser(userId); + clue.setCreateUser(userId); + clue.setOrganizationId(orgId); + clue.setId(IDGenerator.nextStr()); + clue.setStage(ClueStatus.NEW.name()); + clue.setInSharedPool(false); + + //保存自定义字段 + clueFieldService.saveModuleField(clue, orgId, userId, request.getModuleFields(), false); + + clueMapper.insert(clue); + baseService.handleAddLogWithResourceName(clue, request.getModuleFields()); + + // 消息通知 + commonNoticeSendService.sendNotice(NotificationConstants.Module.CLUE, + NotificationConstants.Event.CLUE_ADD, clue.getName(), userId, + orgId, List.of(clue.getOwner()), true); + return clue; + } + + @OperationLog(module = LogModule.CLUE_INDEX, type = LogType.UPDATE, resourceId = "{#request.id}") + public Clue update(ClueUpdateRequest request, String userId, String orgId) { + productService.checkProductList(request.getProducts()); + Clue originClue = clueMapper.selectByPrimaryKey(request.getId()); + if (!Strings.CS.equals(originClue.getOwner(), request.getOwner())) { + poolClueService.validateCapacity(1, request.getOwner(), orgId); + } + + Clue clue = BeanUtils.copyBean(new Clue(), request); + clue.setUpdateTime(System.currentTimeMillis()); + clue.setUpdateUser(userId); + + if (StringUtils.isNotBlank(request.getOwner())) { + if (!Strings.CS.equals(request.getOwner(), originClue.getOwner())) { + // 如果责任人有修改,则添加责任人历史 + clueOwnerHistoryService.add(originClue, userId, false); + sendTransferNotice(List.of(originClue), request.getOwner(), userId, orgId); + } + } + + // 获取模块字段 + List originCustomerFields = clueFieldService.getModuleFieldValuesByResourceId(request.getId()); + + if (BooleanUtils.isTrue(request.getAgentInvoke())) { + clueFieldService.updateModuleFieldByAgent(clue, originCustomerFields, request.getModuleFields(), orgId, userId); + } else { + // 更新模块字段 + updateModuleField(clue, request.getModuleFields(), orgId, userId); + } + + clueMapper.update(clue); + clue = clueMapper.selectByPrimaryKey(request.getId()); + baseService.handleUpdateLog(originClue, clue, originCustomerFields, request.getModuleFields(), originClue.getId(), originClue.getName()); + return clueMapper.selectByPrimaryKey(clue.getId()); + } + + private void sendTransferNotice(List originClues, String toUser, String userId, String orgId) { + originClues.forEach(clue -> commonNoticeSendService.sendNotice(NotificationConstants.Module.CLUE, + NotificationConstants.Event.TRANSFER_CLUE, clue.getName(), userId, + orgId, List.of(toUser), true)); + } + + @OperationLog(module = LogModule.CLUE_INDEX, type = LogType.UPDATE, resourceId = "{#request.id}") + public void updateStatus(ClueStatusUpdateRequest request, String userId, String orgId) { + Clue originClue = clueMapper.selectByPrimaryKey(request.getId()); + Clue clue = BeanUtils.copyBean(new Clue(), request); + clue.setUpdateTime(System.currentTimeMillis()); + clue.setUpdateUser(userId); + // 记录修改前的状态 + clue.setLastStage(originClue.getStage()); + clueMapper.update(clue); + // 日志 + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceName(originClue.getName()) + .originalValue(originClue) + .modifiedValue(clueMapper.selectByPrimaryKey(request.getId())) + .build() + ); + } + + private void updateModuleField(Clue clue, List moduleFields, String orgId, String userId) { + if (moduleFields == null) { + // 如果为 null,则不更新 + return; + } + // 先删除 + clueFieldService.deleteByResourceId(clue.getId()); + // 再保存 + clueFieldService.saveModuleField(clue, orgId, userId, moduleFields, true); + } + + /** + * 转移客户 + * + * @param request 请求参数 + * @param userId 用户ID + * @param orgId 组织ID + */ + public void transitionCustomer(ClueTransitionCustomerRequest request, String userId, String orgId) { + Customer customer = customerService.add(request, userId, orgId); + Clue clue = clueMapper.selectByPrimaryKey(request.getClueId()); + clue.setTransitionId(customer.getId()); + clue.setTransitionType(FormKey.CUSTOMER.name()); + clue.setUpdateTime(System.currentTimeMillis()); + clue.setUpdateUser(userId); + clueMapper.update(clue); + + // 同步添加联系人 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CustomerContact::getPhone, clue.getPhone()); + List contacts = customerContactMapper.selectListByLambda(wrapper); + if (CollectionUtils.isEmpty(contacts)) { + CustomerContact contact = new CustomerContact(); + contact.setId(IDGenerator.nextStr()); + contact.setCustomerId(customer.getId()); + contact.setOwner(customer.getOwner()); + contact.setName(clue.getContact()); + contact.setPhone(clue.getPhone()); + contact.setEnable(true); + contact.setOrganizationId(orgId); + contact.setCreateUser(userId); + contact.setCreateTime(System.currentTimeMillis()); + contact.setUpdateUser(userId); + contact.setUpdateTime(System.currentTimeMillis()); + customerContactMapper.insert(contact); + } + + commonNoticeSendService.sendNotice(NotificationConstants.Module.CLUE, + NotificationConstants.Event.CLUE_CONVERT_CUSTOMER, clue.getName(), userId, + orgId, List.of(clue.getOwner()), true); + } + + + @OperationLog(module = LogModule.CLUE_INDEX, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id, String userId, String orgId) { + Clue clue = clueMapper.selectByPrimaryKey(id); + // 删除客户 + clueMapper.deleteByPrimaryKey(id); + // 删除客户模块字段 + clueFieldService.deleteByResourceId(id); + // 删除责任人历史 + clueOwnerHistoryService.deleteByClueIds(List.of(id)); + // 删除跟进记录 + followUpRecordService.deleteByClueIds(List.of(id)); + // 删除跟进计划 + followUpPlanService.deleteByClueIds(List.of(id)); + + // 设置操作对象 + OperationLogContext.setResourceName(clue.getName()); + + // 消息通知 + commonNoticeSendService.sendNotice(NotificationConstants.Module.CLUE, + NotificationConstants.Event.CLUE_DELETED, clue.getName(), userId, + orgId, List.of(clue.getOwner()), true); + + } + + public void batchTransfer(ClueBatchTransferRequest request, String userId, String orgId) { + List clues = clueMapper.selectByIds(request.getIds()); + long processCount = clues.stream().filter(clue -> !Strings.CS.equals(clue.getOwner(), request.getOwner())).count(); + poolClueService.validateCapacity((int) processCount, request.getOwner(), orgId); + + // 添加责任人历史 + clueOwnerHistoryService.batchAdd(request, userId); + extClueMapper.batchTransfer(request); + + // 记录日志 + List logs = clues.stream() + .map(clue -> { + Customer originCustomer = new Customer(); + originCustomer.setOwner(clue.getOwner()); + Customer modifieCustomer = new Customer(); + modifieCustomer.setOwner(request.getOwner()); + LogDTO logDTO = new LogDTO(orgId, clue.getId(), userId, LogType.UPDATE, LogModule.CLUE_INDEX, clue.getName()); + logDTO.setOriginalValue(originCustomer); + logDTO.setModifiedValue(modifieCustomer); + return logDTO; + }).toList(); + + logService.batchAdd(logs); + + sendTransferNotice(clues, request.getOwner(), userId, orgId); + } + + public void batchDelete(List ids, String userId, String orgId) { + List clues = clueMapper.selectByIds(ids); + + // 删除客户 + clueMapper.deleteByIds(ids); + // 删除客户模块字段 + clueFieldService.deleteByResourceIds(ids); + // 删除责任人历史 + clueOwnerHistoryService.deleteByClueIds(ids); + // 删除跟进记录 + followUpRecordService.deleteByClueIds(ids); + // 删除跟进计划 + followUpPlanService.deleteByClueIds(ids); + + // 消息通知 + clues.forEach(clue -> commonNoticeSendService.sendNotice(NotificationConstants.Module.CLUE, + NotificationConstants.Event.CLUE_DELETED, clue.getName(), userId, + orgId, List.of(clue.getOwner()), true)); + } + + private List getOwners(List clues) { + return clues.stream() + .map(Clue::getOwner) + .distinct() + .toList(); + } + + /** + * 批量移入线索池 + * + * @param request 请求参数 + * @param orgId 组织ID + * @param currentUser 当前用户 + */ + public BatchAffectResponse batchToPool(BatchPoolReasonRequest request, String currentUser, String orgId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(Clue::getId, request.getIds()); + List clues = clueMapper.selectListByLambda(wrapper); + List ownerIds = getOwners(clues); + + Map ownersDefaultPoolMap = cluePoolService.getOwnersDefaultPoolMap(ownerIds, orgId); + int success = 0; + List logs = new ArrayList<>(); + for (Clue clue : clues) { + CluePool cluePool = ownersDefaultPoolMap.get(clue.getOwner()); + if (cluePool == null) { + // 未找到默认公海,不移入 + continue; + } + // 日志 + LogDTO logDTO = new LogDTO(orgId, clue.getId(), currentUser, LogType.MOVE_TO_CUSTOMER_POOL, LogModule.CLUE_INDEX, clue.getName()); + String detail = Translator.getWithArgs("clue.to.pool", clue.getName(), cluePool.getName()); + logDTO.setDetail(detail); + logs.add(logDTO); + // 消息通知 + commonNoticeSendService.sendNotice(NotificationConstants.Module.CLUE, + NotificationConstants.Event.CLUE_MOVED_POOL, clue.getName(), currentUser, + orgId, List.of(clue.getOwner()), true); + // 插入责任人历史 + clue.setReasonId(request.getReasonId()); + clueOwnerHistoryService.add(clue, currentUser, true); + clue.setPoolId(cluePool.getId()); + clue.setInSharedPool(true); + clue.setOwner(null); + clue.setCollectionTime(null); + clue.setUpdateUser(currentUser); + clue.setUpdateTime(System.currentTimeMillis()); + extClueMapper.moveToPool(clue); + success++; + } + logService.batchAdd(logs); + return BatchAffectResponse.builder().success(success).fail(request.getIds().size() - success).build(); + } + + /** + * 移入线索池 + * + * @param request 请求参数 + * @param currentUser 当前用户 + * @param orgId 组织ID + */ + public BatchAffectResponse toPool(PoolReasonRequest request, String currentUser, String orgId) { + BatchPoolReasonRequest batchRequest = new BatchPoolReasonRequest(); + batchRequest.setReasonId(request.getReasonId()); + batchRequest.setIds(List.of(request.getId())); + return batchToPool(batchRequest, currentUser, orgId); + } + + public ResourceTabEnableDTO getTabEnableConfig(String userId, String organizationId) { + List rolePermissions = permissionCache.getRolePermissions(userId, organizationId); + return PermissionUtils.getTabEnableConfig(userId, PermissionConstants.CLUE_MANAGEMENT_READ, rolePermissions); + } + + + public String getClueName(String id) { + Clue clue = clueMapper.selectByPrimaryKey(id); + return Optional.ofNullable(clue).map(Clue::getName).orElse(null); + } + + public List getClueListByNames(List names) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.in(Clue::getName, names); + return clueMapper.selectListByLambda(lambdaQueryWrapper); + } + + @SuppressWarnings("unchecked") + public String getClueNameByIds(List ids) { + List clueList = clueMapper.selectByIds(ids); + if (CollectionUtils.isNotEmpty(clueList)) { + List names = clueList.stream().map(Clue::getName).toList(); + return String.join(",", JSON.parseArray(JSON.toJSONString(names))); + } + return StringUtils.EMPTY; + } + + /** + * 批量关联线索和客户 + * + * @param request 关联参数 + * @param currentUser 当前用户 + * @param orgId 组织ID + */ + public void batchTransition(BatchReTransitionCustomerRequest request, String currentUser, String orgId) { + if (CollectionUtils.isEmpty(request.getClueIds())) { + throw new GenericException(Translator.get("clue_ids_not_empty")); + } + Customer customer = customerMapper.selectByPrimaryKey(request.getCustomerId()); + // 操作人尝试领取公海客户, 领取失败则不继续 + if (customer.getInSharedPool()) { + PoolCustomerPickRequest pickRequest = new PoolCustomerPickRequest(); + pickRequest.setCustomerId(customer.getId()); + pickRequest.setPoolId(customer.getPoolId()); + poolCustomerService.pick(pickRequest, currentUser, orgId); + } + request.getClueIds().forEach(clueId -> transitionCs(clueId, customer, currentUser, orgId)); + } + + /** + * 线索关联已有客户 + * + * @param clueId 线索ID + * @param currentUser 当前用户ID + * @param orgId 组织ID + */ + public void transitionCs(String clueId, Customer transitionCs, String currentUser, String orgId) { + Clue clue = clueMapper.selectByPrimaryKey(clueId); + // 负责人不存在, 跳过关联 + List owners = extUserMapper.selectUserNameByIds(List.of(clue.getOwner())); + if (CollectionUtils.isEmpty(owners)) { + return; + } + + // 关联 + TransformCsAssociateDTO transformCsAssociateDTO = transformCsAssociate(clue, transitionCs, currentUser, orgId); + clue.setTransitionId(transitionCs.getId()); + clue.setTransitionType("CUSTOMER"); + clueMapper.update(clue); + + // 转移线索的计划&记录 + batchCopyCluePlanAndRecord(clue.getId(), transitionCs.getId(), null, transformCsAssociateDTO.getContactId()); + // 刷新转换过程中同名客户的最新跟进时间 + refreshCsFollowTime(clue, transitionCs); + + // 只通知线索负责人 + Map paramMap = new HashMap<>(8); + paramMap.put("useTemplate", "true"); + paramMap.put("template", Translator.get("message.clue_relate_customer")); + paramMap.put("customerName", transitionCs.getName()); + paramMap.put("name", clue.getName()); + commonNoticeSendService.sendNotice(NotificationConstants.Module.CLUE, NotificationConstants.Event.CLUE_CONVERT_CUSTOMER, + paramMap, currentUser, orgId, List.of(clue.getOwner()), true); + } + + /** + * 线索转换 + * + * @param request 请求参数 + * @param currentUser 当前用户 + * @param orgId 组织ID + */ + public String transform(ClueTransformRequest request, String currentUser, String orgId) { + checkTransformPermission(request.getOppCreated()); + Clue clue = clueMapper.selectByPrimaryKey(request.getClueId()); + if (clue == null) { + throw new GenericException(Translator.get("clue_not_exist")); + } + List owners = extUserMapper.selectUserNameByIds(List.of(clue.getOwner())); + if (CollectionUtils.isEmpty(owners)) { + throw new GenericException(Translator.get("clue_owner_not_exist")); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Customer::getName, clue.getName()); + List customers = customerMapper.selectListByLambda(wrapper); + boolean uniqueCheck = moduleFormService.hasFieldUniqueCheck(FormKey.CUSTOMER.getKey(), orgId, BusinessModuleField.CUSTOMER_NAME.getKey()); + Customer transformCustomer; + if (uniqueCheck && CollectionUtils.isNotEmpty(customers)) { + // 表单存在唯一性校验, 且同名客户存在, 根据规则选取 + transformCustomer = selectorCs(customers, clue.getOwner()); + } else { + // 根据表单联动来创建客户 + transformCustomer = generateCustomerByLinkForm(clue, currentUser, orgId); + } + // 刷新转换过程中同名客户的最新跟进时间 + refreshCsFollowTime(clue, transformCustomer); + + TransformCsAssociateDTO transformCsAssociateDTO = transformCsAssociate(clue, transformCustomer, currentUser, orgId); + clue.setTransitionId(transformCustomer.getId()); + clue.setTransitionType("CUSTOMER"); + clueMapper.update(clue); + + // 客户转换通知 + Map paramMap = new HashMap<>(4); + paramMap.put("useTemplate", "true"); + paramMap.put("template", Translator.get("message.clue_convert_customer_text")); + paramMap.put("name", clue.getName()); + commonNoticeSendService.sendNotice(NotificationConstants.Module.CLUE, NotificationConstants.Event.CLUE_CONVERT_CUSTOMER, + paramMap, currentUser, orgId, List.of(clue.getOwner()), true); + + // 是否转换商机 + if (request.getOppCreated()) { + transformCustomer.setName(request.getOppName()); + Opportunity transformOpportunity = generateOpportunityByLinkForm(clue, transformCsAssociateDTO.getContactId(), transformCustomer, currentUser, orgId); + // 转移线索的计划&记录 + batchCopyCluePlanAndRecord(clue.getId(), transformCustomer.getId(), transformOpportunity.getId(), transformCsAssociateDTO.getContactId()); + paramMap.put("template", Translator.get("message.clue_convert_business_text")); + paramMap.put("name", clue.getName()); + commonNoticeSendService.sendNotice(NotificationConstants.Module.CLUE, NotificationConstants.Event.CLUE_CONVERT_BUSINESS, + paramMap, currentUser, orgId, List.of(clue.getOwner()), true); + return transformOpportunity.getId(); + } else { + // 转移线索的计划&记录 + batchCopyCluePlanAndRecord(clue.getId(), transformCustomer.getId(), null, transformCsAssociateDTO.getContactId()); + return transformCustomer.getId(); + } + } + + /** + * 批量复制线索跟进计划和记录 + * + * @param clueId 线索ID + * @param customerId 客户ID + * @param opportunityId 商机ID + */ + public void batchCopyCluePlanAndRecord(String clueId, String customerId, String opportunityId, String contactId) { + // 记录 + LambdaQueryWrapper recordLambdaQueryWrapper = new LambdaQueryWrapper<>(); + recordLambdaQueryWrapper.eq(FollowUpRecord::getClueId, clueId).eq(FollowUpRecord::getType, FollowUpPlanType.CLUE.name()); + List followUpRecords = followUpRecordMapper.selectListByLambda(recordLambdaQueryWrapper); + if (CollectionUtils.isNotEmpty(followUpRecords)) { + List ids = followUpRecords.stream().map(FollowUpRecord::getId).toList(); + LambdaQueryWrapper fieldLambdaQueryWrapper = new LambdaQueryWrapper<>(); + fieldLambdaQueryWrapper.in(FollowUpRecordField::getResourceId, ids); + List followUpRecordFields = followUpRecordFieldMapper.selectListByLambda(fieldLambdaQueryWrapper); + LambdaQueryWrapper fieldBlobLambdaQueryWrapper = new LambdaQueryWrapper<>(); + fieldBlobLambdaQueryWrapper.in(FollowUpRecordFieldBlob::getResourceId, ids); + List followUpRecordFieldBlobs = followUpRecordFieldBlobMapper.selectListByLambda(fieldBlobLambdaQueryWrapper); + followUpRecords.forEach(record -> { + String recordId = IDGenerator.nextStr(); + followUpRecordFields.stream().filter(recordField -> Strings.CS.equals(recordField.getResourceId(), record.getId())).forEach(field -> { + field.setId(IDGenerator.nextStr()); + field.setResourceId(recordId); + }); + followUpRecordFieldBlobs.stream().filter(recordFieldBlob -> Strings.CS.equals(recordFieldBlob.getResourceId(), record.getId())).forEach(fieldBlob -> { + fieldBlob.setId(IDGenerator.nextStr()); + fieldBlob.setResourceId(recordId); + }); + record.setId(recordId); + record.setClueId(null); + record.setType(FollowUpPlanType.CUSTOMER.name()); + record.setCustomerId(customerId); + record.setOpportunityId(opportunityId); + record.setContactId(contactId); + }); + followUpRecordMapper.batchInsert(followUpRecords); + followUpRecordFieldMapper.batchInsert(followUpRecordFields); + followUpRecordFieldBlobMapper.batchInsert(followUpRecordFieldBlobs); + } + // 计划 + LambdaQueryWrapper planLambdaQueryWrapper = new LambdaQueryWrapper<>(); + planLambdaQueryWrapper.eq(FollowUpPlan::getClueId, clueId).eq(FollowUpPlan::getType, FollowUpPlanType.CLUE.name()); + List followUpPlans = followUpPlanMapper.selectListByLambda(planLambdaQueryWrapper); + if (CollectionUtils.isNotEmpty(followUpPlans)) { + List ids = followUpPlans.stream().map(FollowUpPlan::getId).toList(); + LambdaQueryWrapper fieldLambdaQueryWrapper = new LambdaQueryWrapper<>(); + fieldLambdaQueryWrapper.in(FollowUpPlanField::getResourceId, ids); + List followUpPlanFields = followUpPlanFieldMapper.selectListByLambda(fieldLambdaQueryWrapper); + LambdaQueryWrapper fieldBlobLambdaQueryWrapper = new LambdaQueryWrapper<>(); + fieldBlobLambdaQueryWrapper.in(FollowUpPlanFieldBlob::getResourceId, ids); + List followUpPlanFieldBlobs = followUpPlanFieldBlobMapper.selectListByLambda(fieldBlobLambdaQueryWrapper); + followUpPlans.forEach(plan -> { + String planId = IDGenerator.nextStr(); + followUpPlanFields.stream().filter(planField -> Strings.CS.equals(planField.getResourceId(), plan.getId())).forEach(field -> { + field.setId(IDGenerator.nextStr()); + field.setResourceId(planId); + }); + followUpPlanFieldBlobs.stream().filter(planFieldBlob -> Strings.CS.equals(planFieldBlob.getResourceId(), plan.getId())).forEach(fieldBlob -> { + fieldBlob.setId(IDGenerator.nextStr()); + fieldBlob.setResourceId(planId); + }); + plan.setId(planId); + plan.setClueId(null); + plan.setType(FollowUpPlanType.CUSTOMER.name()); + plan.setCustomerId(customerId); + plan.setOpportunityId(opportunityId); + plan.setContactId(contactId); + }); + followUpPlanMapper.batchInsert(followUpPlans); + followUpPlanFieldMapper.batchInsert(followUpPlanFields); + followUpPlanFieldBlobMapper.batchInsert(followUpPlanFieldBlobs); + } + } + + /** + * 检查转换权限 + * + * @param checkOpportunityPermission 是否检查商机权限 + */ + public void checkTransformPermission(boolean checkOpportunityPermission) { + if (!PermissionUtils.hasPermission(PermissionConstants.CUSTOMER_MANAGEMENT_ADD)) { + throw new GenericException(Translator.get("transform.miss.customer.permission")); + } + if (checkOpportunityPermission && !PermissionUtils.hasPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_ADD)) { + throw new GenericException(Translator.get("transform.miss.opportunity.permission")); + } + } + + /** + * 同名客户选择器 + * + * @param customers 客户列表 + * + * @return 客户 + */ + public Customer selectorCs(List customers, String clueOwner) { + if (customers.size() == 1) { + return customers.getFirst(); + } + // 优先选择不在公海的且负责人一致的客户 + Optional find = customers.stream().filter(customer -> !customer.getInSharedPool() && Strings.CS.equals(customer.getOwner(), clueOwner)) + .findFirst(); + return find.orElseGet(customers::getFirst); + } + + /** + * 通过表单联动来创建客户 + * + * @param clue 线索 + * @param currentUser 当前用户 + * @param orgId 组织ID + * + * @return 客户 + */ + public Customer generateCustomerByLinkForm(Clue clue, String currentUser, String orgId) { + CustomerAddRequest addRequest = new CustomerAddRequest(); + ModuleFormConfigDTO customerFormConfig = moduleFormService.getBusinessFormConfig(FormKey.CUSTOMER.getKey(), orgId); + FormLinkFill customerLinkFillDTO; + try { + customerLinkFillDTO = moduleFormService.fillFormLinkValue(new Customer(), get(clue.getId()), + customerFormConfig, orgId, FormKey.CLUE.getKey(), LinkScenarioKey.CLUE_TO_CUSTOMER.name()); + } catch (Exception e) { + log.error("Attempt to fill customer form error: {}", e.getMessage()); + throw new GenericException(Translator.get("transform.customer.error")); + } + // 部分内置字段未配置联动, 取线索值即可 + addRequest.setName(customerLinkFillDTO.getEntity() == null || StringUtils.isEmpty(customerLinkFillDTO.getEntity().getName()) ? + clue.getName() : customerLinkFillDTO.getEntity().getName()); + addRequest.setOwner(customerLinkFillDTO.getEntity() == null || StringUtils.isEmpty(customerLinkFillDTO.getEntity().getOwner()) ? + clue.getOwner() : customerLinkFillDTO.getEntity().getOwner()); + addRequest.setModuleFields(customerLinkFillDTO.getFields()); + addRequest.setFollower(clue.getFollower()); + addRequest.setFollowTime(clue.getFollowTime()); + return customerService.add(addRequest, currentUser, orgId); + } + + /** + * 通过表单联动来创建商机 + * + * @param clue 线索 + * @param contactId 联系人ID + * @param currentUser 当前用户 + * @param orgId 组织ID + * + * @return 商机 + */ + public Opportunity generateOpportunityByLinkForm(Clue clue, String contactId, Customer transformCustomer, String currentUser, String orgId) { + ModuleFormConfigDTO opportunityFormConfig = moduleFormService.getBusinessFormConfig(FormKey.OPPORTUNITY.getKey(), orgId); + FormLinkFill opportunityLinkFillDTO; + try { + opportunityLinkFillDTO = moduleFormService.fillFormLinkValue(new Opportunity(), get(clue.getId()), + opportunityFormConfig, orgId, FormKey.CLUE.getKey(), LinkScenarioKey.CLUE_TO_OPPORTUNITY.name()); + } catch (Exception e) { + log.error("Attempt to fill opportunity form error: {}", e.getMessage()); + throw new GenericException(Translator.get("transform.opportunity.error")); + } + OpportunityAddRequest addRequest = new OpportunityAddRequest(); + if (opportunityLinkFillDTO.getEntity() != null) { + BeanUtils.copyBean(addRequest, opportunityLinkFillDTO.getEntity()); + } + // 部分内置字段需手动设置值 + addRequest.setName(transformCustomer.getName()); + addRequest.setCustomerId(transformCustomer.getId()); + if (CollectionUtils.isEmpty(addRequest.getProducts())) { + addRequest.setProducts(new ArrayList<>()); + } + if (StringUtils.isEmpty(addRequest.getOwner())) { + addRequest.setOwner(clue.getOwner()); + } + if (StringUtils.isEmpty(addRequest.getContactId())) { + addRequest.setContactId(contactId); + } + addRequest.setModuleFields(opportunityLinkFillDTO.getFields()); + addRequest.setFollower(clue.getFollower()); + addRequest.setFollowTime(clue.getFollowTime()); + return opportunityService.add(addRequest, currentUser, orgId); + } + + /** + * 通过表单联动来构建客户联系人创建对象 + * @param clue 线索 + * @param orgId 组织ID + * @return 客户联系人创建对象 + */ + public CustomerContactAddRequest buildContactRequestByLinkForm(Clue clue, String orgId) { + ModuleFormConfigDTO contactFormConfig = moduleFormService.getBusinessFormConfig(FormKey.CONTACT.getKey(), orgId); + FormLinkFill fillDTO = null; + try { + fillDTO = moduleFormService.fillFormLinkValue(new CustomerContactAddRequest(), get(clue.getId()), + contactFormConfig, orgId, FormKey.CLUE.getKey(), LinkScenarioKey.CLUE_TO_CONTACT.name()); + } catch (Exception e) { + log.error("Attempt to fill contact form error: {}", e.getMessage()); + } + if (fillDTO == null || fillDTO.getEntity() == null) { + return new CustomerContactAddRequest(); + } + CustomerContactAddRequest request = fillDTO.getEntity(); + request.setModuleFields(fillDTO.getFields()); + return request; + } + + /** + * 转换客户处理 + * + * @param clue 线索 + * @param transformCs 转换客户 + * @param currentUser 当前用户 + * @param orgId 组织ID + */ + public TransformCsAssociateDTO transformCsAssociate(Clue clue, Customer transformCs, String currentUser, String orgId) { + TransformCsAssociateDTO transformDTO = new TransformCsAssociateDTO(); + // 如果当前线索负责人不是关联客户的负责人,且不是客户协作人, 则添加协作关系 + if (!Strings.CS.equals(transformCs.getOwner(), clue.getOwner()) && !customerCollaborationService.hasCollaboration(clue.getOwner(), transformCs.getId())) { + CustomerCollaborationAddRequest collaborationAddRequest = new CustomerCollaborationAddRequest(); + collaborationAddRequest.setCustomerId(transformCs.getId()); + collaborationAddRequest.setCollaborationType("COLLABORATION"); + collaborationAddRequest.setUserId(clue.getOwner()); + customerCollaborationService.add(collaborationAddRequest, currentUser, orgId); + } + + // 线索联系人 => 客户联系人 + CustomerContactAddRequest request = buildContactRequestByLinkForm(clue, orgId); + if (StringUtils.isEmpty(request.getName())) { + return transformDTO; + } + boolean unique = customerContactService.checkCustomerContactUnique(request.getName(), request.getPhone(), transformCs.getId(), orgId); + if (unique) { + request.setCustomerId(transformCs.getId()); + if (StringUtils.isEmpty(request.getOwner())) { + request.setOwner(clue.getOwner()); + } + CustomerContact contact = customerContactService.add(request, currentUser, orgId); + transformDTO.setContactId(contact.getId()); + } + + return transformDTO; + } + + /** + * 下载导入的模板 + * + * @param response 响应 + */ + public void downloadImportTpl(HttpServletResponse response, String currentOrg) { + new EasyExcelExporter().exportMultiSheetTplWithSharedHandler(response, + moduleFormService.getCustomImportHeadsNoRef(FormKey.CLUE.getKey(), currentOrg), + Translator.get("clue.import_tpl.name"), Translator.get(SheetKey.DATA), Translator.get(SheetKey.COMMENT), + new CustomTemplateWriteHandler(moduleFormService.getAllCustomImportFields(FormKey.CLUE.getKey(), currentOrg)), + new CustomHeadColWidthStyleStrategy()); + } + + /** + * 导入检查 + * + * @param file 导入文件 + * @param currentOrg 当前组织 + * + * @return 导入检查信息 + */ + public ImportResponse importPreCheck(MultipartFile file, String currentOrg) { + if (file == null) { + throw new GenericException(Translator.get("file_cannot_be_null")); + } + return checkImportExcel(file, currentOrg); + } + + /** + * 线索导入 + * + * @param file 导入文件 + * @param currentOrg 当前组织 + * @param currentUser 当前用户 + * + * @return 导入返回信息 + */ + public ImportResponse realImport(MultipartFile file, String currentOrg, String currentUser) { + try { + List fields = moduleFormService.getAllFields(FormKey.CLUE.getKey(), currentOrg); + CustomImportAfterDoConsumer afterDo = (clues, clueFields, clueFieldBlobs) -> { + List logs = new ArrayList<>(); + clues.forEach(clue -> { + clue.setCollectionTime(clue.getCreateTime()); + clue.setStage(ClueStatus.NEW.name()); + clue.setInSharedPool(false); + logs.add(new LogDTO(currentOrg, clue.getId(), currentUser, LogType.ADD, LogModule.CLUE_INDEX, clue.getName())); + // 消息通知 (异步) + commonNoticeSendService.sendNotice(NotificationConstants.Module.CLUE, NotificationConstants.Event.CLUE_ADD, clue.getName(), currentUser, + currentOrg, List.of(clue.getOwner()), true); + }); + clueMapper.batchInsert(clues); + clueFieldMapper.batchInsert(clueFields.stream().map(field -> BeanUtils.copyBean(new ClueField(), field)).toList()); + clueFieldBlobMapper.batchInsert(clueFieldBlobs.stream().map(field -> BeanUtils.copyBean(new ClueFieldBlob(), field)).toList()); + // 日志 + logService.batchAdd(logs); + }; + CustomFieldImportEventListener eventListener = new CustomFieldImportEventListener<>(fields, Clue.class, currentOrg, currentUser, + "clue_field", afterDo, 2000, null, null); + FastExcelFactory.read(file.getInputStream(), eventListener).headRowNumber(1).ignoreEmptyRow(true).sheet().doRead(); + return ImportResponse.builder().errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccessCount()).failCount(eventListener.getErrList().size()).build(); + } catch (Exception e) { + log.error("clue import error: ", e); + throw new GenericException(e.getMessage()); + } + } + + /** + * 检查导入的文件 + * + * @param file 文件 + * @param currentOrg 当前组织 + * + * @return 检查信息 + */ + private ImportResponse checkImportExcel(MultipartFile file, String currentOrg) { + try { + List fields = moduleFormService.getAllCustomImportFields(FormKey.CLUE.getKey(), currentOrg); + CustomFieldCheckEventListener eventListener = new CustomFieldCheckEventListener(fields, "clue", "clue_field", currentOrg); + FastExcelFactory.read(file.getInputStream(), eventListener).headRowNumber(1).ignoreEmptyRow(true).sheet().doRead(); + return ImportResponse.builder().errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccess()).failCount(eventListener.getErrList().size()).build(); + } catch (Exception e) { + log.error("clue import pre-check error: {}", e.getMessage()); + throw new GenericException(e.getMessage()); + } + } + + @SuppressWarnings("unchecked") + public void batchUpdate(ResourceBatchEditRequest request, String userId, String organizationId) { + BaseField field = clueFieldService.getAndCheckField(request.getFieldId(), organizationId); + if (Strings.CS.equals(field.getBusinessKey(), BusinessModuleField.CLUE_OWNER.getBusinessKey())) { + // 修改负责人,走批量转移接口 + ClueBatchTransferRequest batchTransferRequest = new ClueBatchTransferRequest(); + batchTransferRequest.setIds(request.getIds()); + batchTransferRequest.setOwner(request.getFieldValue().toString()); + batchTransfer(batchTransferRequest, userId, organizationId); + return; + } + + if (Strings.CS.equals(field.getBusinessKey(), BusinessModuleField.CLUE_PRODUCTS.getBusinessKey())) { + productService.checkProductList((List) request.getFieldValue()); + } + + List originClues = clueMapper.selectByIds(request.getIds()); + + clueFieldService.batchUpdate(request, field, originClues, Clue.class, LogModule.CLUE_INDEX, extClueMapper::batchUpdate, userId, organizationId); + } + + public List chart(ChartAnalysisRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { + ModuleFormConfigDTO formConfig = getFormConfig(orgId); + formConfig.getFields().addAll(BaseChartService.getChartBaseFields()); + ChartAnalysisDbRequest chartAnalysisDbRequest = ConditionFilterUtils.parseChartAnalysisRequest(request, formConfig); + ClueChartAnalysisDbRequest clueChartAnalysisDbRequest = BeanUtils.copyBean(new ClueChartAnalysisDbRequest(), chartAnalysisDbRequest); + List chartResults = extClueMapper.chart(clueChartAnalysisDbRequest, userId, orgId, deptDataPermission); + return baseChartService.translateAxisName(formConfig, chartAnalysisDbRequest, chartResults); + } + + /** + * 处理已转移线索的跟进计划和记录 + */ + public void processTransferredCluePlanAndRecord() { + List records = new ArrayList<>(); + List plans = new ArrayList<>(); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Clue::getTransitionType, "CUSTOMER"); + List clues = clueMapper.selectListByLambda(wrapper); + List transferredClues = clues.stream().filter(clue -> StringUtils.isNotBlank(clue.getTransitionId())).toList(); + Map clueTransferMap = transferredClues.stream().collect(Collectors.toMap(Clue::getId, Clue::getTransitionId)); + List clueIds = transferredClues.stream().map(Clue::getId).toList(); + // 记录 + LambdaQueryWrapper recordLambdaQueryWrapper = new LambdaQueryWrapper<>(); + recordLambdaQueryWrapper.in(FollowUpRecord::getClueId, clueIds).eq(FollowUpRecord::getType, FollowUpPlanType.CLUE.name()); + List clueRecords = followUpRecordMapper.selectListByLambda(recordLambdaQueryWrapper); + Map clueFollowMap = new HashMap<>(8); + clueRecords.forEach(clueRecord -> { + String customerId = clueTransferMap.get(clueRecord.getClueId()); + clueRecord.setId(IDGenerator.nextStr()); + clueRecord.setCustomerId(customerId); + clueRecord.setClueId(null); + clueRecord.setType(FollowUpPlanType.CUSTOMER.name()); + if (StringUtils.isNotBlank(clueRecord.getCustomerId())) { + records.add(clueRecord); + ClueFollowDTO clueFollowDTO = clueFollowMap.get(customerId); + if (clueFollowDTO == null) { + clueFollowMap.put(customerId, ClueFollowDTO.builder().follower(clueRecord.getOwner()) + .followerTime(clueRecord.getFollowTime()).build()); + } else { + Long recordTime = clueRecord.getFollowTime(); + if (recordTime == null) { + return; + } + Long followerTime = clueFollowDTO.getFollowerTime(); + if (followerTime == null || recordTime > followerTime) { + clueFollowDTO.setFollower(clueRecord.getOwner()); + clueFollowDTO.setFollowerTime(recordTime); + clueFollowMap.put(customerId, clueFollowDTO); + } + } + } + }); + // 计划 + LambdaQueryWrapper planLambdaQueryWrapper = new LambdaQueryWrapper<>(); + planLambdaQueryWrapper.in(FollowUpPlan::getClueId, clueIds).eq(FollowUpPlan::getType, FollowUpPlanType.CLUE.name()); + List cluePlans = followUpPlanMapper.selectListByLambda(planLambdaQueryWrapper); + cluePlans.forEach(cluePlan -> { + cluePlan.setId(IDGenerator.nextStr()); + cluePlan.setCustomerId(clueTransferMap.get(cluePlan.getClueId())); + cluePlan.setClueId(null); + cluePlan.setType(FollowUpPlanType.CUSTOMER.name()); + if (StringUtils.isNotBlank(cluePlan.getCustomerId())) { + plans.add(cluePlan); + } + }); + // 批量插入 + if (CollectionUtils.isNotEmpty(records)) { + followUpRecordMapper.batchInsert(records); + } + if (CollectionUtils.isNotEmpty(plans)) { + followUpPlanMapper.batchInsert(plans); + } + // 更新客户最新跟进人和时间 + List customerIds = clueFollowMap.keySet().stream().toList(); + List customers = customerMapper.selectByIds(customerIds); + customers.forEach(customer -> { + if (customer.getFollowTime() == null || customer.getFollowTime() < clueFollowMap.get(customer.getId()).getFollowerTime()) { + customer.setFollower(clueFollowMap.get(customer.getId()).getFollower()); + customer.setFollowTime(clueFollowMap.get(customer.getId()).getFollowerTime()); + } + customerMapper.updateById(customer); + }); + } + + /** + * 刷新客户的最新跟进时间 + * @param clue 线索信息 + * @param transitionCs 转移的客户信息 + */ + private void refreshCsFollowTime(Clue clue, Customer transitionCs) { + Long clueFollowTime = clue.getFollowTime(); + Long customerFollowTime = transitionCs.getFollowTime(); + if (clueFollowTime == null) { + return; + } + if (customerFollowTime == null || customerFollowTime < clueFollowTime) { + Customer updateCustomer = new Customer(); + updateCustomer.setId(transitionCs.getId()); + updateCustomer.setFollower(clue.getFollower()); + updateCustomer.setFollowTime(clueFollowTime); + customerMapper.updateById(updateCustomer); + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/service/PoolClueService.java b/backend/crm/src/main/java/cn/cordys/crm/clue/service/PoolClueService.java new file mode 100644 index 0000000..5319152 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/service/PoolClueService.java @@ -0,0 +1,394 @@ +package cn.cordys.crm.clue.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.TimeUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.clue.constants.ClueStatus; +import cn.cordys.crm.clue.domain.*; +import cn.cordys.crm.clue.dto.CluePoolDTO; +import cn.cordys.crm.clue.dto.CluePoolPickRuleDTO; +import cn.cordys.crm.clue.dto.CluePoolRecycleRuleDTO; +import cn.cordys.crm.clue.dto.request.PoolCluePickRequest; +import cn.cordys.crm.clue.mapper.ExtClueCapacityMapper; +import cn.cordys.crm.clue.mapper.ExtClueMapper; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.RuleConditionDTO; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.request.PoolBatchAssignRequest; +import cn.cordys.crm.system.dto.request.PoolBatchPickRequest; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.notice.CommonNoticeSendService; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.UserExtendService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +public class PoolClueService { + + public static final long DAY_MILLIS = 24 * 60 * 60 * 1000; + @Resource + private BaseMapper clueMapper; + @Resource + private ExtClueMapper extClueMapper; + @Resource + private BaseMapper ownerMapper; + @Resource + private BaseMapper poolMapper; + @Resource + private BaseMapper userMapper; + @Resource + private BaseMapper pickRuleMapper; + @Resource + private BaseMapper recycleRuleMapper; + @Resource + private ExtClueCapacityMapper extClueCapacityMapper; + @Resource + private UserExtendService userExtendService; + @Resource + private LogService logService; + @Resource + private CommonNoticeSendService commonNoticeSendService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private CluePoolService cluePoolService; + @Resource + private ClueFieldService clueFieldService; + + /** + * 获取当前用户线索池选项 + * + * @param currentUser 当前用户ID + * @param currentOrgId 当前组织ID + * + * @return 线索池选项 + */ + public List getPoolOptions(String currentUser, String currentOrgId) { + List options = new ArrayList<>(); + LambdaQueryWrapper poolWrapper = new LambdaQueryWrapper<>(); + poolWrapper.eq(CluePool::getEnable, true).eq(CluePool::getOrganizationId, currentOrgId); + poolWrapper.orderByDesc(CluePool::getUpdateTime); + List pools = poolMapper.selectListByLambda(poolWrapper); + if (CollectionUtils.isEmpty(pools)) { + return new ArrayList<>(); + } + + List userIds = pools.stream().flatMap(pool -> Stream.of(pool.getCreateUser(), pool.getUpdateUser())).toList(); + List createOrUpdateUsers = userMapper.selectByIds(userIds.toArray(new String[0])); + Map userMap = createOrUpdateUsers.stream() + .collect(Collectors.toMap(User::getId, User::getName)); + + List poolIds = pools.stream() + .map(CluePool::getId) + .toList(); + + LambdaQueryWrapper pickRuleWrapper = new LambdaQueryWrapper<>(); + pickRuleWrapper.in(CluePoolPickRule::getPoolId, poolIds); + + List pickRules = pickRuleMapper.selectListByLambda(pickRuleWrapper); + Map pickRuleMap = pickRules.stream() + .collect(Collectors.toMap(CluePoolPickRule::getPoolId, pickRule -> pickRule)); + + LambdaQueryWrapper recycleRuleWrapper = new LambdaQueryWrapper<>(); + recycleRuleWrapper.in(CluePoolRecycleRule::getPoolId, poolIds); + + List recycleRules = recycleRuleMapper.selectListByLambda(recycleRuleWrapper); + Map recycleRuleMap = recycleRules.stream() + .collect(Collectors.toMap(CluePoolRecycleRule::getPoolId, recycleRule -> recycleRule)); + + Map> hiddenFieldMap = cluePoolService.getCluePoolHiddenFieldsByPoolIds(poolIds) + .stream() + .collect(Collectors.groupingBy(CluePoolHiddenField::getPoolId)); + + List fields = moduleFormCacheService.getBusinessFormConfig(FormKey.CLUE.getKey(), currentOrgId).getFields(); + + pools.forEach(pool -> { + List scopeIds = userExtendService.getScopeOwnerIds(JSON.parseArray(pool.getScopeId(), String.class), currentOrgId); + List ownerIds = userExtendService.getScopeOwnerIds(JSON.parseArray(pool.getOwnerId(), String.class), currentOrgId); + if (scopeIds.contains(currentUser) || ownerIds.contains(currentUser) || Strings.CS.equals(currentUser, InternalUser.ADMIN.getValue())) { + CluePoolDTO poolDTO = new CluePoolDTO(); + BeanUtils.copyBean(poolDTO, pool); + poolDTO.setMembers(userExtendService.getScope(JSON.parseArray(pool.getScopeId(), String.class))); + poolDTO.setOwners(userExtendService.getScope(JSON.parseArray(pool.getOwnerId(), String.class))); + poolDTO.setCreateUserName(userMap.get(pool.getCreateUser())); + poolDTO.setUpdateUserName(userMap.get(pool.getUpdateUser())); + + CluePoolPickRuleDTO pickRule = new CluePoolPickRuleDTO(); + BeanUtils.copyBean(pickRule, pickRuleMap.get(pool.getId())); + CluePoolRecycleRuleDTO recycleRule = new CluePoolRecycleRuleDTO(); + CluePoolRecycleRule cluePoolRecycleRule = recycleRuleMap.get(pool.getId()); + BeanUtils.copyBean(recycleRule, cluePoolRecycleRule); + recycleRule.setConditions(JSON.parseArray(cluePoolRecycleRule.getCondition(), RuleConditionDTO.class)); + + poolDTO.setPickRule(pickRule); + poolDTO.setRecycleRule(recycleRule); + poolDTO.setEditable(ownerIds.contains(currentUser)); + + Set hiddenFieldIds; + if (hiddenFieldMap.get(pool.getId()) != null) { + hiddenFieldIds = hiddenFieldMap.get(pool.getId()).stream() + .map(CluePoolHiddenField::getFieldId) + .collect(Collectors.toSet()); + } else { + hiddenFieldIds = Set.of(); + } + + poolDTO.setFieldConfigs(cluePoolService.getCluePoolFieldConfigs(fields, hiddenFieldIds)); + options.add(poolDTO); + } + }); + return options; + } + + /** + * 领取线索 + * + * @param request 请求参数 + * @param currentUser 当前用户ID + * @param currentOrgId 当前组织ID + */ + public void pick(PoolCluePickRequest request, String currentUser, String currentOrgId) { + CluePool pool = poolMapper.selectByPrimaryKey(request.getPoolId()); + validateCapacity(1, currentUser, currentOrgId); + LambdaQueryWrapper pickRuleWrapper = new LambdaQueryWrapper<>(); + pickRuleWrapper.eq(CluePoolPickRule::getPoolId, request.getPoolId()); + List cluePoolPickRules = pickRuleMapper.selectListByLambda(pickRuleWrapper); + CluePoolPickRule pickRule = cluePoolPickRules.getFirst(); + boolean poolAdmin = userExtendService.isPoolAdmin(JSON.parseArray(pool.getOwnerId(), String.class), currentUser, currentOrgId); + if (!poolAdmin) { + validateDailyPickNum(1, currentUser, pickRule); + } + ownClue(request.getClueId(), currentUser, pickRule, currentUser, LogType.PICK, currentOrgId, poolAdmin); + } + + /** + * 分配线索 + * + * @param id 客户ID + * @param assignUserId 分配用户ID + */ + @OperationLog(module = LogModule.CLUE_POOL_INDEX, type = LogType.ASSIGN, resourceId = "{#request.clueId}") + public void assign(String id, String assignUserId, String currentOrgId, String currentUser) { + validateCapacity(1, assignUserId, currentOrgId); + ownClue(id, assignUserId, null, currentUser, LogType.ASSIGN, currentOrgId, false); + } + + /** + * 删除线索 + * + * @param id 客户ID + */ + @OperationLog(module = LogModule.CLUE_POOL_INDEX, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + Clue clue = clueMapper.selectByPrimaryKey(id); + LambdaQueryWrapper clueWrapper = new LambdaQueryWrapper<>(); + clueWrapper.eq(Clue::getId, id); + clueMapper.deleteByLambda(clueWrapper); + + // 设置操作对象 + OperationLogContext.setResourceName(clue.getName()); + } + + /** + * 批量领取线索 + * + * @param request 请求参数 + * @param currentUser 当前用户ID + * @param currentOrgId 当前组织ID + */ + public void batchPick(PoolBatchPickRequest request, String currentUser, String currentOrgId) { + CluePool pool = poolMapper.selectByPrimaryKey(request.getPoolId()); + validateCapacity(request.getBatchIds().size(), currentUser, currentOrgId); + LambdaQueryWrapper pickRuleWrapper = new LambdaQueryWrapper<>(); + pickRuleWrapper.eq(CluePoolPickRule::getPoolId, request.getPoolId()); + List cluePoolPickRules = pickRuleMapper.selectListByLambda(pickRuleWrapper); + CluePoolPickRule pickRule = cluePoolPickRules.getFirst(); + boolean poolAdmin = userExtendService.isPoolAdmin(JSON.parseArray(pool.getOwnerId(), String.class), currentUser, currentOrgId); + if (!poolAdmin) { + validateDailyPickNum(request.getBatchIds().size(), currentUser, pickRule); + } + request.getBatchIds().forEach(id -> ownClue(id, currentUser, pickRule, currentUser, LogType.PICK, currentOrgId, poolAdmin)); + } + + /** + * 批量分配线索 + * + * @param request 请求参数 + * @param assignUserId 分配用户ID + * @param currentOrgId 当前组织ID + */ + public void batchAssign(PoolBatchAssignRequest request, String assignUserId, String currentOrgId, String currentUser) { + validateCapacity(request.getBatchIds().size(), assignUserId, currentOrgId); + request.getBatchIds().forEach(id -> ownClue(id, assignUserId, null, currentUser, LogType.ASSIGN, currentOrgId, false)); + } + + /** + * 批量删除客户 + * + * @param ids 客户ID集合 + */ + public void batchDelete(List ids, String userId, String orgId) { + List clues = clueMapper.selectByIds(ids); + LambdaQueryWrapper clueWrapper = new LambdaQueryWrapper<>(); + clueWrapper.in(Clue::getId, ids); + clueMapper.deleteByLambda(clueWrapper); + + List logs = clues.stream() + .map(clue -> + new LogDTO(orgId, clue.getId(), userId, LogType.DELETE, LogModule.CLUE_POOL_INDEX, clue.getName()) + ) + .toList(); + logService.batchAdd(logs); + } + + /** + * 校验库容 + * + * @param processCount 处理数量 + * @param ownUserId 负责人用户ID + * @param currentOrgId 当前组织ID + */ + public void validateCapacity(int processCount, String ownUserId, String currentOrgId) { + // 实际可处理条数 = 负责人库容容量 - 所领取的数量 < 处理数量, 提示库容不足. + Integer capacity = getUserCapacity(ownUserId, currentOrgId); + int ownCount = (int) extClueMapper.getOwnerCount(ownUserId); + if (capacity != null && capacity - ownCount < processCount) { + throw new GenericException(Translator.getWithArgs("customer.capacity.over", Math.max(capacity - ownCount, 0))); + } + } + + /** + * 校验每日领取数量 + * + * @param pickingCount 领取数量 + * @param ownUserId 负责人用户ID + * @param pickRule 领取规则 + */ + public void validateDailyPickNum(int pickingCount, String ownUserId, CluePoolPickRule pickRule) { + if (pickRule.getLimitOnNumber()) { + LambdaQueryWrapper clueWrapper = new LambdaQueryWrapper<>(); + clueWrapper + .eq(Clue::getOwner, ownUserId) + .eq(Clue::getInSharedPool, false) + .between(Clue::getCollectionTime, TimeUtils.getTodayStart(), TimeUtils.getTodayStart() + DAY_MILLIS); + List clues = clueMapper.selectListByLambda(clueWrapper); + int pickedCount = clues.size(); + if (pickingCount + pickedCount > pickRule.getPickNumber()) { + throw new GenericException(Translator.get("customer.daily.pick.over")); + } + } + } + + /** + * 获取用户库容 + * + * @param userId 用户ID + * @param organizationId 组织ID + * + * @return 库容 + */ + public Integer getUserCapacity(String userId, String organizationId) { + List scopeIds = userExtendService.getUserScopeIds(userId, organizationId); + return extClueCapacityMapper.getCapacityByScopeIds(scopeIds, organizationId); + } + + /** + * 拥有客户 + * + * @param clueId 线索ID + * @param ownerId 拥有人ID + */ + private void ownClue(String clueId, String ownerId, CluePoolPickRule pickRule, String operateUserId, String logType, String currentOrgId, boolean isPoolAdmin) { + Clue clue = clueMapper.selectByPrimaryKey(clueId); + if (clue == null) { + throw new IllegalArgumentException(Translator.get("clue.not.exist")); + } + if (!clue.getInSharedPool()) { + throw new GenericException(Translator.getWithArgs("clue.pool.occupied", clue.getName())); + } + if (!isPoolAdmin && pickRule != null && pickRule.getLimitNew()) { + LocalDateTime joinPoolTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(clue.getUpdateTime()), ZoneId.systemDefault()); + LocalDateTime releaseDate = joinPoolTime.plusDays(pickRule.getNewPickInterval()); + if (releaseDate.isAfter(LocalDateTime.now())) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + throw new GenericException(Translator.getWithArgs("pool.data.release.date", releaseDate.format(formatter))); + } + } + if (pickRule != null && pickRule.getLimitPreOwner()) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(ClueOwner::getClueId, clueId); + List clueOwners = ownerMapper.selectListByLambda(queryWrapper); + if (CollectionUtils.isNotEmpty(clueOwners)) { + clueOwners.sort(Comparator.comparingLong(ClueOwner::getCollectionTime).reversed()); + ClueOwner lastOwner = clueOwners.getFirst(); + if (Strings.CS.equals(lastOwner.getOwner(), ownerId) && + System.currentTimeMillis() < pickRule.getPickIntervalDays() * DAY_MILLIS + lastOwner.getEndTime()) { + LocalDateTime nextPickTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(pickRule.getPickIntervalDays() * DAY_MILLIS + lastOwner.getEndTime()), + ZoneId.systemDefault()); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + throw new GenericException(Translator.getWithArgs("customer.pre_owner.pick.limit", nextPickTime.format(formatter))); + } + } + } + clue.setPoolId(null); + clue.setInSharedPool(false); + clue.setOwner(ownerId); + clue.setCollectionTime(System.currentTimeMillis()); + clue.setStage(ClueStatus.FOLLOWING.name()); + clue.setUpdateUser(ownerId); + clue.setUpdateTime(System.currentTimeMillis()); + extClueMapper.updateIncludeNullById(clue); + + // 日志 + LogDTO logDTO = new LogDTO(currentOrgId, clue.getId(), operateUserId, logType, LogModule.CLUE_POOL_INDEX, clue.getName()); + logService.add(logDTO); + + // 分配通知 + if (Strings.CS.equals(logType, LogType.ASSIGN)) { + commonNoticeSendService.sendNotice(NotificationConstants.Module.CLUE, NotificationConstants.Event.CLUE_DISTRIBUTED, + clue.getName(), operateUserId, currentOrgId, List.of(clue.getOwner()), true); + } + } + + public void batchUpdate(ResourceBatchEditRequest request, String userId, String organizationId) { + BaseField field = clueFieldService.getAndCheckField(request.getFieldId(), organizationId); + + if (Strings.CS.equals(field.getBusinessKey(), BusinessModuleField.CLUE_OWNER.getBusinessKey())) { + // 修改负责人,走批量分配的接口 + PoolBatchAssignRequest batchAssignRequest = new PoolBatchAssignRequest(); + batchAssignRequest.setBatchIds(request.getIds()); + batchAssignRequest.setAssignUserId(request.getFieldValue().toString()); + batchAssign(batchAssignRequest, batchAssignRequest.getAssignUserId(), organizationId, userId); + return; + } + + List originCustomers = clueMapper.selectByIds(request.getIds()); + + clueFieldService.batchUpdate(request, field, originCustomers, Clue.class, LogModule.CLUE_POOL_INDEX, extClueMapper::batchUpdate, userId, organizationId); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/utils/ClueFieldUtils.java b/backend/crm/src/main/java/cn/cordys/crm/clue/utils/ClueFieldUtils.java new file mode 100644 index 0000000..4e57ff4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/utils/ClueFieldUtils.java @@ -0,0 +1,54 @@ +package cn.cordys.crm.clue.utils; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.util.TimeUtils; +import cn.cordys.crm.clue.dto.response.ClueListResponse; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class ClueFieldUtils { + + public static LinkedHashMap getSystemFieldMap(ClueListResponse data, Map> optionMap) { + LinkedHashMap systemFieldMap = new LinkedHashMap<>(); + systemFieldMap.put("name", data.getName()); + systemFieldMap.put("owner", data.getOwnerName()); + systemFieldMap.put("contact", data.getContact()); + systemFieldMap.put("phone", data.getPhone()); + systemFieldMap.put("products", getProducts(optionMap, data.getProducts())); + systemFieldMap.put("collectionTime", TimeUtils.getDateTimeStr(data.getCollectionTime())); + systemFieldMap.put("createUser", data.getCreateUserName()); + systemFieldMap.put("createTime", TimeUtils.getDateTimeStr(data.getCreateTime())); + systemFieldMap.put("updateUser", data.getUpdateUserName()); + systemFieldMap.put("updateTime", TimeUtils.getDateTimeStr(data.getUpdateTime())); + systemFieldMap.put("follower", data.getFollowerName()); + systemFieldMap.put("followTime", TimeUtils.getDateTimeStr(data.getFollowTime())); + systemFieldMap.put("reservedDays", data.getReservedDays()); + systemFieldMap.put("recyclePoolName", data.getRecyclePoolName()); + systemFieldMap.put("departmentId", data.getDepartmentName()); + return systemFieldMap; + } + + public static Object getProducts(Map> optionMap, List products) { + List productNames = new ArrayList<>(); + if (optionMap.containsKey(BusinessModuleField.CLUE_PRODUCTS.getBusinessKey()) && CollectionUtils.isNotEmpty(products)) { + Map productsMap = optionMap.get(BusinessModuleField.CLUE_PRODUCTS.getBusinessKey()).stream().collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + products.forEach(product -> { + if (productsMap.containsKey(product)) { + productNames.add(productsMap.get(product)); + } + }); + } + if (CollectionUtils.isEmpty(productNames)) { + return StringUtils.EMPTY; + } + return StringUtils.join(productNames, ","); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/utils/PoolClueFieldUtils.java b/backend/crm/src/main/java/cn/cordys/crm/clue/utils/PoolClueFieldUtils.java new file mode 100644 index 0000000..cb3d517 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/utils/PoolClueFieldUtils.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.clue.utils; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.clue.dto.response.ClueListResponse; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class PoolClueFieldUtils { + + public static LinkedHashMap getSystemFieldMap(ClueListResponse data, Map> optionMap) { + LinkedHashMap systemFieldMap = ClueFieldUtils.getSystemFieldMap(data, optionMap); + systemFieldMap.put("reasonId", data.getReasonName()); + return systemFieldMap; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/constants/BusinessTitleConstants.java b/backend/crm/src/main/java/cn/cordys/crm/contract/constants/BusinessTitleConstants.java new file mode 100644 index 0000000..49eb1ed --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/constants/BusinessTitleConstants.java @@ -0,0 +1,52 @@ +package cn.cordys.crm.contract.constants; + +public enum BusinessTitleConstants { + + NAME("name", "公司名称", "Name"), + IDENTIFICATION_NUMBER("identificationNumber", "纳税人识别号", "Identification number"), + OPENING_BANK("openingBank", "开户银行", "Opening bank"), + BANK_ACCOUNT("bankAccount", "银行账户", "Bank account"), + REGISTRATION_ADDRESS("registrationAddress", "注册地址", "Registration address"), + PHONE_NUMBER("phoneNumber", "注册电话", "Phone number"), + REGISTERED_CAPITAL("registeredCapital", "注册资本", "Registered capital"), + COMPANY_SIZE("companySize", "公司规模", "Customer size"), + REGISTRATION_NUMBER("registrationNumber", "工商注册账号", "Registration number"), + PROVINCE("province", "省", "Province"), + CITY("city", "市", "City"), + SCALE("scale", "企业规模", "Scale"), + INDUSTRY("industry", "国标行业", "Industry"), + REMARK("remark", "备注", "Remark"), + COMPANY_NUMBER("companyNumber", "公司编号", "Company number"); + + private final String key; + private final String ch; + private final String us; + + BusinessTitleConstants(String key, String ch, String us) { + this.key = key; + this.ch = ch; + this.us = us; + } + + public String getKey() { + return key; + } + + public String getCh() { + return ch; + } + + public String getUs() { + return us; + } + + public String getId() { + if (this == BusinessTitleConstants.NAME) { + // name 和其他字段的 businessKey 冲突 + return "business_title_" + getKey(); + } else { + // 其他字段不冲突,不处理,避免影响历史数据 + return getKey(); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/constants/BusinessTitleType.java b/backend/crm/src/main/java/cn/cordys/crm/contract/constants/BusinessTitleType.java new file mode 100644 index 0000000..9aec58a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/constants/BusinessTitleType.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.contract.constants; + + +/** + * 工商抬头类型 + */ +public enum BusinessTitleType { + + + /** + * 自定义 + */ + CUSTOM, + + /** + * 第三方 + */ + THIRD_PARTY, + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/constants/ContractApprovalStatus.java b/backend/crm/src/main/java/cn/cordys/crm/contract/constants/ContractApprovalStatus.java new file mode 100644 index 0000000..3bdc61b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/constants/ContractApprovalStatus.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.contract.constants; + + +/** + * 合同审核状态 + */ +public enum ContractApprovalStatus { + + /** + * 审核中 + */ + APPROVING, + + /** + * 通过 + */ + APPROVED, + + /** + * 不通过 + */ + UNAPPROVED, + /** + * 撤销 + */ + REVOKED, + /** + * 当关闭审批时,审批状态为 NONE + */ + NONE, +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/constants/ContractPaymentPlanStatus.java b/backend/crm/src/main/java/cn/cordys/crm/contract/constants/ContractPaymentPlanStatus.java new file mode 100644 index 0000000..22f5436 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/constants/ContractPaymentPlanStatus.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.contract.constants; + +public enum ContractPaymentPlanStatus { + /** + * 待处理 + */ + PENDING, + /** + * 部分完成 + */ + PARTIALLY_COMPLETED, + /** + * 已完成 + */ + COMPLETED +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/constants/ContractStage.java b/backend/crm/src/main/java/cn/cordys/crm/contract/constants/ContractStage.java new file mode 100644 index 0000000..76b0101 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/constants/ContractStage.java @@ -0,0 +1,44 @@ +package cn.cordys.crm.contract.constants; + + +/** + * 合同状态 + */ +public enum ContractStage { + + + /** + * 待签署 + */ + PENDING_SIGNING, + + /** + * 已签署 + */ + SIGNED, + + /** + * 合同变更 + */ + CHANGE, + + /** + * 履行中 + */ + IN_PROGRESS, + + /** + * 履行完毕 + */ + COMPLETED_PERFORMANCE, + + /** + * 作废 + */ + VOID, + + /** + * 合同完结 + */ + ARCHIVED, +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/constants/SystemFieldConstants.java b/backend/crm/src/main/java/cn/cordys/crm/contract/constants/SystemFieldConstants.java new file mode 100644 index 0000000..195ab35 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/constants/SystemFieldConstants.java @@ -0,0 +1,42 @@ +package cn.cordys.crm.contract.constants; + +import cn.cordys.crm.system.constants.FieldSourceType; +import lombok.Getter; + +/** + * 系统字段枚举 + */ +@Getter +public enum SystemFieldConstants { + + /** + * 报价数据源 (状态, 审批状态) + */ + QUOTATION_INVALID("invalid", FieldSourceType.QUOTATION), + QUOTATION_APPROVAL_STATUS("approvalStatus", FieldSourceType.QUOTATION), + + /** + * 合同数据源 (合同阶段, 审批状态) + */ + CONTRACT_STAGE("stage", FieldSourceType.CONTRACT), + CONTRACT_APPROVAL_STATUS("approvalStatus", FieldSourceType.CONTRACT), + + /** + * 订单数据源 (订单阶段, 审批状态) + */ + ORDER_STAGE("stage", FieldSourceType.ORDER), + ORDER_APPROVAL_STATUS("approvalStatus", FieldSourceType.ORDER), + + /** + * 发票数据源 (审批状态) + */ + INVOICE_APPROVAL_STATUS("approvalStatus", FieldSourceType.INVOICE); + + private final String key; + private final FieldSourceType sourceType; + + SystemFieldConstants(String key, FieldSourceType sourceType) { + this.key = key; + this.sourceType = sourceType; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/controller/BusinessTitleConfigController.java b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/BusinessTitleConfigController.java new file mode 100644 index 0000000..61e7649 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/BusinessTitleConfigController.java @@ -0,0 +1,41 @@ +package cn.cordys.crm.contract.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.contract.domain.BusinessTitleConfig; +import cn.cordys.crm.contract.service.BusinessTitleConfigService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + + +@Tag(name = "工商抬头配置") +@RestController +@RequestMapping("/business-title/config") +public class BusinessTitleConfigController { + + @Resource + private BusinessTitleConfigService businessTitleConfigService; + + + @GetMapping("/get") + @Operation(summary = "获取工商抬头配置") + public List getStageConfigList() { + return businessTitleConfigService.getConfigs(OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/switch/{id}") + @Operation(summary = "必填/非必填设置") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void switchRequired(@PathVariable String id) { + businessTitleConfigService.switchRequired(id); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/controller/BusinessTitleController.java b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/BusinessTitleController.java new file mode 100644 index 0000000..a5665cf --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/BusinessTitleController.java @@ -0,0 +1,187 @@ +package cn.cordys.crm.contract.controller; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.ExportDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.contract.domain.BusinessTitle; +import cn.cordys.crm.contract.dto.request.*; +import cn.cordys.crm.contract.dto.response.BusinessTitleListResponse; +import cn.cordys.crm.contract.service.BusinessTitleExportService; +import cn.cordys.crm.contract.service.BusinessTitleService; +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Tag(name = "工商抬头") +@RestController +@RequestMapping("/contract/business-title") +public class BusinessTitleController { + + @Resource + private BusinessTitleService businessTitleService; + @Resource + private BusinessTitleExportService businessTitleExportService; + + + @GetMapping("/module/form") + @Operation(summary = "获取表单配置") + public ModuleFormConfigDTO getModuleFormConfig() { + return businessTitleService.getBusinessFormConfig(); + } + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.CONTRACT_BUSINESS_TITLE_ADD) + @Operation(summary = "创建") + public BusinessTitle add(@Validated @RequestBody BusinessTitleAddRequest request) { + return businessTitleService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.CONTRACT_BUSINESS_TITLE_UPDATE) + @Operation(summary = "更新") + public BusinessTitle update(@Validated @RequestBody BusinessTitleUpdateRequest request) { + return businessTitleService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_BUSINESS_TITLE_DELETE) + @Operation(summary = "删除") + public void delete(@PathVariable("id") String id) { + businessTitleService.delete(id); + } + + + @GetMapping("/invoice/check/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_BUSINESS_TITLE_DELETE) + @Operation(summary = "检查工商抬头是否开过票") + public boolean checkInvoice(@PathVariable String id) { + return businessTitleService.checkHasInvoice(id); + } + + + @PostMapping("/page") + @RequiresPermissions(PermissionConstants.CONTRACT_BUSINESS_TITLE_READ) + @Operation(summary = "列表") + public Pager> list(@Validated @RequestBody BusinessTitlePageRequest request) { + ConditionFilterUtils.parseCondition(request); + return businessTitleService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/get/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_BUSINESS_TITLE_READ) + @Operation(summary = "详情") + public BusinessTitleListResponse get(@PathVariable("id") String id) { + return businessTitleService.get(id); + } + + + @PostMapping("/approval") + @RequiresPermissions(PermissionConstants.CONTRACT_BUSINESS_TITLE_APPROVAL) + @Operation(summary = "审核通过/不通过") + public void approval(@Validated @RequestBody BusinessTitleApprovalRequest request) { + businessTitleService.approvalContract(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/revoke/{id}") + @Operation(summary = "撤销审批") + public String revoke(@PathVariable("id") String id) { + return businessTitleService.revoke(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/export-select") + @Operation(summary = "导出选中工商抬头") + @RequiresPermissions(PermissionConstants.CONTRACT_BUSINESS_TITLE_EXPORT) + public String exportSelect(@Validated @RequestBody ExportSelectRequest request) { + ExportDTO exportDTO = ExportDTO.builder() + .exportType(ExportConstants.ExportType.BUSINESS_TITLE.name()) + .fileName(request.getFileName()) + .headList(request.getHeadList()) + .logModule(LogModule.CONTRACT_BUSINESS_TITLE) + .locale(LocaleContextHolder.getLocale()) + .orgId(OrganizationContext.getOrganizationId()) + .userId(SessionUtils.getUserId()) + .selectIds(request.getIds()) + .selectRequest(request) + .build(); + return businessTitleExportService.exportSelect(exportDTO); + } + + + @PostMapping("/export-all") + @Operation(summary = "导出全部") + @RequiresPermissions(PermissionConstants.CONTRACT_BUSINESS_TITLE_EXPORT) + public String exportAll(@Validated @RequestBody BusinessTitleExportRequest request) { + ConditionFilterUtils.parseCondition(request); + ExportDTO exportDTO = ExportDTO.builder() + .exportType(ExportConstants.ExportType.BUSINESS_TITLE.name()) + .fileName(request.getFileName()) + .headList(request.getHeadList()) + .logModule(LogModule.CONTRACT_BUSINESS_TITLE) + .locale(LocaleContextHolder.getLocale()) + .orgId(OrganizationContext.getOrganizationId()) + .userId(SessionUtils.getUserId()) + .pageRequest(request) + .build(); + return businessTitleExportService.export(exportDTO); + } + + + @GetMapping("/template/download") + @RequiresPermissions(PermissionConstants.CONTRACT_BUSINESS_TITLE_IMPORT) + @Operation(summary = "下载导入模板") + public void downloadImportTpl(HttpServletResponse response) { + businessTitleService.downloadImportTpl(response, OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/import/pre-check") + @Operation(summary = "excel导入检查") + @RequiresPermissions(PermissionConstants.CONTRACT_BUSINESS_TITLE_IMPORT) + public ImportResponse preCheck(@RequestPart(value = "file", required = false) MultipartFile file, @Validated @RequestPart("request") BusinessTitleImportRequest request) { + return businessTitleService.importPreCheck(file, OrganizationContext.getOrganizationId(), request); + } + + + @PostMapping(value = "/import") + @Operation(summary = "导入") + @RequiresPermissions(PermissionConstants.CONTRACT_BUSINESS_TITLE_IMPORT) + public ImportResponse realImport(@RequestPart(value = "file", required = false) MultipartFile file, @Validated @RequestPart("request") BusinessTitleImportRequest request) { + return businessTitleService.realImport(file, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), request); + } + + + @PostMapping("/third-query/option") + @RequiresPermissions(PermissionConstants.CONTRACT_BUSINESS_TITLE_ADD) + @Operation(summary = "第三方接口分页模糊查询工商名称") + public Pager> thirdQueryOptions(@Validated @RequestBody BasePageRequest request) { + return businessTitleService.thirdQueryOption(request.getKeyword(), request.getCurrent(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/third-query") + @RequiresPermissions(PermissionConstants.CONTRACT_BUSINESS_TITLE_ADD) + @Operation(summary = "第三方接口查询工商抬头信息") + public BusinessTitle thirdQuery(@RequestParam String keyword) { + return businessTitleService.thirdQuery(keyword, OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractController.java b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractController.java new file mode 100644 index 0000000..19c39c8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractController.java @@ -0,0 +1,271 @@ +package cn.cordys.crm.contract.controller; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.InternalUserView; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ExportDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.common.dto.condition.BaseCondition; +import cn.cordys.common.dto.stage.StageSortRequest; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.CsBatchPermission; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.clue.dto.request.ContractDetailPaymentPlanPageRequest; +import cn.cordys.crm.contract.domain.Contract; +import cn.cordys.crm.contract.dto.request.*; +import cn.cordys.crm.contract.dto.response.*; +import cn.cordys.crm.contract.service.*; +import cn.cordys.crm.customer.dto.request.ContractDetailInvoicePageRequest; +import cn.cordys.crm.customer.dto.request.ContractOrderPageRequest; +import cn.cordys.crm.order.dto.response.OrderListResponse; +import cn.cordys.crm.order.service.OrderService; +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.BatchAffectReasonResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.List; + + +@Tag(name = "合同") +@RestController +@RequestMapping("/contract") +public class ContractController { + @Resource + private ContractService contractService; + @Resource + private ContractExportService contractExportService; + @Resource + private DataScopeService dataScopeService; + @Resource + private ContractPaymentPlanService contractPaymentPlanService; + @Resource + private ContractPaymentRecordService contractPaymentRecordService; + @Resource + private ContractInvoiceService contractInvoiceService; + @Resource + private OrderService orderService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + + + @GetMapping("/module/form") + @RequiresPermissions(PermissionConstants.CONTRACT_READ) + @Operation(summary = "获取表单配置") + public ModuleFormConfigDTO getModuleFormConfig() { + return moduleFormCacheService.getBusinessFormConfig(FormKey.CONTRACT.getKey(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.CONTRACT_ADD) + @Operation(summary = "创建") + public Contract add(@Validated @RequestBody ContractAddRequest request) { + return contractService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @CsPermission(value = PermissionConstants.CONTRACT_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.CONTRACT) + @Operation(summary = "更新") + public Contract update(@Validated @RequestBody ContractUpdateRequest request) { + return contractService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update/stage") + @CsPermission(value = PermissionConstants.CONTRACT_STAGE, resourceId = "{#request.id}", formType = FormKeyConstants.CONTRACT) + @Operation(summary = "更新合同阶段") + public void updateStage(@Validated @RequestBody ContractStageRequest request) { + contractService.updateStage(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @CsPermission(value = PermissionConstants.CONTRACT_DELETE, resourceId = "{#id}", formType = FormKeyConstants.CONTRACT) + @Operation(summary = "删除") + public void delete(@PathVariable("id") String id) { + contractService.delete(id, SessionUtils.getUserId()); + } + + @GetMapping("/get/snapshot/{id}") + @CsPermission(value = PermissionConstants.CONTRACT_READ, resourceId = "{#id}", formType = FormKeyConstants.CONTRACT) + @Operation(summary = "获取详情快照") + public ContractGetResponse getSnapshot(@PathVariable("id") String id) { + return contractService.getSnapshot(id, OrganizationContext.getOrganizationId()); + } + + @GetMapping("/get/{id}") + @CsPermission(value = PermissionConstants.CONTRACT_READ, resourceId = "{#id}", formType = FormKeyConstants.CONTRACT) + @Operation(summary = "详情") + public ContractGetResponse get(@PathVariable("id") String id) { + return contractService.get(id, OrganizationContext.getOrganizationId()); + } + + @GetMapping("/module/form/snapshot/{id}") + @CsPermission(value = PermissionConstants.CONTRACT_READ, resourceId = "{#id}", formType = FormKeyConstants.CONTRACT) + @Operation(summary = "获取表单快照配置") + public ModuleFormConfigDTO getFormSnapshot(@PathVariable("id") String id) { + return contractService.getFormSnapshot(id, OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/page") + @CsPermission(PermissionConstants.CONTRACT_READ) + @Operation(summary = "列表") + public PagerWithOption> list(@Validated @RequestBody ContractPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTRACT.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_READ); + return contractService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission, false); + } + + @PostMapping("/batch/update") + @CsBatchPermission(value = PermissionConstants.CONTRACT_UPDATE, resourceId = "{#request.ids}", formType = FormKeyConstants.CONTRACT) + @Operation(summary = "批量更新合同") + public BatchAffectReasonResponse batchUpdate(@Validated @RequestBody ResourceBatchEditRequest request) { + return contractService.batchUpdate(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/contract-payment-plan/page") + @RequiresPermissions({PermissionConstants.CONTRACT_READ, PermissionConstants.CONTRACT_PAYMENT_PLAN_READ}) + @Operation(summary = "合同详情-回款列表") + public PagerWithOption> paymentPlanList(@Validated @RequestBody ContractDetailPaymentPlanPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTRACT_PAYMENT_PLAN.getKey()); + request.setViewId(InternalUserView.ALL.name()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_PAYMENT_PLAN_READ); + return contractPaymentPlanService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @PostMapping("/contract-payment-record/page") + @RequiresPermissions({PermissionConstants.CONTRACT_READ, PermissionConstants.CONTRACT_PAYMENT_RECORD_READ}) + @Operation(summary = "合同详情-回款记录") + public PagerWithOption> paymentRecordList(@Validated @RequestBody ContractPaymentRecordPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTRACT_PAYMENT_RECORD.getKey()); + request.setViewId(InternalUserView.ALL.name()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_PAYMENT_RECORD_READ); + return contractPaymentRecordService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @GetMapping("/tab") + @RequiresPermissions(PermissionConstants.CONTRACT_READ) + @Operation(summary = "tab是否显示") + public ResourceTabEnableDTO getTabEnableConfig() { + return contractService.getTabEnableConfig(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/export-select") + @Operation(summary = "导出选中合同") + @CsBatchPermission(value = PermissionConstants.CONTRACT_EXPORT, resourceId = "{#request.ids}", formType = FormKeyConstants.CONTRACT) + public String exportSelect(@Validated @RequestBody ExportSelectRequest request) { + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), PermissionConstants.CONTRACT_READ); + ExportDTO exportDTO = ExportDTO.builder() + .exportType(ExportConstants.ExportType.CONTRACT.name()) + .fileName(request.getFileName()) + .headList(request.getHeadList()) + .logModule(LogModule.CONTRACT_INDEX) + .locale(LocaleContextHolder.getLocale()) + .orgId(OrganizationContext.getOrganizationId()) + .userId(SessionUtils.getUserId()) + .deptDataPermission(deptDataPermission) + .selectIds(request.getIds()) + .selectRequest(request) + .formKey(FormKey.CONTRACT.getKey()) + .build(); + return contractExportService.exportSelectWithMergeStrategy(exportDTO); + } + + @PostMapping("/export-all") + @Operation(summary = "导出全部合同") + @RequiresPermissions(PermissionConstants.CONTRACT_EXPORT) + public String exportAll(@Validated @RequestBody ContractExportRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTRACT.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_READ); + ExportDTO exportDTO = ExportDTO.builder() + .exportType(ExportConstants.ExportType.CONTRACT.name()) + .fileName(request.getFileName()) + .headList(request.getHeadList()) + .logModule(LogModule.CONTRACT_INDEX) + .locale(LocaleContextHolder.getLocale()) + .orgId(OrganizationContext.getOrganizationId()) + .userId(SessionUtils.getUserId()) + .deptDataPermission(deptDataPermission) + .pageRequest(request) + .formKey(FormKey.CONTRACT.getKey()) + .build(); + return contractExportService.exportAllWithMergeStrategy(exportDTO); + } + + @PostMapping("/invoice/page") + @RequiresPermissions({PermissionConstants.CONTRACT_READ, PermissionConstants.CONTRACT_INVOICE_READ}) + @Operation(summary = "合同详情-发票列表") + public PagerWithOption> invoiceList(@Validated @RequestBody ContractDetailInvoicePageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.INVOICE.getKey()); + request.setViewId(InternalUserView.ALL.name()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_INVOICE_READ); + return contractInvoiceService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @PostMapping("/order/page") + @RequiresPermissions({PermissionConstants.CONTRACT_READ, PermissionConstants.ORDER_READ}) + @Operation(summary = "合同详情-订单列表") + public PagerWithOption> invoiceList(@Validated @RequestBody ContractOrderPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.ORDER.getKey()); + request.setViewId(InternalUserView.ALL.name()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.ORDER_READ); + return orderService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission, false); + } + + @GetMapping("/invoice/statistic/{contractId}") + @RequiresPermissions({PermissionConstants.CONTRACT_READ, PermissionConstants.CONTRACT_INVOICE_READ}) + @Operation(summary = "合同详情-发票列表统计") + public CustomerInvoiceStatisticResponse calculateCustomerInvoiceStatistic(@PathVariable String contractId) { + BigDecimal invoiceAmount = contractInvoiceService.calculateContractInvoiceAmount(contractId, SessionUtils.getUserId(), + OrganizationContext.getOrganizationId()); + + CustomerInvoiceStatisticResponse response = new CustomerInvoiceStatisticResponse(); + Contract contract = contractService.selectByPrimaryKey(contractId); + response.setContractAmount(contract == null ? BigDecimal.ZERO : contract.getAmount()); + response.setInvoicedAmount(invoiceAmount); + response.setUninvoicedAmount(response.getContractAmount().subtract(invoiceAmount)); + return response; + } + + + @PostMapping("/statistic") + @RequiresPermissions(PermissionConstants.CONTRACT_READ) + @Operation(summary = "合同统计") + public ContractStatisticResponse searchStatistic(@Validated @RequestBody BaseCondition request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTRACT.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_READ); + return contractService.searchStatistic(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @PostMapping("/sort") + @Operation(summary = "合同阶段看板拖拽排序") + public void sortModule(@Validated @RequestBody StageSortRequest request) { + contractService.sort(request, SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractInvoiceController.java b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractInvoiceController.java new file mode 100644 index 0000000..cce7c9b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractInvoiceController.java @@ -0,0 +1,167 @@ +package cn.cordys.crm.contract.controller; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ExportDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.CsBatchPermission; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.contract.domain.ContractInvoice; +import cn.cordys.crm.contract.dto.request.ContractInvoiceAddRequest; +import cn.cordys.crm.contract.dto.request.ContractInvoiceExportRequest; +import cn.cordys.crm.contract.dto.request.ContractInvoicePageRequest; +import cn.cordys.crm.contract.dto.request.ContractInvoiceUpdateRequest; +import cn.cordys.crm.contract.dto.response.ContractInvoiceGetResponse; +import cn.cordys.crm.contract.dto.response.ContractInvoiceListResponse; +import cn.cordys.crm.contract.service.ContractInvoiceExportService; +import cn.cordys.crm.contract.service.ContractInvoiceService; +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.NotNull; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + + +@Tag(name = "发票") +@RestController +@RequestMapping("/invoice") +public class ContractInvoiceController { + @Resource + private ContractInvoiceService contractInvoiceService; + @Resource + private ContractInvoiceExportService contractInvoiceExportService; + @Resource + private DataScopeService dataScopeService; + + @GetMapping("/module/form") + @CsPermission(PermissionConstants.CONTRACT_INVOICE_READ) + @Operation(summary = "获取表单配置") + public ModuleFormConfigDTO getModuleFormConfig() { + return contractInvoiceService.getBusinessFormConfig(OrganizationContext.getOrganizationId()); + } + + @PostMapping("/page") + @CsPermission(PermissionConstants.CONTRACT_INVOICE_READ) + @Operation(summary = "列表") + public PagerWithOption> list(@Validated @RequestBody ContractInvoicePageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.INVOICE.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_INVOICE_READ); + return contractInvoiceService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @GetMapping("/get/snapshot/{id}") + @CsPermission(value = PermissionConstants.CONTRACT_INVOICE_READ, resourceId = "{#id}", formType = FormKeyConstants.CONTRACT_INVOICE) + @Operation(summary = "获取详情快照") + public ContractInvoiceGetResponse getSnapshot(@PathVariable("id") String id) { + return contractInvoiceService.getSnapshot(id, OrganizationContext.getOrganizationId()); + } + + @GetMapping("/get/{id}") + @CsPermission(value = PermissionConstants.CONTRACT_INVOICE_READ, resourceId = "{#id}", formType = FormKeyConstants.CONTRACT_INVOICE) + @Operation(summary = "详情") + public ContractInvoiceGetResponse get(@PathVariable("id") String id) { + return contractInvoiceService.get(id, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/add") + @CsPermission(PermissionConstants.CONTRACT_INVOICE_ADD) + @Operation(summary = "创建") + public ContractInvoice add(@Validated @RequestBody ContractInvoiceAddRequest request) { + return contractInvoiceService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @CsPermission(value = PermissionConstants.CONTRACT_INVOICE_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.CONTRACT_INVOICE) + @Operation(summary = "更新") + public ContractInvoice update(@Validated @RequestBody ContractInvoiceUpdateRequest request) { + return contractInvoiceService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/delete/{id}") + @CsPermission(value = PermissionConstants.CONTRACT_INVOICE_DELETE, resourceId = "{#id}", formType = FormKeyConstants.CONTRACT_INVOICE) + @Operation(summary = "删除") + public void delete(@PathVariable("id") String id) { + contractInvoiceService.delete(id, SessionUtils.getUserId()); + } + + @GetMapping("/module/form/snapshot/{id}") + @CsPermission(value = PermissionConstants.CONTRACT_INVOICE_READ, resourceId = "{#id}", formType = FormKeyConstants.CONTRACT_INVOICE) + @Operation(summary = "获取表单快照配置") + public ModuleFormConfigDTO getFormSnapshot(@PathVariable("id") String id) { + return contractInvoiceService.getFormSnapshot(id, OrganizationContext.getOrganizationId()); + } + + @GetMapping("/tab") + @CsPermission(PermissionConstants.CONTRACT_INVOICE_READ) + @Operation(summary = "tab是否显示") + public ResourceTabEnableDTO getTabEnableConfig() { + return contractInvoiceService.getTabEnableConfig(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/export-select") + @Operation(summary = "导出选中发票") + @CsBatchPermission(value = PermissionConstants.CONTRACT_INVOICE_EXPORT, resourceId = "{#request.ids}", formType = FormKeyConstants.CONTRACT_INVOICE) + public String exportSelect(@Validated @RequestBody ExportSelectRequest request) { + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), PermissionConstants.CONTRACT_INVOICE_READ); + ExportDTO exportDTO = ExportDTO.builder() + .exportType(ExportConstants.ExportType.CONTRACT_INVOICE.name()) + .fileName(request.getFileName()) + .headList(request.getHeadList()) + .logModule(LogModule.CONTRACT_INVOICE) + .locale(LocaleContextHolder.getLocale()) + .orgId(OrganizationContext.getOrganizationId()) + .userId(SessionUtils.getUserId()) + .deptDataPermission(deptDataPermission) + .selectIds(request.getIds()) + .selectRequest(request) + .formKey(FormKey.INVOICE.getKey()) + .build(); + return contractInvoiceExportService.exportSelect(exportDTO); + } + + @PostMapping("/export-all") + @Operation(summary = "导出全部合同") + @CsPermission(PermissionConstants.CONTRACT_INVOICE_EXPORT) + public String exportAll(@Validated @RequestBody ContractInvoiceExportRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.INVOICE.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_INVOICE_EXPORT); + ExportDTO exportDTO = ExportDTO.builder() + .exportType(ExportConstants.ExportType.CONTRACT_INVOICE.name()) + .fileName(request.getFileName()) + .headList(request.getHeadList()) + .logModule(LogModule.CONTRACT_INVOICE) + .locale(LocaleContextHolder.getLocale()) + .orgId(OrganizationContext.getOrganizationId()) + .userId(SessionUtils.getUserId()) + .deptDataPermission(deptDataPermission) + .pageRequest(request) + .formKey(FormKey.INVOICE.getKey()) + .build(); + return contractInvoiceExportService.export(exportDTO); + } + + @PostMapping("/batch/delete") + @CsBatchPermission(value = PermissionConstants.CONTRACT_INVOICE_DELETE, resourceId = "{#ids}", formType = FormKeyConstants.CONTRACT_INVOICE) + @Operation(summary = "批量删除客户") + public void batchDelete(@RequestBody @NotNull List ids) { + contractInvoiceService.batchDelete(ids, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractInvoiceUserViewController.java b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractInvoiceUserViewController.java new file mode 100644 index 0000000..04da141 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractInvoiceUserViewController.java @@ -0,0 +1,93 @@ +package cn.cordys.crm.contract.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.UserViewResourceType; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import cn.cordys.crm.system.dto.response.UserViewListResponse; +import cn.cordys.crm.system.dto.response.UserViewResponse; +import cn.cordys.crm.system.service.UserViewService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "发票视图") +@RestController +@RequestMapping("/invoice/view") +public class ContractInvoiceUserViewController { + + @Resource + private UserViewService userViewService; + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.CONTRACT_INVOICE_READ) + @Operation(summary = "添加发票视图") + public UserView add(@Validated @RequestBody UserViewAddRequest request) { + return userViewService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), UserViewResourceType.CONTRACT_INVOICE.name()); + } + + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.CONTRACT_INVOICE_READ) + @Operation(summary = "编辑发票视图") + public UserView update(@Validated @RequestBody UserViewUpdateRequest request) { + return userViewService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_INVOICE_READ) + @Operation(summary = "删除发票视图") + public void delete(@PathVariable String id) { + userViewService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/detail/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_INVOICE_READ) + @Operation(summary = "发票视图详情") + public UserViewResponse viewDetail(@PathVariable String id) { + return userViewService.getViewDetail(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), FormKey.INVOICE.getKey()); + } + + + @GetMapping("/list") + @RequiresPermissions(PermissionConstants.CONTRACT_INVOICE_READ) + @Operation(summary = "发票视图列表") + public List queryList() { + return userViewService.list(UserViewResourceType.CONTRACT_INVOICE.name(), SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/fixed/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_INVOICE_READ) + @Operation(summary = "发票固定/取消固定") + public void fixed(@PathVariable String id) { + userViewService.fixed(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/edit/pos") + @Operation(summary = "视图-拖拽排序") + @RequiresPermissions(PermissionConstants.CONTRACT_INVOICE_READ) + public void editPos(@Validated @RequestBody PosRequest request) { + userViewService.editPos(request, SessionUtils.getUserId(), UserViewResourceType.CONTRACT_INVOICE.name()); + } + + + @GetMapping("/enable/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_INVOICE_READ) + @Operation(summary = "视图-启用/禁用") + public void enable(@PathVariable String id) { + userViewService.enable(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractPaymentPlanController.java b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractPaymentPlanController.java new file mode 100644 index 0000000..d89f236 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractPaymentPlanController.java @@ -0,0 +1,150 @@ +package cn.cordys.crm.contract.controller; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ExportDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.CsBatchPermission; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.contract.domain.ContractPaymentPlan; +import cn.cordys.crm.contract.dto.request.ContractPaymentPlanAddRequest; +import cn.cordys.crm.contract.dto.request.ContractPaymentPlanExportRequest; +import cn.cordys.crm.contract.dto.request.ContractPaymentPlanPageRequest; +import cn.cordys.crm.contract.dto.request.ContractPaymentPlanUpdateRequest; +import cn.cordys.crm.contract.dto.response.ContractPaymentPlanGetResponse; +import cn.cordys.crm.contract.dto.response.ContractPaymentPlanListResponse; +import cn.cordys.crm.contract.service.ContractPaymentPlanExportService; +import cn.cordys.crm.contract.service.ContractPaymentPlanService; +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * + * @author jianxing + * @date 2025-11-21 15:11:29 + */ +@Tag(name = "合同回款计划") +@RestController +@RequestMapping("/contract/payment-plan") +public class ContractPaymentPlanController { + @Resource + private ContractPaymentPlanService contractPaymentPlanService; + @Resource + private ContractPaymentPlanExportService contractPaymentPlanExportService; + @Resource + private DataScopeService dataScopeService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + + @GetMapping("/module/form") + @CsPermission(PermissionConstants.CONTRACT_PAYMENT_PLAN_READ) + @Operation(summary = "获取表单配置") + public ModuleFormConfigDTO getModuleFormConfig() { + return moduleFormCacheService.getBusinessFormConfig(FormKey.CONTRACT_PAYMENT_PLAN.getKey(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/page") + @CsPermission(PermissionConstants.CONTRACT_PAYMENT_PLAN_READ) + @Operation(summary = "合同回款计划列表") + public PagerWithOption> list(@Validated @RequestBody ContractPaymentPlanPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTRACT_PAYMENT_PLAN.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_PAYMENT_PLAN_READ); + return contractPaymentPlanService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @GetMapping("/get/{id}") + @CsPermission(value = PermissionConstants.CONTRACT_PAYMENT_PLAN_READ, resourceId = "{#id}", formType = FormKeyConstants.CONTRACT_PAYMENT_PLAN) + @Operation(summary = "合同回款计划详情") + public ContractPaymentPlanGetResponse get(@PathVariable String id){ + return contractPaymentPlanService.get(id); + } + + @PostMapping("/add") + @CsPermission(PermissionConstants.CONTRACT_PAYMENT_PLAN_ADD) + @Operation(summary = "添加合同回款计划") + public ContractPaymentPlan add(@Validated @RequestBody ContractPaymentPlanAddRequest request) { + return contractPaymentPlanService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @CsPermission(value = PermissionConstants.CONTRACT_PAYMENT_PLAN_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.CONTRACT_PAYMENT_PLAN) + @Operation(summary = "更新合同回款计划") + public ContractPaymentPlan update(@Validated @RequestBody ContractPaymentPlanUpdateRequest request) { + return contractPaymentPlanService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/delete/{id}") + @CsPermission(value = PermissionConstants.CONTRACT_PAYMENT_PLAN_DELETE, resourceId = "{#id}", formType = FormKeyConstants.CONTRACT_PAYMENT_PLAN) + @Operation(summary = "删除合同回款计划") + public void delete(@PathVariable String id) { + contractPaymentPlanService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/tab") + @CsPermission(PermissionConstants.CONTRACT_PAYMENT_PLAN_READ) + @Operation(summary = "tab是否显示") + public ResourceTabEnableDTO getTabEnableConfig() { + return contractPaymentPlanService.getTabEnableConfig(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/export-select") + @Operation(summary = "导出选中回款计划") + @CsBatchPermission(value = PermissionConstants.CONTRACT_PAYMENT_PLAN_READ, resourceId = "{#request.ids}", formType = FormKeyConstants.CONTRACT_PAYMENT_PLAN) + public String exportSelect(@Validated @RequestBody ExportSelectRequest request) { + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), PermissionConstants.CONTRACT_PAYMENT_PLAN_READ); + ExportDTO exportDTO = ExportDTO.builder() + .exportType(ExportConstants.ExportType.CONTRACT_PAYMENT_PLAN.name()) + .fileName(request.getFileName()) + .headList(request.getHeadList()) + .logModule(LogModule.CONTRACT_PAYMENT) + .locale(LocaleContextHolder.getLocale()) + .orgId(OrganizationContext.getOrganizationId()) + .userId(SessionUtils.getUserId()) + .deptDataPermission(deptDataPermission) + .selectIds(request.getIds()) + .selectRequest(request) + .build(); + return contractPaymentPlanExportService.exportSelect(exportDTO); + } + + @PostMapping("/export-all") + @Operation(summary = "导出全部回款计划") + @CsPermission(PermissionConstants.CONTRACT_PAYMENT_PLAN_READ) + public String exportAll(@Validated @RequestBody ContractPaymentPlanExportRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTRACT_PAYMENT_PLAN.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_PAYMENT_PLAN_READ); + ExportDTO exportDTO = ExportDTO.builder() + .exportType(ExportConstants.ExportType.CONTRACT_PAYMENT_PLAN.name()) + .fileName(request.getFileName()) + .headList(request.getHeadList()) + .logModule(LogModule.CONTRACT_PAYMENT) + .locale(LocaleContextHolder.getLocale()) + .orgId(OrganizationContext.getOrganizationId()) + .userId(SessionUtils.getUserId()) + .deptDataPermission(deptDataPermission) + .pageRequest(request) + .build(); + return contractPaymentPlanExportService.export(exportDTO); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractPaymentPlanUserViewController.java b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractPaymentPlanUserViewController.java new file mode 100644 index 0000000..11af8d3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractPaymentPlanUserViewController.java @@ -0,0 +1,95 @@ +package cn.cordys.crm.contract.controller; + + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.UserViewResourceType; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import cn.cordys.crm.system.dto.response.UserViewListResponse; +import cn.cordys.crm.system.dto.response.UserViewResponse; +import cn.cordys.crm.system.service.UserViewService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "合同回款计划视图") +@RestController +@RequestMapping("/contract/payment-plan/view") +public class ContractPaymentPlanUserViewController { + + @Resource + private UserViewService userViewService; + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.CONTRACT_PAYMENT_PLAN_READ) + @Operation(summary = "添加回款计划视图") + public UserView add(@Validated @RequestBody UserViewAddRequest request) { + return userViewService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), UserViewResourceType.CONTRACT_PAYMENT_PLAN.name()); + } + + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.CONTRACT_PAYMENT_PLAN_READ) + @Operation(summary = "编辑回款计划视图") + public UserView update(@Validated @RequestBody UserViewUpdateRequest request) { + return userViewService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_PAYMENT_PLAN_READ) + @Operation(summary = "删除回款计划视图") + public void delete(@PathVariable String id) { + userViewService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/detail/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_PAYMENT_PLAN_READ) + @Operation(summary = "回款计划视图详情") + public UserViewResponse viewDetail(@PathVariable String id) { + return userViewService.getViewDetail(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), FormKey.CONTRACT_PAYMENT_PLAN.getKey()); + } + + + @GetMapping("/list") + @RequiresPermissions(PermissionConstants.CONTRACT_PAYMENT_PLAN_READ) + @Operation(summary = "回款计划视图列表") + public List queryList() { + return userViewService.list(UserViewResourceType.CONTRACT_PAYMENT_PLAN.name(), SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/fixed/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_PAYMENT_PLAN_READ) + @Operation(summary = "回款计划视图固定/取消固定") + public void fixed(@PathVariable String id) { + userViewService.fixed(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/edit/pos") + @Operation(summary = "回款计划视图-拖拽排序") + @RequiresPermissions(PermissionConstants.CONTRACT_PAYMENT_PLAN_READ) + public void editPos(@Validated @RequestBody PosRequest request) { + userViewService.editPos(request, SessionUtils.getUserId(), UserViewResourceType.CONTRACT_PAYMENT_PLAN.name()); + } + + + @GetMapping("/enable/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_PAYMENT_PLAN_READ) + @Operation(summary = "回款计划视图-启用/禁用") + public void enable(@PathVariable String id) { + userViewService.enable(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractPaymentRecordController.java b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractPaymentRecordController.java new file mode 100644 index 0000000..09d6667 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractPaymentRecordController.java @@ -0,0 +1,174 @@ +package cn.cordys.crm.contract.controller; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ExportDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.CsBatchPermission; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.contract.domain.ContractPaymentRecord; +import cn.cordys.crm.contract.dto.request.*; +import cn.cordys.crm.contract.dto.response.ContractPaymentRecordGetResponse; +import cn.cordys.crm.contract.dto.response.ContractPaymentRecordResponse; +import cn.cordys.crm.contract.dto.response.ContractPaymentRecordStatisticResponse; +import cn.cordys.crm.contract.service.ContractPaymentRecordExportService; +import cn.cordys.crm.contract.service.ContractPaymentRecordService; +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * @author song-cc-rock + */ +@Tag(name = "合同回款记录") +@RestController +@RequestMapping("/contract/payment-record") +public class ContractPaymentRecordController { + + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private DataScopeService dataScopeService; + @Resource + private ContractPaymentRecordService contractPaymentRecordService; + @Resource + private ContractPaymentRecordExportService contractPaymentRecordExportService; + + @GetMapping("/module/form") + @CsPermission(PermissionConstants.CONTRACT_PAYMENT_RECORD_READ) + @Operation(summary = "获取表单配置") + public ModuleFormConfigDTO getModuleFormConfig() { + return moduleFormCacheService.getBusinessFormConfig(FormKey.CONTRACT_PAYMENT_RECORD.getKey(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/page") + @CsPermission(PermissionConstants.CONTRACT_PAYMENT_RECORD_READ) + @Operation(summary = "回款记录列表") + public PagerWithOption> list(@Validated @RequestBody ContractPaymentRecordPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTRACT_PAYMENT_RECORD.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_PAYMENT_RECORD_READ); + return contractPaymentRecordService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @PostMapping("/add") + @RequiresPermissions(value = {PermissionConstants.CONTRACT_PAYMENT_RECORD_ADD, PermissionConstants.CONTRACT_PAYMENT}, logical = Logical.OR) + @Operation(summary = "添加回款记录") + public ContractPaymentRecord add(@Validated @RequestBody ContractPaymentRecordAddRequest request) { + return contractPaymentRecordService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @CsPermission(value = PermissionConstants.CONTRACT_PAYMENT_RECORD_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.CONTRACT_PAYMENT_RECORD) + @Operation(summary = "修改回款记录") + public ContractPaymentRecord update(@Validated @RequestBody ContractPaymentRecordUpdateRequest request) { + return contractPaymentRecordService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/delete/{id}") + @CsPermission(value = PermissionConstants.CONTRACT_PAYMENT_RECORD_DELETE, resourceId = "{#id}", formType = FormKeyConstants.CONTRACT_PAYMENT_RECORD) + @Operation(summary = "删除回款记录") + public void delete(@PathVariable String id) { + contractPaymentRecordService.delete(id); + } + + @GetMapping("/get/{id}") + @CsPermission(value = PermissionConstants.CONTRACT_PAYMENT_RECORD_READ, resourceId = "{#id}", formType = FormKeyConstants.CONTRACT_PAYMENT_RECORD) + @Operation(summary = "回款记录详情") + public ContractPaymentRecordGetResponse get(@PathVariable String id) { + return contractPaymentRecordService.get(id); + } + + @GetMapping("/tab") + @CsPermission(PermissionConstants.CONTRACT_PAYMENT_RECORD_READ) + @Operation(summary = "视图TAB显示配置") + public ResourceTabEnableDTO getTabEnableConfig() { + return contractPaymentRecordService.getTabEnableConfig(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/template/download") + @CsPermission(PermissionConstants.CONTRACT_PAYMENT_RECORD_IMPORT) + @Operation(summary = "下载导入模板") + public void downloadImportTpl(HttpServletResponse response) { + contractPaymentRecordService.downloadImportTpl(response, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/import/pre-check") + @Operation(summary = "导入检查") + @CsPermission(PermissionConstants.CONTRACT_PAYMENT_RECORD_IMPORT) + public ImportResponse preCheck(@RequestPart(value = "file") MultipartFile file) { + return contractPaymentRecordService.importPreCheck(file, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/import") + @Operation(summary = "导入") + @CsPermission(PermissionConstants.CONTRACT_PAYMENT_RECORD_IMPORT) + public ImportResponse realImport(@RequestPart(value = "file") MultipartFile file) { + return contractPaymentRecordService.realImport(file, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/export-select") + @Operation(summary = "导出选中回款记录") + @CsBatchPermission(value = PermissionConstants.CONTRACT_PAYMENT_RECORD_IMPORT, resourceId = "{#request.ids}", formType = FormKeyConstants.CONTRACT_PAYMENT_RECORD) + public String exportSelect(@Validated @RequestBody ExportSelectRequest request) { + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), PermissionConstants.CONTRACT_PAYMENT_RECORD_READ); + ExportDTO exportDTO = ExportDTO.builder().exportType(ExportConstants.ExportType.CONTRACT_PAYMENT_RECORD.name()) + .fileName(request.getFileName()).headList(request.getHeadList()) + .logModule(LogModule.CONTRACT_PAYMENT_RECORD).locale(LocaleContextHolder.getLocale()) + .orgId(OrganizationContext.getOrganizationId()).userId(SessionUtils.getUserId()) + .deptDataPermission(deptDataPermission).selectIds(request.getIds()) + .selectRequest(request) + .build(); + return contractPaymentRecordExportService.exportSelect(exportDTO); + } + + @PostMapping("/export-all") + @Operation(summary = "导出全部回款记录") + @CsPermission(PermissionConstants.CONTRACT_PAYMENT_RECORD_IMPORT) + public String exportAll(@Validated @RequestBody ContractPaymentRecordExportRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTRACT_PAYMENT_RECORD.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_PAYMENT_RECORD_READ); + ExportDTO exportDTO = ExportDTO.builder().exportType(ExportConstants.ExportType.CONTRACT_PAYMENT_RECORD.name()) + .fileName(request.getFileName()).headList(request.getHeadList()) + .logModule(LogModule.CONTRACT_PAYMENT_RECORD).locale(LocaleContextHolder.getLocale()) + .orgId(OrganizationContext.getOrganizationId()).userId(SessionUtils.getUserId()) + .deptDataPermission(deptDataPermission).pageRequest(request) + .build(); + return contractPaymentRecordExportService.export(exportDTO); + } + + + @PostMapping("/statistic") + @CsPermission(PermissionConstants.CONTRACT_PAYMENT_RECORD_READ) + @Operation(summary = "回款统计") + public ContractPaymentRecordStatisticResponse searchStatistic(@Validated @RequestBody ContractPaymentRecordStatisticRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTRACT_PAYMENT_RECORD.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_PAYMENT_RECORD_READ); + return contractPaymentRecordService.searchStatistic(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractPaymentRecordUserViewController.java b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractPaymentRecordUserViewController.java new file mode 100644 index 0000000..4000ab2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractPaymentRecordUserViewController.java @@ -0,0 +1,97 @@ +package cn.cordys.crm.contract.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.UserViewResourceType; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import cn.cordys.crm.system.dto.response.UserViewListResponse; +import cn.cordys.crm.system.dto.response.UserViewResponse; +import cn.cordys.crm.system.service.UserViewService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * @author song-cc-rock + */ +@Tag(name = "合同回款记录视图") +@RestController +@RequestMapping("/contract/payment-record/view") +public class ContractPaymentRecordUserViewController { + + @Resource + private UserViewService userViewService; + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.CONTRACT_PAYMENT_RECORD_READ) + @Operation(summary = "添加回款记录视图") + public UserView add(@Validated @RequestBody UserViewAddRequest request) { + return userViewService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), UserViewResourceType.CONTRACT_PAYMENT_RECORD.name()); + } + + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.CONTRACT_PAYMENT_RECORD_READ) + @Operation(summary = "编辑回款记录视图") + public UserView update(@Validated @RequestBody UserViewUpdateRequest request) { + return userViewService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_PAYMENT_RECORD_READ) + @Operation(summary = "删除回款记录视图") + public void delete(@PathVariable String id) { + userViewService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/detail/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_PAYMENT_RECORD_READ) + @Operation(summary = "回款记录视图详情") + public UserViewResponse viewDetail(@PathVariable String id) { + return userViewService.getViewDetail(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), FormKey.CONTRACT_PAYMENT_RECORD.getKey()); + } + + + @GetMapping("/list") + @RequiresPermissions(PermissionConstants.CONTRACT_PAYMENT_RECORD_READ) + @Operation(summary = "回款记录视图列表") + public List queryList() { + return userViewService.list(UserViewResourceType.CONTRACT_PAYMENT_RECORD.name(), SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/fixed/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_PAYMENT_RECORD_READ) + @Operation(summary = "回款记录视图固定/取消固定") + public void fixed(@PathVariable String id) { + userViewService.fixed(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/edit/pos") + @Operation(summary = "回款记录视图-拖拽排序") + @RequiresPermissions(PermissionConstants.CONTRACT_PAYMENT_RECORD_READ) + public void editPos(@Validated @RequestBody PosRequest request) { + userViewService.editPos(request, SessionUtils.getUserId(), UserViewResourceType.CONTRACT_PAYMENT_RECORD.name()); + } + + + @GetMapping("/enable/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_PAYMENT_RECORD_READ) + @Operation(summary = "回款记录视图-启用/禁用") + public void enable(@PathVariable String id) { + userViewService.enable(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractStageController.java b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractStageController.java new file mode 100644 index 0000000..b692973 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractStageController.java @@ -0,0 +1,91 @@ +package cn.cordys.crm.contract.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.stage.*; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.contract.service.ContractStageService; +import cn.cordys.crm.system.service.StageAdvancedConfigService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "合同状态流设置") +@RestController +@RequestMapping("/contract/stage") +public class ContractStageController { + + @Resource + private ContractStageService contractStageService; + @Resource + private StageAdvancedConfigService stageAdvancedConfigService; + + @GetMapping("/get") + @Operation(summary = "合同状态配置列表") + public StageConfigsResponse getStageConfigList() { + return contractStageService.getStageConfigList(OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/add") + @Operation(summary = "添加合同状态流") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public String add(@RequestBody StageAddRequest request) { + return contractStageService.addStageConfig(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @Operation(summary = "删除合同状态流") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void delete(@PathVariable("id") @Validated String id) { + contractStageService.delete(id); + } + + + @PostMapping("/update-rollback") + @Operation(summary = "合同状态流回退设置") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void update(@Validated @RequestBody StageRollBackRequest request) { + contractStageService.updateRollBack(request, OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/update") + @Operation(summary = "更新合同阶段配置") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void update(@Validated @RequestBody StageUpdateRequest request) { + contractStageService.update(request, SessionUtils.getUserId()); + } + + + @PostMapping("/sort") + @Operation(summary = "合同阶段排序") + @RequiresPermissions(PermissionConstants.MODULE_SETTING_UPDATE) + public void sort(@RequestBody List ids) { + contractStageService.sort(ids, OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/circulation-type/{type}") + @Operation(summary = "基础/高级流转切换") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void circulationType(@PathVariable String type) { + stageAdvancedConfigService.switchType(type, FormKey.CONTRACT.getKey(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/advanced/config") + @Operation(summary = "合同流转配置保存") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void advancedConfigAdd(@RequestBody StageAdvancedConfigRequest request) { + stageAdvancedConfigService.saveAdvancedConfig(request, FormKey.CONTRACT.getKey(), OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } +} + diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractUserViewController.java b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractUserViewController.java new file mode 100644 index 0000000..5fd32bb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/controller/ContractUserViewController.java @@ -0,0 +1,93 @@ +package cn.cordys.crm.contract.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.UserViewResourceType; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import cn.cordys.crm.system.dto.response.UserViewListResponse; +import cn.cordys.crm.system.dto.response.UserViewResponse; +import cn.cordys.crm.system.service.UserViewService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "合同视图") +@RestController +@RequestMapping("/contract/view") +public class ContractUserViewController { + + @Resource + private UserViewService userViewService; + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.CONTRACT_READ) + @Operation(summary = "添加合同视图") + public UserView add(@Validated @RequestBody UserViewAddRequest request) { + return userViewService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), UserViewResourceType.CONTRACT.name()); + } + + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.CONTRACT_READ) + @Operation(summary = "编辑合同视图") + public UserView update(@Validated @RequestBody UserViewUpdateRequest request) { + return userViewService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_READ) + @Operation(summary = "删除合同视图") + public void delete(@PathVariable String id) { + userViewService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/detail/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_READ) + @Operation(summary = "合同视图详情") + public UserViewResponse viewDetail(@PathVariable String id) { + return userViewService.getViewDetail(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), FormKey.CONTRACT.getKey()); + } + + + @GetMapping("/list") + @RequiresPermissions(PermissionConstants.CONTRACT_READ) + @Operation(summary = "合同视图列表") + public List queryList() { + return userViewService.list(UserViewResourceType.CONTRACT.name(), SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/fixed/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_READ) + @Operation(summary = "视图固定/取消固定") + public void fixed(@PathVariable String id) { + userViewService.fixed(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/edit/pos") + @Operation(summary = "视图-拖拽排序") + @RequiresPermissions(PermissionConstants.CONTRACT_READ) + public void editPos(@Validated @RequestBody PosRequest request) { + userViewService.editPos(request, SessionUtils.getUserId(), UserViewResourceType.CONTRACT.name()); + } + + + @GetMapping("/enable/{id}") + @RequiresPermissions(PermissionConstants.CONTRACT_READ) + @Operation(summary = "视图-启用/禁用") + public void enable(@PathVariable String id) { + userViewService.enable(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/BusinessTitle.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/BusinessTitle.java new file mode 100644 index 0000000..2e3e930 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/BusinessTitle.java @@ -0,0 +1,69 @@ +package cn.cordys.crm.contract.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "business_title") +public class BusinessTitle extends BaseModel { + + @Schema(description = "公司名称") + private String name; + + @Schema(description = "来源类型") + private String type; + + @Schema(description = "纳税人识别号") + private String identificationNumber; + + @Schema(description = "开户银行") + private String openingBank; + + @Schema(description = "银行账户") + private String bankAccount; + + @Schema(description = "注册地址") + private String registrationAddress; + + @Schema(description = "注册电话") + private String phoneNumber; + + @Schema(description = "注册资本") + private String registeredCapital; + + @Schema(description = "公司规模") + private String companySize; + + @Schema(description = "工商注册账号") + private String registrationNumber; + + @Schema(description = "审核状态") + private String approvalStatus; + + @Schema(description = "不通过原因") + private String unapprovedReason; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "省") + private String province; + + @Schema(description = "市") + private String city; + + @Schema(description = "企业规模") + private String scale; + + @Schema(description = "国标行业") + private String industry; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "公司编号") + private String companyNumber; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/BusinessTitleConfig.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/BusinessTitleConfig.java new file mode 100644 index 0000000..1385715 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/BusinessTitleConfig.java @@ -0,0 +1,28 @@ +package cn.cordys.crm.contract.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +@Data +@Table(name = "business_title_config") +public class BusinessTitleConfig implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "字段") + private String field; + + @Schema(description = "是否必填") + private Boolean required; + + @Schema(description = "组织id") + private String organizationId; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/Contract.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/Contract.java new file mode 100644 index 0000000..c915144 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/Contract.java @@ -0,0 +1,52 @@ +package cn.cordys.crm.contract.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@Table(name = "contract") +public class Contract extends BaseModel { + + @Schema(description = "合同名称") + private String name; + + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "合同负责人") + private String owner; + + @Schema(description = "金额") + private BigDecimal amount; + + @Schema(description = "编号") + private String number; + + @Schema(description = "审核状态") + private String approvalStatus; + + @Schema(description = "合同阶段") + private String stage; + + @Schema(description = "合同开始时间") + private Long startTime; + + @Schema(description = "合同结束时间") + private Long endTime; + + @Schema(description = "作废原因") + private String voidReason; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "自定义排序") + private Long pos; + + @Schema(description = "是否审批通过过") + private Boolean approved; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractField.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractField.java new file mode 100644 index 0000000..5bd56db --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractField.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.contract.domain; + +import cn.cordys.common.domain.BaseResourceSubField; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "contract_field") +public class ContractField extends BaseResourceSubField { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractFieldBlob.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractFieldBlob.java new file mode 100644 index 0000000..549bb82 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractFieldBlob.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.contract.domain; + +import cn.cordys.common.domain.BaseResourceSubField; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "contract_field_blob") +public class ContractFieldBlob extends BaseResourceSubField { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractInvoice.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractInvoice.java new file mode 100644 index 0000000..c776993 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractInvoice.java @@ -0,0 +1,49 @@ +package cn.cordys.crm.contract.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 发票 + * + * @author jianxing + * @date 2025-12-29 18:22:59 + */ +@Data +@Table(name = "contract_invoice") +public class ContractInvoice extends BaseModel { + + @Schema(description = "发票名称") + private String name; + + @Schema(description = "合同id") + private String contractId; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "开票金额") + private BigDecimal amount; + + @Schema(description = "发票类型") + private String invoiceType; + + @Schema(description = "税率") + private BigDecimal taxRate; + + @Schema(description = "工商抬头") + private String businessTitleId; + + @Schema(description = "审核状态") + private String approvalStatus; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "是否审批通过过") + private Boolean approved; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractInvoiceField.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractInvoiceField.java new file mode 100644 index 0000000..a01165e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractInvoiceField.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.contract.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "contract_invoice_field") +public class ContractInvoiceField extends BaseResourceField { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractInvoiceFieldBlob.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractInvoiceFieldBlob.java new file mode 100644 index 0000000..36e5000 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractInvoiceFieldBlob.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.contract.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "contract_invoice_field_blob") +public class ContractInvoiceFieldBlob extends BaseResourceField { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractInvoiceSnapshot.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractInvoiceSnapshot.java new file mode 100644 index 0000000..70ea662 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractInvoiceSnapshot.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.contract.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + + +@Data +@Table(name = "contract_invoice_snapshot") +public class ContractInvoiceSnapshot implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "发票id") + private String invoiceId; + + @Schema(description = "表单属性快照") + private String invoiceProp; + + @Schema(description = "表单值快照") + private String invoiceValue; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentPlan.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentPlan.java new file mode 100644 index 0000000..f6bdfc7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentPlan.java @@ -0,0 +1,40 @@ +package cn.cordys.crm.contract.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 合同回款计划 + * + * @author jianxing + * @date 2025-11-21 15:11:29 + */ +@Data +@Table(name = "contract_payment_plan") +public class ContractPaymentPlan extends BaseModel { + + @Schema(description = "回款计划名称") + private String name; + + @Schema(description = "合同ID") + private String contractId; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "计划状态") + private String planStatus; + + @Schema(description = "计划回款金额") + private BigDecimal planAmount; + + @Schema(description = "计划回款时间") + private Long planEndTime; + + @Schema(description = "组织id") + private String organizationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentPlanField.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentPlanField.java new file mode 100644 index 0000000..24d4da1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentPlanField.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.contract.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 合同回款自定义属性 + * + * @author jianxing + * @date 2025-02-10 18:12:46 + */ +@Data +@Table(name = "contract_payment_plan_field") +public class ContractPaymentPlanField extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentPlanFieldBlob.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentPlanFieldBlob.java new file mode 100644 index 0000000..5c1fe72 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentPlanFieldBlob.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.contract.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 合同回款自定义属性大文本 + * + * @author jianxing + * @date 2025-02-27 14:43:46 + */ +@Data +@Table(name = "contract_payment_plan_field_blob") +public class ContractPaymentPlanFieldBlob extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentRecord.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentRecord.java new file mode 100644 index 0000000..58ee565 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentRecord.java @@ -0,0 +1,42 @@ +package cn.cordys.crm.contract.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 合同回款记录 + * @author song-cc-rock + */ + +@Data +@Table(name = "contract_payment_record") +public class ContractPaymentRecord extends BaseModel { + + @Schema(description = "回款记录名称") + private String name; + + @Schema(description = "回款编号") + private String no; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "合同ID") + private String contractId; + + @Schema(description = "回款计划ID") + private String paymentPlanId; + + @Schema(description = "回款金额") + private BigDecimal recordAmount; + + @Schema(description = "回款时间") + private Long recordEndTime; + + @Schema(description = "组织id") + private String organizationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentRecordField.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentRecordField.java new file mode 100644 index 0000000..38be3c3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentRecordField.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.contract.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * 合同回款记录自定义属性表 + * @author song-cc-rock + */ + +@Data +@Table(name = "contract_payment_record_field") +public class ContractPaymentRecordField extends BaseResourceField { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentRecordFieldBlob.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentRecordFieldBlob.java new file mode 100644 index 0000000..651f651 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractPaymentRecordFieldBlob.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.contract.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * 合同回款记录自定义属性大文本 + * @author song-cc-rock + */ +@Data +@Table(name = "contract_payment_record_field_blob") +public class ContractPaymentRecordFieldBlob extends BaseResourceField { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractSnapshot.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractSnapshot.java new file mode 100644 index 0000000..91e8d99 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractSnapshot.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.contract.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + + +@Data +@Table(name = "contract_snapshot") +public class ContractSnapshot implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "合同id") + private String contractId; + + @Schema(description = "表单属性快照") + private String contractProp; + + @Schema(description = "表单值快照") + private String contractValue; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractStageConfig.java b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractStageConfig.java new file mode 100644 index 0000000..a4b3c55 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/domain/ContractStageConfig.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.contract.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "contract_stage_config") +public class ContractStageConfig extends BaseModel { + + @Schema(description = "订单状态") + private String name; + + @Schema(description = "订单类型") + private String type; + + @Schema(description = "进行中回退设置") + private Boolean afootRollBack; + + @Schema(description = "完结回退设置") + private Boolean endRollBack; + + @Schema(description = "顺序") + private Long pos; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "流转类型(NORMAL-普通,ADVANCED-高级)") + private String circulationType; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitleAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitleAddRequest.java new file mode 100644 index 0000000..a79854e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitleAddRequest.java @@ -0,0 +1,68 @@ +package cn.cordys.crm.contract.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class BusinessTitleAddRequest { + + @Size(max = 255) + @Schema(description = "公司名称") + private String name; + + @Size(max = 255) + @Schema(description = "纳税人识别号") + private String identificationNumber; + + @Size(max = 255) + @Schema(description = "开户银行") + private String openingBank; + + @Size(max = 255) + @Schema(description = "银行账户") + private String bankAccount; + + @Size(max = 255) + @Schema(description = "注册地址") + private String registrationAddress; + + @Size(max = 255) + @Schema(description = "注册电话") + private String phoneNumber; + + @Schema(description = "注册资本") + private String registeredCapital; + + @Size(max = 255) + @Schema(description = "公司规模") + private String companySize; + + @Size(max = 255) + @Schema(description = "工商注册账号") + private String registrationNumber; + + @Size(max = 50) + @Schema(description = "来源类型(自定义(CUSTOM)/三方(THIRD_PARTY))") + private String type; + + @Size(max = 255) + @Schema(description = "省") + private String province; + + @Size(max = 255) + @Schema(description = "城") + private String city; + + @Size(max = 255) + @Schema(description = "企业规模") + private String scale; + + @Size(max = 255) + @Schema(description = "国标行业") + private String industry; + + @Size(max = 255) + @Schema(description = "备注") + private String remark; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitleApprovalRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitleApprovalRequest.java new file mode 100644 index 0000000..a5b4fdf --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitleApprovalRequest.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.contract.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class BusinessTitleApprovalRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + + @NotBlank + @Schema(description = "审核状态", requiredMode = Schema.RequiredMode.REQUIRED) + private String approvalStatus; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitleExportRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitleExportRequest.java new file mode 100644 index 0000000..5c118f8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitleExportRequest.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.dto.ExportHeadDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class BusinessTitleExportRequest extends BusinessTitlePageRequest{ + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "表头信息") + @NotEmpty(message = "{export_head_list_is_empty}") + private List headList; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitleImportRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitleImportRequest.java new file mode 100644 index 0000000..3ad5b67 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitleImportRequest.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.contract.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class BusinessTitleImportRequest { + + @NotBlank + @Schema(description = "导入类型") + private String importType; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitlePageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitlePageRequest.java new file mode 100644 index 0000000..1eb8d7a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitlePageRequest.java @@ -0,0 +1,8 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import lombok.Data; + +@Data +public class BusinessTitlePageRequest extends BasePageRequest { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitleUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitleUpdateRequest.java new file mode 100644 index 0000000..30e2b06 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/BusinessTitleUpdateRequest.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.contract.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class BusinessTitleUpdateRequest extends BusinessTitleAddRequest{ + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 32) + private String id; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractAddRequest.java new file mode 100644 index 0000000..5969987 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractAddRequest.java @@ -0,0 +1,52 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class ContractAddRequest { + + @NotBlank(message = "{contract.name.required}") + @Size(max = 255) + @Schema(description = "合同名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @NotBlank(message = "{contract.customer.required}") + @Size(max = 32) + @Schema(description = "客户id", requiredMode = Schema.RequiredMode.REQUIRED) + private String customerId; + + @NotBlank(message = "{owner.required}") + @Size(max = 32) + @Schema(description = "负责人", requiredMode = Schema.RequiredMode.REQUIRED) + private String owner; + + @Schema(description = "累计金额") + private String amount; + + @Schema(description = "合同开始时间", requiredMode = Schema.RequiredMode.REQUIRED) + private Long startTime; + + @Schema(description = "合同结束时间", requiredMode = Schema.RequiredMode.REQUIRED) + private Long endTime; + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "表单配置") + private ModuleFormConfigDTO moduleFormConfigDTO; + + @Schema(description = "子产品信息") + private List> products; + + @Schema(description = "编号") + private String number; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractApprovalBatchRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractApprovalBatchRequest.java new file mode 100644 index 0000000..49f8155 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractApprovalBatchRequest.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.contract.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class ContractApprovalBatchRequest { + + @NotEmpty + @Schema(description = "ID集合") + private List ids; + + @NotBlank + @Schema(description = "状态") + private String approvalStatus; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractApprovalRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractApprovalRequest.java new file mode 100644 index 0000000..dc39696 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractApprovalRequest.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.contract.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ContractApprovalRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + + @NotBlank + @Schema(description = "审核状态", requiredMode = Schema.RequiredMode.REQUIRED) + private String approvalStatus; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractArchivedRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractArchivedRequest.java new file mode 100644 index 0000000..9878bc0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractArchivedRequest.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.contract.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ContractArchivedRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + + @NotBlank + @Schema(description = "归档状态", requiredMode = Schema.RequiredMode.REQUIRED) + private String archivedStatus; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractExportRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractExportRequest.java new file mode 100644 index 0000000..9758325 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractExportRequest.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.dto.ExportHeadDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class ContractExportRequest extends ContractPageRequest { + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "表头信息") + @NotEmpty(message = "{export_head_list_is_empty}") + private List headList; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractInvoiceAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractInvoiceAddRequest.java new file mode 100644 index 0000000..179f6c8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractInvoiceAddRequest.java @@ -0,0 +1,50 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * + * @author jianxing + * @date 2025-12-29 18:22:59 + */ +@Data +public class ContractInvoiceAddRequest { + @Size(max = 255) + @NotBlank + @Schema(description = "发票名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Size(max = 32) + @NotBlank + @Schema(description = "合同id", requiredMode = Schema.RequiredMode.REQUIRED) + private String contractId; + + @Size(max = 32) + @NotBlank + @Schema(description = "负责人", requiredMode = Schema.RequiredMode.REQUIRED) + private String owner; + + private BigDecimal amount; + + @Size(max = 50) + private String invoiceType; + + private BigDecimal taxRate; + + @Size(max = 50) + private String businessTitleId; + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "表单配置") + private ModuleFormConfigDTO moduleFormConfigDTO; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractInvoiceApprovalRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractInvoiceApprovalRequest.java new file mode 100644 index 0000000..888db3c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractInvoiceApprovalRequest.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.contract.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ContractInvoiceApprovalRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + + @NotBlank + @Schema(description = "审核状态", requiredMode = Schema.RequiredMode.REQUIRED) + private String approvalStatus; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractInvoiceExportRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractInvoiceExportRequest.java new file mode 100644 index 0000000..26e2834 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractInvoiceExportRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.dto.ExportHeadDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class ContractInvoiceExportRequest extends ContractInvoicePageRequest { + + @Schema(description = "合同ID") + private String contractId; + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "表头信息") + @NotEmpty(message = "{export_head_list_is_empty}") + private List headList; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractInvoicePageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractInvoicePageRequest.java new file mode 100644 index 0000000..a67f6ee --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractInvoicePageRequest.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import lombok.Data; + +/** + * + * @author jianxing + * @date 2025-12-29 18:22:59 + */ +@Data +public class ContractInvoicePageRequest extends BasePageRequest { + public String getContractId() {return null;} + public String getCustomerId() {return null;} +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractInvoiceUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractInvoiceUpdateRequest.java new file mode 100644 index 0000000..1ebce18 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractInvoiceUpdateRequest.java @@ -0,0 +1,59 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * + * @author jianxing + * @date 2025-12-29 18:22:59 + */ +@Data +public class ContractInvoiceUpdateRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 32) + private String id; + + @Size(max = 255) + @Schema(description = "发票名称") + private String name; + + @Schema(description = "合同id") + private String contractId; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "开票金额") + private BigDecimal amount; + + @Schema(description = "发票类型") + private String invoiceType; + + @Schema(description = "税率") + private BigDecimal taxRate; + + @Schema(description = "工商抬头") + private String businessTitleId; + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "表单配置") + private ModuleFormConfigDTO moduleFormConfigDTO; + + @Schema(description = "是否提审更新 normal-正常更新 approval-评审更新") + private String updateType; + + @Schema(description = "变更说明") + private String comment; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPageRequest.java new file mode 100644 index 0000000..1343f0f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPageRequest.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +@Data +public class ContractPageRequest extends BasePageRequest { + + @Schema(description = "看板模式") + private Boolean board = false; + public String getCustomerId() { + return null; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentPlanAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentPlanAddRequest.java new file mode 100644 index 0000000..e091b75 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentPlanAddRequest.java @@ -0,0 +1,48 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.contract.constants.ContractPaymentPlanStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * + * @author jianxing + * @date 2025-11-21 15:11:29 + */ +@Data +public class ContractPaymentPlanAddRequest { + + @NotBlank + @Size(max = 255) + @Schema(description = "回款计划名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Size(max = 32) + @NotBlank + @Schema(description = "合同ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String contractId; + + @Size(max = 32) + @NotBlank + @Schema(description = "负责人", requiredMode = Schema.RequiredMode.REQUIRED) + private String owner; + + @Size(max = 32) + @Schema(description = "计划状态", requiredMode = Schema.RequiredMode.REQUIRED) + @EnumValue(enumClass = ContractPaymentPlanStatus.class) + private String planStatus; + + private BigDecimal planAmount; + + private Long planEndTime; + + @Schema(description = "模块字段值") + private List moduleFields; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentPlanExportRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentPlanExportRequest.java new file mode 100644 index 0000000..f300c51 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentPlanExportRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.dto.ExportHeadDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class ContractPaymentPlanExportRequest extends ContractPaymentPlanPageRequest { + + @Schema(description = "合同ID") + private String contractId; + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "表头信息") + @NotEmpty(message = "{export_head_list_is_empty}") + private List headList; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentPlanPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentPlanPageRequest.java new file mode 100644 index 0000000..81827d7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentPlanPageRequest.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import lombok.Data; + + +/** + * + * @author jianxing + * @date 2025-11-21 15:11:29 + */ +@Data +public class ContractPaymentPlanPageRequest extends BasePageRequest { + + public String getContractId() {return null;} + + public String getCustomerId() { + return null; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentPlanUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentPlanUpdateRequest.java new file mode 100644 index 0000000..698a288 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentPlanUpdateRequest.java @@ -0,0 +1,51 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.contract.constants.ContractPaymentPlanStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * + * @author jianxing + * @date 2025-11-21 15:11:29 + */ +@Data +public class ContractPaymentPlanUpdateRequest { + + @Size(max = 255) + private String name; + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 32) + private String id; + + @Schema(description = "合同ID") + private String contractId; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "计划状态") + @EnumValue(enumClass = ContractPaymentPlanStatus.class) + private String planStatus; + + @Schema(description = "计划回款金额") + private BigDecimal planAmount; + + @Schema(description = "计划回款时间") + private Long planEndTime; + + @Schema(description = "模块字段值") + private List moduleFields; + + @Schema(description = "是否是AI调用") + private Boolean agentInvoke = false; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentRecordAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentRecordAddRequest.java new file mode 100644 index 0000000..af9d4a5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentRecordAddRequest.java @@ -0,0 +1,48 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * @author song-cc-rock + */ +@Data +public class ContractPaymentRecordAddRequest { + + @NotBlank + @Size(max = 255) + @Schema(description = "回款记录名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @NotBlank + @Size(max = 32) + @Schema(description = "负责人", requiredMode = Schema.RequiredMode.REQUIRED) + private String owner; + + @Schema(description = "回款编号") + private String no; + + @NotBlank + @Size(max = 32) + @Schema(description = "合同ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String contractId; + + @Size(max = 32) + @Schema(description = "回款计划ID") + private String paymentPlanId; + + @Schema(description = "回款金额") + private BigDecimal recordAmount; + + @Schema(description = "回款时间") + private Long recordEndTime; + + @Schema(description = "自定义字段值") + private List moduleFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentRecordExportRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentRecordExportRequest.java new file mode 100644 index 0000000..8cc8d86 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentRecordExportRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.dto.ExportHeadDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class ContractPaymentRecordExportRequest extends ContractPaymentRecordPageRequest { + + @Schema(description = "合同ID") + private String contractId; + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "表头信息") + @NotEmpty(message = "{export_head_list_is_empty}") + private List headList; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentRecordPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentRecordPageRequest.java new file mode 100644 index 0000000..7d91014 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentRecordPageRequest.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * @author song-cc-rock + */ +@Data +public class ContractPaymentRecordPageRequest extends BasePageRequest { + + @Schema(description = "合同ID") + private String contractId; + + @Schema(description = "客户ID") + private String customerId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentRecordStatisticRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentRecordStatisticRequest.java new file mode 100644 index 0000000..68baba5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentRecordStatisticRequest.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.dto.condition.BaseCondition; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class ContractPaymentRecordStatisticRequest extends BaseCondition { + + @Schema(description = "合同id") + private String contractId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentRecordUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentRecordUpdateRequest.java new file mode 100644 index 0000000..0ef7a32 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractPaymentRecordUpdateRequest.java @@ -0,0 +1,50 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * @author song-cc-rock + */ +@Data +public class ContractPaymentRecordUpdateRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 32) + private String id; + + @NotBlank + @Size(max = 255) + @Schema(description = "回款记录名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @NotBlank + @Size(max = 32) + @Schema(description = "负责人", requiredMode = Schema.RequiredMode.REQUIRED) + private String owner; + + @NotBlank + @Size(max = 32) + @Schema(description = "合同ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String contractId; + + @Size(max = 32) + @Schema(description = "回款计划ID") + private String paymentPlanId; + + @Schema(description = "回款金额") + private BigDecimal recordAmount; + + @Schema(description = "回款时间") + private Long recordEndTime; + + @Schema(description = "自定义字段值") + private List moduleFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractStageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractStageRequest.java new file mode 100644 index 0000000..84b4d3c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractStageRequest.java @@ -0,0 +1,30 @@ +package cn.cordys.crm.contract.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class ContractStageRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 32) + private String id; + + + @Schema(description = "阶段: PENDING_SIGNING/SIGNED/CHANGE/IN_PROGRESS/COMPLETED_PERFORMANCE/VOID/ARCHIVED", requiredMode = Schema.RequiredMode.REQUIRED) + private String stage; + + + @Size(max = 255) + @Schema(description = "作废原因") + private String voidReason; + + @Schema(description = "更新字段") + private List fields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractUpdateRequest.java new file mode 100644 index 0000000..5c55278 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractUpdateRequest.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.contract.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class ContractUpdateRequest extends ContractAddRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 32) + private String id; + + @Schema(description = "是否提审更新 normal-正常更新 approval-评审更新") + private String updateType; + + @Schema(description = "变更说明") + private String comment; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractVoidRequest.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractVoidRequest.java new file mode 100644 index 0000000..603d529 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/request/ContractVoidRequest.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.contract.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class ContractVoidRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + + @Size(max = 255) + @Schema(description = "作废原因") + private String voidReason; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/BusinessTitleListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/BusinessTitleListResponse.java new file mode 100644 index 0000000..26feabe --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/BusinessTitleListResponse.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.contract.dto.response; + +import cn.cordys.crm.contract.domain.BusinessTitle; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class BusinessTitleListResponse extends BusinessTitle { + + @Schema(description = "创建人名称") + private String createUserName; + @Schema(description = "修改人名称") + private String updateUserName; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractGetResponse.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractGetResponse.java new file mode 100644 index 0000000..0898574 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractGetResponse.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.contract.dto.response; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class ContractGetResponse extends ContractListResponse { + + @Schema(description = "选项集合") + private Map> optionMap; + + @Schema(description = "附件集合") + private Map> attachmentMap; + + @Schema(description = "产品子列表") + private List> products; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractInvoiceGetResponse.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractInvoiceGetResponse.java new file mode 100644 index 0000000..818952d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractInvoiceGetResponse.java @@ -0,0 +1,52 @@ +package cn.cordys.crm.contract.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.contract.domain.ContractInvoice; +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * + * @author jianxing + * @date 2025-12-29 18:22:59 + */ +@Data +public class ContractInvoiceGetResponse extends ContractInvoice { + @Schema(description = "合同名称") + private String contractName; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "部门id") + private String departmentId; + + @Schema(description = "部门名称") + private String departmentName; + + @Schema(description = "工商抬头名称") + private String businessTitleName; + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "选项集合") + private Map> optionMap; + + @Schema(description = "附件集合") + private Map> attachmentMap; + + @Schema(description = "审批第一个节点是否通过") + private Boolean firstApproved; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractInvoiceListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractInvoiceListResponse.java new file mode 100644 index 0000000..2c38653 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractInvoiceListResponse.java @@ -0,0 +1,48 @@ +package cn.cordys.crm.contract.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.contract.domain.ContractInvoice; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * + * @author jianxing + * @date 2025-12-29 18:22:59 + */ +@Data +public class ContractInvoiceListResponse extends ContractInvoice { + + @Schema(description = "合同名称") + private String contractName; + + @Schema(description = "合同金额") + private BigDecimal contractAmount; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "部门id") + private String departmentId; + + @Schema(description = "部门名称") + private String departmentName; + + @Schema(description = "工商抬头名称") + private String businessTitleName; + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "审批第一个节点是否通过") + private Boolean firstApproved; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractListResponse.java new file mode 100644 index 0000000..10338f5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractListResponse.java @@ -0,0 +1,49 @@ +package cn.cordys.crm.contract.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.contract.domain.Contract; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +@Data +public class ContractListResponse extends Contract { + + @Schema(description = "客户名称") + private String customerName; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "关联的客户是否在公海") + private Boolean inCustomerPool; + + @Schema(description = "客户公海id") + private String poolId; + + @Schema(description = "部门id") + private String departmentId; + + @Schema(description = "部门名称") + private String departmentName; + + @Schema(description = "已回款金额") + private BigDecimal alreadyPayAmount; + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "商机阶段") + private String stageName; + + @Schema(description = "审批第一个节点是否通过") + private Boolean firstApproved; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractPaymentPlanGetResponse.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractPaymentPlanGetResponse.java new file mode 100644 index 0000000..8b05dc4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractPaymentPlanGetResponse.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.contract.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.contract.domain.ContractPaymentPlan; +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * + * @author jianxing + * @date 2025-11-21 15:11:29 + */ +@Data +public class ContractPaymentPlanGetResponse extends ContractPaymentPlan { + @Schema(description = "合同名称") + private String contractName; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "归属部门") + private String departmentId; + + @Schema(description = "归属部门名称") + private String departmentName; + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "选项集合") + private Map> optionMap; + + @Schema(description = "附件集合") + private Map> attachmentMap; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractPaymentPlanListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractPaymentPlanListResponse.java new file mode 100644 index 0000000..3406352 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractPaymentPlanListResponse.java @@ -0,0 +1,37 @@ +package cn.cordys.crm.contract.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.contract.domain.ContractPaymentPlan; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * + * @author jianxing + * @date 2025-11-21 15:11:29 + */ +@Data +public class ContractPaymentPlanListResponse extends ContractPaymentPlan { + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "归属部门") + private String departmentId; + + @Schema(description = "归属部门名称") + private String departmentName; + + @Schema(description = "合同名称") + private String contractName; + + @Schema(description = "自定义字段集合") + private List moduleFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractPaymentRecordGetResponse.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractPaymentRecordGetResponse.java new file mode 100644 index 0000000..e949087 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractPaymentRecordGetResponse.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.contract.dto.response; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * @author song-cc-rock + */ +@Data +public class ContractPaymentRecordGetResponse extends ContractPaymentRecordResponse { + + @Schema(description = "选项集合") + private Map> optionMap; + + @Schema(description = "附件集合") + private Map> attachmentMap; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractPaymentRecordResponse.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractPaymentRecordResponse.java new file mode 100644 index 0000000..6919bbd --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractPaymentRecordResponse.java @@ -0,0 +1,39 @@ +package cn.cordys.crm.contract.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.contract.domain.ContractPaymentRecord; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * @author song-cc-rock + */ +@Data +public class ContractPaymentRecordResponse extends ContractPaymentRecord { + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "合同名称") + private String contractName; + + @Schema(description = "回款计划名称") + private String paymentPlanName; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "归属部门") + private String departmentId; + + @Schema(description = "归属部门名称") + private String departmentName; + + @Schema(description = "自定义字段集合") + private List moduleFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractPaymentRecordStatisticResponse.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractPaymentRecordStatisticResponse.java new file mode 100644 index 0000000..d20b4cc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractPaymentRecordStatisticResponse.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.contract.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class ContractPaymentRecordStatisticResponse { + + @Schema(description = "总金额") + private Double amount = 0.0; + + @Schema(description = "平均金额") + private Double averageAmount = 0.0; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractStatisticResponse.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractStatisticResponse.java new file mode 100644 index 0000000..5105909 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/ContractStatisticResponse.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.contract.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class ContractStatisticResponse { + + @Schema(description = "总金额") + private Double amount = 0.0; + + @Schema(description = "平均金额") + private Double averageAmount = 0.0; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/CustomerContractStatisticResponse.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/CustomerContractStatisticResponse.java new file mode 100644 index 0000000..3d078ad --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/CustomerContractStatisticResponse.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.contract.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class CustomerContractStatisticResponse { + @Schema(description = "合同总金额") + private BigDecimal totalAmount; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/CustomerInvoiceStatisticResponse.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/CustomerInvoiceStatisticResponse.java new file mode 100644 index 0000000..c27056b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/CustomerInvoiceStatisticResponse.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.contract.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class CustomerInvoiceStatisticResponse { + @Schema(description = "合同金额") + private BigDecimal contractAmount; + @Schema(description = "未开票金额") + private BigDecimal uninvoicedAmount; + @Schema(description = "已开票金额") + private BigDecimal invoicedAmount; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/CustomerPaymentPlanStatisticResponse.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/CustomerPaymentPlanStatisticResponse.java new file mode 100644 index 0000000..735fa2f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/CustomerPaymentPlanStatisticResponse.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.contract.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class CustomerPaymentPlanStatisticResponse { + @Schema(description = "计划回款总金额") + private BigDecimal totalPlanAmount; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/CustomerPaymentRecordStatisticResponse.java b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/CustomerPaymentRecordStatisticResponse.java new file mode 100644 index 0000000..99cb19d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/dto/response/CustomerPaymentRecordStatisticResponse.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.contract.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * @author song-cc-rock + */ +@Data +public class CustomerPaymentRecordStatisticResponse { + + @Schema(description = "应回款") + private BigDecimal totalAmount; + @Schema(description = "已回款") + private BigDecimal receivedAmount; + @Schema(description = "待回款") + private BigDecimal pendingAmount; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/excel/constants/BusinessTitleImportFiled.java b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/constants/BusinessTitleImportFiled.java new file mode 100644 index 0000000..718c26d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/constants/BusinessTitleImportFiled.java @@ -0,0 +1,64 @@ +package cn.cordys.crm.contract.excel.constants; + + +import cn.cordys.crm.contract.excel.domain.BusinessTitleExcelData; +import lombok.Getter; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; + + +public enum BusinessTitleImportFiled { + + NAME("name", "公司名称", "Name", BusinessTitleExcelData::getName), + IDENTIFICATION_NUMBER("identificationNumber", "纳税人识别号", "Identification number", BusinessTitleExcelData::getIdentificationNumber), + OPENING_BANK("openingBank", "开户银行", "Opening bank", BusinessTitleExcelData::getOpeningBank), + BANK_ACCOUNT("bankAccount", "银行账户", "Bank account", BusinessTitleExcelData::getBankAccount), + REGISTRATION_ADDRESS("registrationAddress", "注册地址", "Registration address", BusinessTitleExcelData::getRegistrationAddress), + PHONE_NUMBER("phoneNumber", "注册电话", "Phone number", BusinessTitleExcelData::getPhoneNumber), + REGISTERED_CAPITAL("registeredCapital", "注册资本", "Registered capital", BusinessTitleExcelData::getRegisteredCapital), + COMPANY_SIZE("companySize", "公司规模", "Customer size", BusinessTitleExcelData::getCompanySize), + REGISTRATION_NUMBER("registrationNumber", "工商注册账号", "Registration number", BusinessTitleExcelData::getRegistrationNumber), + PROVINCE("province", "省", "Province", BusinessTitleExcelData::getProvince), + CITY("city", "市", "City", BusinessTitleExcelData::getCity), + SCALE("scale", "企业规模", "Scale", BusinessTitleExcelData::getScale), + INDUSTRY("industry", "国标行业", "Industry", BusinessTitleExcelData::getIndustry), + REMARK("remark", "备注", "Remark", BusinessTitleExcelData::getRemark); + + @Getter + private final Map filedLangMap; + private final Function parseFunc; + @Getter + private final String value; + + BusinessTitleImportFiled(String value, String zn, String us, Function parseFunc) { + this.filedLangMap = new HashMap<>(); + filedLangMap.put(Locale.SIMPLIFIED_CHINESE, zn); + filedLangMap.put(Locale.US, us); + this.value = value; + this.parseFunc = parseFunc; + } + + public String parseExcelDataValue(BusinessTitleExcelData excelData) { + return parseFunc.apply(excelData); + } + + public boolean containsHead(String head) { + return filedLangMap.containsValue(head); + } + + public static BusinessTitleImportFiled fromHeader(String header) { + if (header == null) { + return null; + } + + for (BusinessTitleImportFiled field : values()) { + if (field.filedLangMap.containsValue(header)) { + return field; + } + } + return null; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/excel/constants/BusinessTitleImportType.java b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/constants/BusinessTitleImportType.java new file mode 100644 index 0000000..590aae4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/constants/BusinessTitleImportType.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.contract.excel.constants; + +public enum BusinessTitleImportType { + /** + * 新增 + */ + ADD, + + /** + * 更新 + */ + UPDATE, +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/excel/domain/BusinessTitleExcelData.java b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/domain/BusinessTitleExcelData.java new file mode 100644 index 0000000..3971efa --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/domain/BusinessTitleExcelData.java @@ -0,0 +1,101 @@ +package cn.cordys.crm.contract.excel.domain; + +import cn.cordys.crm.contract.excel.constants.BusinessTitleImportFiled; +import cn.idev.excel.annotation.ExcelIgnore; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + + +@Getter +@Setter +public class BusinessTitleExcelData { + /** + * 公司名称 + */ + @ExcelIgnore + private String name; + /** + * 纳税人识别号 + */ + @ExcelIgnore + private String identificationNumber; + /** + * 开户银行 + */ + @ExcelIgnore + private String openingBank; + /** + * 银行账户 + */ + @ExcelIgnore + private String bankAccount; + /** + * 注册地址 + */ + @ExcelIgnore + private String registrationAddress; + /** + * 注册电话 + */ + @ExcelIgnore + private String phoneNumber; + /** + * 注册资本 + */ + @ExcelIgnore + private String registeredCapital; + /** + * 公司规模 + */ + @ExcelIgnore + private String companySize; + /** + * 工商注册账号 + */ + @ExcelIgnore + private String registrationNumber; + /** + * 省 + */ + @ExcelIgnore + private String province; + /** + * 市 + */ + @ExcelIgnore + private String city; + /** + * 企业规模 + */ + @ExcelIgnore + private String scale; + /** + * 国标行业 + */ + @ExcelIgnore + private String industry; + /** + * 备注 + */ + @ExcelIgnore + private String remark; + + + public List> getHead() { + return new ArrayList<>(); + } + + public List> getHead(Locale lang) { + List> heads = new ArrayList<>(); + BusinessTitleImportFiled[] fields = BusinessTitleImportFiled.values(); + for (BusinessTitleImportFiled field : fields) { + heads.add(Collections.singletonList(field.getFiledLangMap().get(lang))); + } + return heads; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/excel/domain/BusinessTitleExcelDataCn.java b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/domain/BusinessTitleExcelDataCn.java new file mode 100644 index 0000000..e68f7c3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/domain/BusinessTitleExcelDataCn.java @@ -0,0 +1,104 @@ +package cn.cordys.crm.contract.excel.domain; + +import cn.idev.excel.annotation.ExcelProperty; +import cn.idev.excel.annotation.write.style.ColumnWidth; +import lombok.Data; + +import java.util.List; +import java.util.Locale; + + +@Data +@ColumnWidth(15) +public class BusinessTitleExcelDataCn extends BusinessTitleExcelData { + + /** + * 公司名称 + */ + @ColumnWidth(255) + @ExcelProperty("公司名称") + private String name; + /** + * 纳税人识别号 + */ + @ColumnWidth(255) + @ExcelProperty("纳税人识别号") + private String identificationNumber; + /** + * 开户银行 + */ + @ColumnWidth(255) + @ExcelProperty("开户银行") + private String openingBank; + /** + * 银行账户 + */ + @ColumnWidth(255) + @ExcelProperty("银行账户") + private String bankAccount; + /** + * 注册地址 + */ + @ColumnWidth(255) + @ExcelProperty("注册地址") + private String registrationAddress; + /** + * 注册电话 + */ + @ColumnWidth(255) + @ExcelProperty("注册电话") + private String phoneNumber; + /** + * 注册资本 + */ + @ColumnWidth(255) + @ExcelProperty("注册资本") + private String registeredCapital; + /** + * 公司规模 + */ + @ColumnWidth(255) + @ExcelProperty("公司规模") + private String companySize; + /** + * 工商注册账号 + */ + @ColumnWidth(255) + @ExcelProperty("工商注册账号") + private String registrationNumber; + /** + * 省 + */ + @ColumnWidth(255) + @ExcelProperty("省") + private String province; + /** + * 市 + */ + @ColumnWidth(255) + @ExcelProperty("市") + private String city; + /** + * 企业规模 + */ + @ColumnWidth(255) + @ExcelProperty("企业规模") + private String scale; + /** + * 国标行业 + */ + @ColumnWidth(255) + @ExcelProperty("国标行业") + private String industry; + /** + * 备注 + */ + @ColumnWidth(255) + @ExcelProperty("备注") + private String remark; + + @Override + public List> getHead() { + return super.getHead(Locale.SIMPLIFIED_CHINESE); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/excel/domain/BusinessTitleExcelDataFactory.java b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/domain/BusinessTitleExcelDataFactory.java new file mode 100644 index 0000000..4418017 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/domain/BusinessTitleExcelDataFactory.java @@ -0,0 +1,28 @@ +package cn.cordys.crm.contract.excel.domain; + +import cn.cordys.excel.domain.ExcelDataFactory; +import org.springframework.context.i18n.LocaleContextHolder; + +import java.util.Locale; + + +public class BusinessTitleExcelDataFactory implements ExcelDataFactory { + + @Override + public Class getExcelDataByLocal() { + Locale locale = LocaleContextHolder.getLocale(); + if (Locale.US.toString().equalsIgnoreCase(locale.toString())) { + return BusinessTitleExcelDataUs.class; + } + return BusinessTitleExcelDataCn.class; + } + + public BusinessTitleExcelData getExcelDataLocal() { + Locale locale = LocaleContextHolder.getLocale(); + if (Locale.US.toString().equalsIgnoreCase(locale.toString())) { + return new BusinessTitleExcelDataUs(); + } + return new BusinessTitleExcelDataCn(); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/excel/domain/BusinessTitleExcelDataUs.java b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/domain/BusinessTitleExcelDataUs.java new file mode 100644 index 0000000..8259cbb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/domain/BusinessTitleExcelDataUs.java @@ -0,0 +1,104 @@ +package cn.cordys.crm.contract.excel.domain; + +import cn.idev.excel.annotation.ExcelProperty; +import cn.idev.excel.annotation.write.style.ColumnWidth; +import lombok.Data; + +import java.util.List; +import java.util.Locale; + + +@Data +@ColumnWidth(15) +public class BusinessTitleExcelDataUs extends BusinessTitleExcelData { + + /** + * 公司名称 + */ + @ColumnWidth(255) + @ExcelProperty("Name") + private String name; + /** + * 纳税人识别号 + */ + @ColumnWidth(255) + @ExcelProperty("Identification number") + private String identificationNumber; + /** + * 开户银行 + */ + @ColumnWidth(255) + @ExcelProperty("Opening bank") + private String openingBank; + /** + * 银行账户 + */ + @ColumnWidth(255) + @ExcelProperty("Bank account") + private String bankAccount; + /** + * 注册地址 + */ + @ColumnWidth(255) + @ExcelProperty("Registration address") + private String registrationAddress; + /** + * 注册电话 + */ + @ColumnWidth(255) + @ExcelProperty("Phone number") + private String phoneNumber; + /** + * 注册资本 + */ + @ColumnWidth(255) + @ExcelProperty("Registered capital") + private String registeredCapital; + /** + * 公司规模 + */ + @ColumnWidth(255) + @ExcelProperty("Customer size") + private String companySize; + /** + * 工商注册号 + */ + @ColumnWidth(255) + @ExcelProperty("Registration number") + private String registrationNumber; + /** + * 省 + */ + @ColumnWidth(255) + @ExcelProperty("Province") + private String province; + /** + * 市 + */ + @ColumnWidth(255) + @ExcelProperty("City") + private String city; + /** + * 企业规模 + */ + @ColumnWidth(255) + @ExcelProperty("Scale") + private String scale; + /** + * 国标行业 + */ + @ColumnWidth(255) + @ExcelProperty("Industry") + private String industry; + /** + * 备注 + */ + @ColumnWidth(255) + @ExcelProperty("remark") + private String remark; + + @Override + public List> getHead() { + return super.getHead(Locale.US); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/excel/handler/BusinessTitleTemplateWriteHandler.java b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/handler/BusinessTitleTemplateWriteHandler.java new file mode 100644 index 0000000..15d54a8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/handler/BusinessTitleTemplateWriteHandler.java @@ -0,0 +1,76 @@ +package cn.cordys.crm.contract.excel.handler; + +import cn.cordys.common.util.Translator; +import cn.cordys.crm.contract.excel.constants.BusinessTitleImportFiled; +import cn.idev.excel.util.BooleanUtils; +import cn.idev.excel.write.handler.RowWriteHandler; +import cn.idev.excel.write.handler.SheetWriteHandler; +import cn.idev.excel.write.handler.context.RowWriteHandlerContext; +import cn.idev.excel.write.metadata.style.WriteCellStyle; +import cn.idev.excel.write.style.HorizontalCellStyleStrategy; +import org.apache.poi.ss.usermodel.Comment; +import org.apache.poi.ss.usermodel.Drawing; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.usermodel.XSSFClientAnchor; +import org.apache.poi.xssf.usermodel.XSSFRichTextString; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + + +public class BusinessTitleTemplateWriteHandler implements RowWriteHandler, SheetWriteHandler { + + private final Map fieldMap = new HashMap<>(); + private Sheet sheet; + private Drawing drawingPatriarch; + + private final Map requiredMap; + + public BusinessTitleTemplateWriteHandler(List> headList, Map requiredMap) { + initIndex(headList); + this.requiredMap = requiredMap; + } + + public static HorizontalCellStyleStrategy getHorizontalWrapStrategy() { + WriteCellStyle contentWriteCellStyle = new WriteCellStyle(); + // 设置自动换行 + contentWriteCellStyle.setWrapped(true); + return new HorizontalCellStyleStrategy(null, contentWriteCellStyle); + } + + private void initIndex(List> headList) { + int index = 0; + for (List list : headList) { + for (String head : list) { + this.fieldMap.put(head, index); + index++; + } + } + } + + @Override + public void afterRowDispose(RowWriteHandlerContext context) { + if (BooleanUtils.isTrue(context.getHead())) { + sheet = context.getWriteSheetHolder().getSheet(); + drawingPatriarch = sheet.createDrawingPatriarch(); + + for (Map.Entry entry : fieldMap.entrySet()) { + String key = BusinessTitleImportFiled.fromHeader(entry.getKey()).name().toLowerCase(); + if (requiredMap.containsKey(key)&&requiredMap.get(key)) { + setComment(fieldMap.get(entry.getKey()), Translator.get("required")); + } + } + } + + } + + private void setComment(Integer index, String text) { + if (index == null) { + return; + } + Comment comment = drawingPatriarch.createCellComment(new XSSFClientAnchor(0, 0, 0, 0, index, 0, index + 3, 1)); + comment.setString(new XSSFRichTextString(text)); + sheet.getRow(0).getCell(0).setCellComment(comment); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/excel/listener/BusinessTitleCheckEventListener.java b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/listener/BusinessTitleCheckEventListener.java new file mode 100644 index 0000000..9947b9e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/listener/BusinessTitleCheckEventListener.java @@ -0,0 +1,195 @@ +package cn.cordys.crm.contract.excel.listener; + +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.mapper.CommonMapper; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.contract.dto.request.BusinessTitleImportRequest; +import cn.cordys.crm.contract.excel.constants.BusinessTitleImportFiled; +import cn.cordys.crm.contract.excel.constants.BusinessTitleImportType; +import cn.cordys.excel.domain.ExcelErrData; +import cn.idev.excel.annotation.ExcelProperty; +import cn.idev.excel.context.AnalysisContext; +import cn.idev.excel.event.AnalysisEventListener; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +public class BusinessTitleCheckEventListener extends AnalysisEventListener> { + + /** + * 错误, 成功信息 + */ + @Getter + protected Integer success = 0; + @Getter + protected List errList = new ArrayList<>(); + protected static final int LENGTH_LIMIT = 255; + protected final String orgId; + private final CommonMapper commonMapper; + @Getter + protected List errRows = new ArrayList<>(); + + private final Class excelDataClass; + private final Map requiredFieldMap; + private final Map excelHeadToFieldNameDic = new HashMap<>(); + protected Map headMap; + private final List> heads; + protected boolean atLeastOne = false; + private final Map excelValueCache = new ConcurrentHashMap<>(); + private BusinessTitleImportRequest request = new BusinessTitleImportRequest(); + + public BusinessTitleCheckEventListener(Class clazz, Map requiredFieldMap, String orgId, List> heads, BusinessTitleImportRequest request) { + this.excelDataClass = clazz; + this.requiredFieldMap = requiredFieldMap; + this.orgId = orgId; + this.commonMapper = CommonBeanFactory.getBean(CommonMapper.class); + this.heads = heads; + this.request = request; + } + + @Override + public void invokeHeadMap(Map headMap, AnalysisContext context) { + if (headMap == null) { + throw new GenericException(Translator.get("user_import_table_header_missing")); + } + + List headList = heads.stream().flatMap(List::stream).toList(); + + headList.forEach(head -> { + if (!headMap.containsValue(head)) { + throw new GenericException(Translator.getWithArgs("illegal_header", head)); + } + }); + + + this.headMap = headMap; + try { + genExcelHeadToFieldNameDicAndGetNotRequiredFields(); + } catch (NoSuchFieldException e) { + log.error(e.getMessage(), e); + } + formatHeadMap(); + super.invokeHeadMap(headMap, context); + } + + @Override + public void invoke(Map data, AnalysisContext analysisContext) { + if (data == null) { + return; + } + atLeastOne = true; + Integer rowIndex = analysisContext.readRowHolder().getRowIndex(); + validateRowData(rowIndex, data); + } + + @Override + public void doAfterAllAnalysed(AnalysisContext analysisContext) { + if (!atLeastOne) { + throw new GenericException(Translator.get("import.data.cannot_be_null")); + } + } + + + private void validateRowData(Integer rowIndex, Map rowData) { + StringBuilder errText = new StringBuilder(); + headMap.forEach((k, v) -> { + if (Strings.CI.equals(request.getImportType(), BusinessTitleImportType.ADD.name())) { + validateRequired(rowData.get(k), errText, v); + validateNameUniques(rowData.get(k), errText, v); + } + if (Strings.CI.equals(request.getImportType(), BusinessTitleImportType.UPDATE.name())) { + validateNameExist(rowData.get(k), errText, v); + } + validateLenLimit(rowData.get(k), errText, v); + }); + if (StringUtils.isNotEmpty(errText)) { + ExcelErrData excelErrData = new ExcelErrData(rowIndex, + Translator.getWithArgs("row.error.tip", rowIndex + 1).concat(" " + errText)); + //错误信息 + errList.add(excelErrData); + errRows.add(rowIndex); + } else if (!errRows.contains(rowIndex)) { + success++; + } + } + + private void validateNameExist(String data, StringBuilder errText, String v) { + if (data != null && BusinessTitleImportFiled.NAME.equals(BusinessTitleImportFiled.fromHeader(v))) { + boolean repeat = commonMapper.checkAddExist("business_title", BusinessTitleImportFiled.NAME.name().toLowerCase(), data, orgId); + if (!repeat) { + errText.append(v).append(":").append(Translator.get("business_title.not.exist")).append(";"); + } + } + } + + private void validateRequired(String data, StringBuilder errText, String v) { + if (BusinessTitleImportFiled.fromHeader(v) != null) { + String key = BusinessTitleImportFiled.fromHeader(v).name().toLowerCase(); + if (requiredFieldMap.containsKey(key) && requiredFieldMap.get(key) && StringUtils.isBlank(data)) { + errText.append(v).append(Translator.get("required")).append(";"); + } + } + } + + private void validateNameUniques(String data, StringBuilder errText, String v) { + if (data != null && BusinessTitleImportFiled.NAME.equals(BusinessTitleImportFiled.fromHeader(v))) { + Boolean existed = excelValueCache.putIfAbsent(data, true); + if (existed != null) { + errText.append(v).append(":").append(Translator.get("business_title.exist")).append(";"); + return; + } + + boolean repeat = commonMapper.checkAddExist("business_title", BusinessTitleImportFiled.NAME.name().toLowerCase(), data, orgId); + if (repeat) { + errText.append(v).append(":").append(Translator.get("business_title.exist")).append(";"); + } + } + } + + private void validateLenLimit(String data, StringBuilder errText, String v) { + if (StringUtils.isNotBlank(data) && data.length() > LENGTH_LIMIT) { + errText.append(v).append(Translator.getWithArgs("over.length", LENGTH_LIMIT)).append(";"); + } + } + + + public void genExcelHeadToFieldNameDicAndGetNotRequiredFields() throws NoSuchFieldException { + Field[] fields = excelDataClass.getDeclaredFields(); + for (Field f : fields) { + Field field = excelDataClass.getDeclaredField(f.getName()); + field.setAccessible(true); + ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class); + if (excelProperty == null) { + continue; + } + StringBuilder headValue = new StringBuilder(); + for (String v : excelProperty.value()) { + headValue.append(v); + } + String head = headValue.toString(); + excelHeadToFieldNameDic.put(head, field.getName()); + } + } + + private void formatHeadMap() { + if (headMap == null || headMap.isEmpty()) { + return; + } + for (Map.Entry entry : headMap.entrySet()) { + String name = entry.getValue(); + if (excelHeadToFieldNameDic.containsKey(name)) { + entry.setValue(excelHeadToFieldNameDic.get(name)); + } + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/excel/listener/BusinessTitleImportEventListener.java b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/listener/BusinessTitleImportEventListener.java new file mode 100644 index 0000000..98b25ec --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/excel/listener/BusinessTitleImportEventListener.java @@ -0,0 +1,161 @@ +package cn.cordys.crm.contract.excel.listener; + +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.contract.dto.request.BusinessTitleImportRequest; +import cn.cordys.crm.contract.excel.constants.BusinessTitleImportFiled; +import cn.cordys.excel.domain.ExcelErrData; +import cn.idev.excel.context.AnalysisContext; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.Strings; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +@Slf4j +public class BusinessTitleImportEventListener extends BusinessTitleCheckEventListener { + + @Getter + private final List dataList; + @Getter + protected List errList = new ArrayList<>(); + @Getter + private int successCount; + protected static final int BATCH_SIZE = 2000; + protected final String userId; + private final Consumer> consumer; + /** + * method cache + */ + private final Map methodCache = new HashMap<>(); + /** + * 业务实体 + */ + private final Class entityClass; + + public BusinessTitleImportEventListener(Class clazz, Class entityClass, Map requiredFieldMap, String orgId, String userId, Consumer> consumer, List> heads, BusinessTitleImportRequest request) { + super(clazz, requiredFieldMap, orgId, heads, request); + this.entityClass = entityClass; + this.userId = userId; + this.consumer = consumer; + this.dataList = new ArrayList<>(BATCH_SIZE); + // 缓存方法, 频繁反射有开销 + cacheSetterMethods(); + } + + + @Override + public void invokeHeadMap(Map headMap, AnalysisContext context) { + super.invokeHeadMap(headMap, context); + } + + @Override + public void invoke(Map data, AnalysisContext analysisContext) { + super.invoke(data, analysisContext); + Integer rowIndex = analysisContext.readRowHolder().getRowIndex(); + if (this.errRows.contains(rowIndex)) { + // 有错误跳过该行 + return; + } + try { + T t = entityClass.getDeclaredConstructor().newInstance(); + String rowKey = IDGenerator.nextStr(); + setInternal(t, rowKey); + headMap.forEach((k, v) -> { + try { + if (BusinessTitleImportFiled.fromHeader(v) == null) { + return; + } + setPropertyValue(t, data.get(k), BusinessTitleImportFiled.fromHeader(v).getValue()); + } catch (Exception e) { + log.error("导入错误, 无法设置字段值. {}", e.getMessage()); + throw new GenericException(e); + } + }); + dataList.add(t); + } catch (Exception e) { + log.error("导入错误, 原因: {}", e.getMessage()); + throw new GenericException(Translator.getWithArgs("import.error", rowIndex + 1).concat(" " + e.getMessage())); + } + + if (dataList.size() >= BATCH_SIZE) { + batchProcessData(); + } + + } + + private void setPropertyValue(T instance, Object value, String fieldName) throws Exception { + Method setter = methodCache.get("set:" + fieldName); + if (setter != null) { + setter.invoke(instance, value); + } + } + + + @Override + public void doAfterAllAnalysed(AnalysisContext analysisContext) { + if (CollectionUtils.isNotEmpty(this.dataList)) { + batchProcessData(); + } + log.info("数据导入完成, 总行数: {}", successCount); + } + + /** + * 批量入库操作 + */ + private void batchProcessData() { + try { + // 执行入库 + consumer.accept(this.dataList); + } catch (Exception e) { + // 入库异常,不影响后续批次 + log.error("批量插入异常: {}", e.getCause().getMessage()); + throw new GenericException(e.getCause()); + } finally { + // 批次插入成功, 统计&&清理 + successCount += this.dataList.size(); + this.dataList.clear(); + } + } + + + /** + * 缓存entity setter + */ + private void cacheSetterMethods() { + for (Method method : entityClass.getMethods()) { + if (method.getName().startsWith("set") && method.getParameterCount() == 1) { + String fieldName = method.getName().substring(3); + String property = Character.toLowerCase(fieldName.charAt(0)) + fieldName.substring(1); + methodCache.put("set:" + property, method); + } else if (method.getParameterCount() == 0 && Strings.CS.equals(method.getName(), "getId")) { + methodCache.put("get:id", method); + } + } + } + + + /** + * 设置entity内部字段 + * + * @param instance 实例对象 + * @param rowKey 唯一Key + * @throws Exception 异常 + */ + private void setInternal(T instance, String rowKey) throws Exception { + methodCache.get("set:id").invoke(instance, rowKey); + methodCache.get("set:createUser").invoke(instance, userId); + methodCache.get("set:createTime").invoke(instance, System.currentTimeMillis()); + methodCache.get("set:updateUser").invoke(instance, userId); + methodCache.get("set:updateTime").invoke(instance, System.currentTimeMillis()); + methodCache.get("set:organizationId").invoke(instance, orgId); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtBusinessTitleMapper.java b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtBusinessTitleMapper.java new file mode 100644 index 0000000..8214cb3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtBusinessTitleMapper.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.contract.mapper; + +import cn.cordys.crm.contract.domain.BusinessTitle; +import cn.cordys.crm.contract.dto.request.BusinessTitlePageRequest; +import cn.cordys.crm.contract.dto.response.BusinessTitleListResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtBusinessTitleMapper { + + + List list(@Param("request") BusinessTitlePageRequest request, @Param("orgId") String orgId, @Param("userId") String userId); + + List getListByIds(@Param("ids") List ids, @Param("userId") String userId, @Param("orgId") String orgId); + + int countByName(@Param("businessName") String businessName, @Param("orgId") String orgId, @Param("id") String id); + + List selectByNames(@Param("names")List names); + + void updateById(@Param("title") BusinessTitle title); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtBusinessTitleMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtBusinessTitleMapper.xml new file mode 100644 index 0000000..f9a1664 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtBusinessTitleMapper.xml @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CONCAT('CO.NO.', LPAD(bt.company_number, 8, '0')) + LIKE CONCAT('%', #{condition.value}, '%') + + + CONCAT('CO.NO.', LPAD(bt.company_number, 8, '0')) + NOT LIKE CONCAT('%', #{condition.value}, '%') + + + bt.company_number IS NULL OR bt.company_number = '' + + + bt.company_number IS NOT NULL AND bt.company_number != '' + + + CONCAT('CO.NO.', LPAD(bt.company_number, 8, '0')) = #{condition.value} + + + CONCAT('CO.NO.', LPAD(bt.company_number, 8, '0')) != #{condition.value} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + order by + + + bt.${sortName} ${sortType}, bt.id ASC + + + order by bt.create_time desc, bt.id asc + + + + + order by bt.create_time desc, bt.id asc + + + + + + + + + + + + + + + update business_title + + + identification_number = #{title.identificationNumber}, + + + opening_bank = #{title.openingBank}, + + + bank_account = #{title.bankAccount}, + + + registration_address = #{title.registrationAddress}, + + + phone_number = #{title.phoneNumber}, + + + registered_capital = #{title.registeredCapital}, + + + company_size = #{title.companySize}, + + + registration_number = #{title.registrationNumber}, + + + province = #{title.province}, + + + city = #{title.city}, + + + scale = #{title.scale}, + + + industry = #{title.industry}, + + + remark = #{title.remark}, + + + update_user = #{title.updateUser}, + + + update_time = #{title.updateTime}, + + + + id = #{title.id} + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractInvoiceMapper.java b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractInvoiceMapper.java new file mode 100644 index 0000000..6c43e1e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractInvoiceMapper.java @@ -0,0 +1,39 @@ +package cn.cordys.crm.contract.mapper; + +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.crm.contract.dto.request.ContractInvoicePageRequest; +import cn.cordys.crm.contract.dto.response.ContractInvoiceGetResponse; +import cn.cordys.crm.contract.dto.response.ContractInvoiceListResponse; +import org.apache.ibatis.annotations.Param; + +import java.math.BigDecimal; +import java.util.List; + +public interface ExtContractInvoiceMapper { + + + List list(@Param("request") ContractInvoicePageRequest request, @Param("orgId") String orgId, + @Param("userId") String userId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission); + + ContractInvoiceGetResponse getDetail(@Param("id") String id); + + + List getListByIds(@Param("ids") List ids, @Param("userId") String userId, @Param("orgId") String orgId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission); + + List selectByStatusAndIds(@Param("ids") List ids, @Param("approvalStatus") String approvalStatus); + + void updateStatus(@Param("id") String id, @Param("approvalStatus") String approvalStatus, @Param("userId") String userId, @Param("updateTime") long updateTime); + + BigDecimal calculateCustomerInvoiceAmount(@Param("customerId") String customerId, @Param("userId") String userId, + @Param("orgId") String orgId, @Param("approved") boolean approved); + + BigDecimal calculateContractInvoiceAmount(@Param("contractId") String contractId, @Param("userId") String userId, + @Param("orgId") String orgId, @Param("approved") boolean approved); + + BigDecimal calculateContractInvoiceValidAmount(@Param("contractId") String contractId, @Param("userId") String userId, + @Param("orgId") String orgId, @Param("excludeId") String excludeId); + + Boolean hasContractInvoice(@Param("contractId") String contractId); + + void updateOldApprovalStatusNone(); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractInvoiceMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractInvoiceMapper.xml new file mode 100644 index 0000000..4ac7602 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractInvoiceMapper.xml @@ -0,0 +1,500 @@ + + + + + + + + + + + + + + + + + + + + + + + + + left join contract_invoice_field_blob ${tablePrefix}_${i} + on ci.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + left join contract_invoice_field ${tablePrefix}_${i} + on ci.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + + left join sys_organization_user ${tablePrefix}_${i} + on ${tablePrefix}_${i}.user_id = ci.owner + + + + + + + + + + + left join contract_invoice_field sort_table + on ci.id = sort_table.resource_id and sort_table.field_id = #{sortName} + left join sys_module_field module_table on sort_table.field_id = module_table.id + + + left join sys_organization_user souc_sort on souc_sort.user_id = ci.owner + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + order by + + + ci.`name` ${sortType}, ci.id ASC + + + c.`name` ${sortType}, ci.id ASC + + + ci.`owner` ${sortType}, ci.id ASC + + + ci.`amount` ${sortType}, ci.id ASC + + + ci.`invoice_type` ${sortType}, ci.id ASC + + + ci.`create_time` ${sortType}, ci.id ASC + + + ci.`update_time` ${sortType}, ci.id ASC + + + ci.`create_user` ${sortType}, ci.id ASC + + + ci.`update_user` ${sortType}, ci.id ASC + + + souc_sort.`id` ${sortType}, ci.id ASC + + + ci.`tax_rate` ${sortType}, ci.id ASC + + + ci.`business_title_id` ${sortType}, ci.id ASC + + + FIELD(ci.approval_status, 'NONE', 'PENDING', 'APPROVING', 'APPROVED', 'REVOKED', 'UNAPPROVED') ${sortType}, ci.id ASC + + + + CASE + WHEN module_table.type = 'INPUT_NUMBER' OR module_table.type = 'FORMULA' + THEN sort_table.field_value+0 + END ${sortType}, + sort_table.field_value ${sortType}, + ci.id ${sortType} + + + + + order by ci.create_time desc, ci.id asc + + + + + + + + + + + + + + + + + + update contract_invoice + set approval_status = #{approvalStatus}, + update_user = #{userId}, + update_time = #{updateTime} + where id = #{id} + + + + update contract_invoice set approval_status = 'NONE' where approval_status = 'APPROVING'; + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractMapper.java b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractMapper.java new file mode 100644 index 0000000..c05263b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractMapper.java @@ -0,0 +1,49 @@ +package cn.cordys.crm.contract.mapper; + +import cn.cordys.common.dto.BatchUpdateDbParam; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.condition.BaseCondition; +import cn.cordys.crm.contract.domain.Contract; +import cn.cordys.crm.contract.dto.request.ContractPageRequest; +import cn.cordys.crm.contract.dto.response.ContractGetResponse; +import cn.cordys.crm.contract.dto.response.ContractListResponse; +import cn.cordys.crm.contract.dto.response.ContractStatisticResponse; +import cn.cordys.crm.contract.dto.response.CustomerContractStatisticResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtContractMapper { + + + List list(@Param("request") ContractPageRequest request, @Param("orgId") String orgId, + @Param("userId") String userId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission, @Param("source") boolean source); + + ContractGetResponse getDetail(@Param("id") String id); + + List getListByIds(@Param("ids") List ids, @Param("userId") String userId, @Param("orgId") String orgId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission); + + CustomerContractStatisticResponse calculateContractStatisticByCustomerId(@Param("customerId") String customerId, @Param("userId") String userId, @Param("orgId") String orgId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission); + + List selectByStatusAndIds(@Param("ids") List ids, @Param("approvalStatus") String approvalStatus); + + void updateStatus(@Param("id") String id, @Param("approvalStatus") String approvalStatus, @Param("userId") String userId, @Param("updateTime") long updateTime); + + void updateStage(@Param("id") String id, @Param("stage") String stage, @Param("userId") String userId, @Param("updateTime") long updateTime); + + List selectByTimestamp(@Param("organizationId") String organizationId, @Param("timestampOld") long timestampOld, @Param("timestamp") long timestamp); + + void batchUpdate(@Param("request") BatchUpdateDbParam request); + + ContractStatisticResponse searchStatistic(@Param("request") BaseCondition request, @Param("orgId") String orgId, @Param("userId") String userId, @Param("dataPermission") DeptDataPermissionDTO dataPermission); + + int countByStage(@Param("stage") String stage); + + Long selectNextPos(@Param("orgId") String orgId, @Param("stage") String stage); + + void moveUpStageContract(@Param("end") Long end, @Param("stage") String stage, @Param("pos") Long pos); + + void moveDownStageContract(@Param("end") Long end, @Param("stage") String stage, @Param("pos") Long pos); + + void updateOldApprovalStatusNone(); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractMapper.xml new file mode 100644 index 0000000..86c1b9d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractMapper.xml @@ -0,0 +1,640 @@ + + + + + + + + + + + + + + + + + + + + + + + + + left join contract_field_blob ${tablePrefix}_${i} + on ct.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + left join contract_field ${tablePrefix}_${i} + on ct.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + + left join sys_organization_user ${tablePrefix}_${i} + on ${tablePrefix}_${i}.user_id = ct.owner + + + + + + + + + + + left join contract_field sort_table + on ct.id = sort_table.resource_id and sort_table.field_id = #{sortName} + left join sys_module_field module_table on sort_table.field_id = module_table.id + + + left join sys_organization_user souc_sort on souc_sort.user_id = ct.owner + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + order by + + + ct.`name` ${sortType}, ct.id ASC + + + c.`name` ${sortType}, ct.id ASC + + + ct.`owner` ${sortType}, ct.id ASC + + + ct.`amount` ${sortType}, ct.id ASC + + + ct.`alreadyPayAmount` ${sortType}, ct.id ASC + + + ct.`stage` ${sortType}, ct.id ASC + + + ct.`create_time` ${sortType}, ct.id ASC + + + ct.`update_time` ${sortType}, ct.id ASC + + + ct.`create_user` ${sortType}, ct.id ASC + + + ct.`update_user` ${sortType}, ct.id ASC + + + souc_sort.`id` ${sortType}, ct.id ASC + + + FIELD(ct.approval_status, 'NONE', 'PENDING', 'APPROVING', 'APPROVED', 'REVOKED', 'UNAPPROVED') ${sortType}, ct.id ASC + + + ct.`start_time` ${sortType}, ct.id ASC + + + ct.`end_time` ${sortType}, ct.id ASC + + + + + CASE + WHEN module_table.type = 'INPUT_NUMBER' OR module_table.type = 'FORMULA' + THEN sort_table.field_value+0 + END ${sortType}, + sort_table.field_value ${sortType}, + ct.id ${sortType} + + + + + order by ct.create_time desc, ct.id asc + + + + + + + + + + + + update contract + set approval_status = #{approvalStatus}, + update_user = #{userId}, + update_time = #{updateTime} + where id = #{id} + + + + update contract + set stage = #{stage}, + update_user = #{userId}, + update_time = #{updateTime} + where id = #{id} + + + + UPDATE contract + + + owner = #{request.fieldValue}, + + + name = #{request.fieldValue}, + + + customer_id = #{request.fieldValue}, + + + amount = #{request.fieldValue}, + + + number = #{request.fieldValue}, + + + stage = #{request.fieldValue}, + + + start_time = #{request.fieldValue}, + + + end_time = #{request.fieldValue}, + + update_time = #{request.updateTime}, + update_user = #{request.updateUser} + + WHERE id IN + + #{id} + + + + + + + + + + + + + update contract set pos = pos + #{pos} where pos > #{end} and stage = #{stage} + + + + update contract set pos = pos + #{pos} where pos >= #{end} and stage = #{stage} + + + + update contract set approval_status = 'NONE' where approval_status = 'APPROVING'; + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractPaymentPlanMapper.java b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractPaymentPlanMapper.java new file mode 100644 index 0000000..50b4333 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractPaymentPlanMapper.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.contract.mapper; + +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.crm.contract.domain.ContractPaymentPlan; +import cn.cordys.crm.contract.dto.request.ContractPaymentPlanPageRequest; +import cn.cordys.crm.contract.dto.response.ContractPaymentPlanListResponse; +import cn.cordys.crm.contract.dto.response.CustomerPaymentPlanStatisticResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * + * @author jianxing + * @date 2025-11-21 15:11:29 + */ +public interface ExtContractPaymentPlanMapper { + List list(@Param("request") ContractPaymentPlanPageRequest request, @Param("userId") String userId, + @Param("orgId") String orgId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission); + + List getListByIds(@Param("ids") List ids, @Param("userId") String userId, @Param("orgId") String orgId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission); + + CustomerPaymentPlanStatisticResponse calculateCustomerPaymentPlanStatistic(@Param("customerId") String customerId, @Param("userId") String userId, @Param("orgId") String orgId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission); + + List selectByTimestamp(@Param("timestamp") long timestamp, @Param("timestampOld") long timestampOld, @Param("orgId") String organizationId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractPaymentPlanMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractPaymentPlanMapper.xml new file mode 100644 index 0000000..67bc49c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractPaymentPlanMapper.xml @@ -0,0 +1,361 @@ + + + + + + + + + + + + + + + + order by + + + cpp.${sortName} ${sortType}, cpp.id ASC + + + sou_sort.${sortName} ${sortType}, cpp.id ASC + + + CASE + WHEN module_table.type = 'INPUT_NUMBER' OR module_table.type = 'FORMULA' + THEN sort_table.field_value + 0 + END ${sortType}, + sort_table.field_value ${sortType}, + cpp.id ${sortType} + + + + + order by cpp.create_time desc, cpp.id asc + + + + + + + + + + + + + + + + + + + + left join contract_payment_plan_field_blob ${tablePrefix}_${i} + on cpp.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + left join contract_payment_plan_field ${tablePrefix}_${i} + on cpp.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + + left join sys_organization_user ${tablePrefix}_${i} + on ${tablePrefix}_${i}.user_id = cpp.owner + + + + + + + + + + + + + left join sys_organization_user sou_sort on cpp.owner = sou_sort.user_id + + + left join contract_payment_plan_field sort_table + on cpp.id = sort_table.resource_id and sort_table.field_id = #{sortName} + left join sys_module_field module_table on sort_table.field_id = module_table.id + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractPaymentRecordMapper.java b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractPaymentRecordMapper.java new file mode 100644 index 0000000..e2fd019 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractPaymentRecordMapper.java @@ -0,0 +1,54 @@ +package cn.cordys.crm.contract.mapper; + +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.crm.contract.dto.request.ContractPaymentRecordPageRequest; +import cn.cordys.crm.contract.dto.request.ContractPaymentRecordStatisticRequest; +import cn.cordys.crm.contract.dto.response.ContractPaymentRecordResponse; +import cn.cordys.crm.contract.dto.response.ContractPaymentRecordStatisticResponse; +import cn.cordys.crm.contract.dto.response.CustomerPaymentRecordStatisticResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * @author song-cc-rock + */ +public interface ExtContractPaymentRecordMapper { + + /** + * 合同回款记录列表 + * + * @param request 请求参数 + * @param currentUser 当前用户 + * @param currentOrg 当前组织 + * @param deptDataPermission 数据权限 + * @return 回款记录列表 + */ + List list(@Param("request") ContractPaymentRecordPageRequest request, @Param("currentUser") String currentUser, + @Param("currentOrg") String currentOrg, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission); + + /** + * 通过ID集合获取回款记录 + * + * @param ids ID集合 + * @param currentUser 当前用户 + * @param currentOrg 当前组织 + * @param deptDataPermission 数据权限 + * @return 回款记录列表 + */ + List getListByIds(@Param("ids") List ids, @Param("currentUser") String currentUser, + @Param("currentOrg") String currentOrg, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission); + + /** + * 汇总客户回款记录金额 + * + * @param customerId 客户ID + * @param userId 用户ID + * @param orgId 组织ID + * @param deptDataPermission 数据权限 + * @return 汇总结果 + */ + CustomerPaymentRecordStatisticResponse sumCustomerRecordAmount(@Param("customerId") String customerId, @Param("userId") String userId, @Param("orgId") String orgId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission); + + ContractPaymentRecordStatisticResponse searchStatistic(@Param("request") ContractPaymentRecordStatisticRequest request, @Param("orgId") String orgId, @Param("userId") String userId, @Param("dataPermission") DeptDataPermissionDTO dataPermission); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractPaymentRecordMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractPaymentRecordMapper.xml new file mode 100644 index 0000000..87cd72d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractPaymentRecordMapper.xml @@ -0,0 +1,429 @@ + + + + + + + + + + + + + + + + order by + + + cpr.${sortName} ${sortType}, cpr.id ASC + + + sou_sort.${sortName} ${sortType}, cpr.id ASC + + + CASE + WHEN module_table.type = 'INPUT_NUMBER' OR module_table.type = 'FORMULA' + THEN sort_table.field_value + 0 + END ${sortType}, + sort_table.field_value ${sortType}, + cpr.id ${sortType} + + + + + order by cpr.create_time desc, cpr.id asc + + + + + + + + + + + + + + + + + + + + left join contract_payment_record_field_blob ${tablePrefix}_${i} + on cpr.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + left join contract_payment_record_field ${tablePrefix}_${i} + on cpr.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + + left join sys_organization_user ${tablePrefix}_${i} + on ${tablePrefix}_${i}.user_id = cpr.owner + + + + + + + + + + + + + left join sys_organization_user sou_sort on cpr.owner = sou_sort.user_id + + + left join contract_payment_record_field sort_table + on cpr.id = sort_table.resource_id and sort_table.field_id = #{sortName} + left join sys_module_field module_table on sort_table.field_id = module_table.id + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractSnapshotMapper.java b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractSnapshotMapper.java new file mode 100644 index 0000000..a0488a7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractSnapshotMapper.java @@ -0,0 +1,9 @@ +package cn.cordys.crm.contract.mapper; + +import cn.cordys.crm.contract.domain.ContractSnapshot; +import org.apache.ibatis.annotations.Param; + +public interface ExtContractSnapshotMapper { + + void update(@Param("snapshot") ContractSnapshot snapshot); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractSnapshotMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractSnapshotMapper.xml new file mode 100644 index 0000000..73ced33 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractSnapshotMapper.xml @@ -0,0 +1,13 @@ + + + + + + + update contract_snapshot + set contract_id = #{snapshot.contractId}, + contract_prop = #{snapshot.contractProp}, + contract_value = #{snapshot.contractValue} + where id = #{snapshot.id} + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractStageConfigMapper.java b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractStageConfigMapper.java new file mode 100644 index 0000000..647390f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractStageConfigMapper.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.contract.mapper; + +import cn.cordys.common.dto.stage.StageConfigResponse; +import cn.cordys.common.dto.stage.StageRollBackRequest; +import cn.cordys.common.dto.stage.StageUpdateRequest; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtContractStageConfigMapper { + + + List getStageConfigList(@Param("orgId") String orgId); + + int countStageConfig(@Param("orgId") String orgId); + + void moveUpStageConfig(@Param("start") Long start, @Param("orgId") String orgId, @Param("pos") Long pos); + + void moveDownStageConfig(@Param("start") Long start, @Param("orgId") String orgId, @Param("pos") Long pos); + + void updateRollBack(@Param("request") StageRollBackRequest request, @Param("orgId") String orgId); + + void updateStageConfig(@Param("request") StageUpdateRequest request, @Param("userId") String userId); + + void updatePos(@Param("id") String id, @Param("pos") Long pos); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractStageConfigMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractStageConfigMapper.xml new file mode 100644 index 0000000..33c11eb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/mapper/ExtContractStageConfigMapper.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + update contract_stage_config + set pos = pos + #{pos} + where pos >= #{start} + and organization_id = #{orgId} + + + + update contract_stage_config + set pos = pos + #{pos} + where pos > #{start} + and organization_id = #{orgId} + + + + + UPDATE contract_stage_config + + + afoot_roll_back =#{request.afootRollBack}, + + + end_roll_back = #{request.endRollBack}, + + + WHERE organization_id = #{orgId} + + + + update contract_stage_config + set name = #{request.name}, + update_user = #{userId}, + update_time = unix_timestamp() * 1000 + where id = #{request.id} + + + + + update contract_stage_config set pos = #{pos} where id = #{id} + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/BusinessTitleConfigService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/BusinessTitleConfigService.java new file mode 100644 index 0000000..1abcca3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/BusinessTitleConfigService.java @@ -0,0 +1,53 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.common.util.FieldConverter; +import cn.cordys.crm.contract.domain.BusinessTitleConfig; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class BusinessTitleConfigService { + + @Resource + private BaseMapper businessTitleConfigMapper; + + + /** + * 获取工商抬头配置 + * + * @param orgId + * + * @return + */ + public List getConfigs(String orgId) { + LambdaQueryWrapper configWrapper = new LambdaQueryWrapper<>(); + configWrapper.eq(BusinessTitleConfig::getOrganizationId, orgId); + List businessTitleConfigs = businessTitleConfigMapper.selectListByLambda(configWrapper); + businessTitleConfigs.forEach(config -> config.setField(FieldConverter.toCamelCaseWithCommons(config.getField()))); + return businessTitleConfigs; + } + + + /** + * 更新 + * + * @param id + */ + public void switchRequired(String id) { + Optional.ofNullable(businessTitleConfigMapper.selectByPrimaryKey(id)) + .ifPresent(config -> { + config.setRequired(!config.getRequired()); + businessTitleConfigMapper.update(config); + }); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/BusinessTitleExportService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/BusinessTitleExportService.java new file mode 100644 index 0000000..48982fa --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/BusinessTitleExportService.java @@ -0,0 +1,110 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.common.dto.ExportDTO; +import cn.cordys.common.dto.ExportHeadDTO; +import cn.cordys.common.service.BaseExportService; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.util.TimeUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.contract.dto.request.BusinessTitlePageRequest; +import cn.cordys.crm.contract.dto.response.BusinessTitleListResponse; +import cn.cordys.crm.contract.mapper.ExtBusinessTitleMapper; +import cn.cordys.registry.ExportThreadRegistry; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class BusinessTitleExportService extends BaseExportService { + + @Resource + private ExtBusinessTitleMapper extBusinessTitleMapper; + @Resource + private BaseService baseService; + + /** + * 选中工商抬头数据 + * + * @return 导出数据列表 + */ + @Override + public List> getSelectExportData(List ids, String taskId, ExportDTO exportDTO) throws InterruptedException { + var orgId = exportDTO.getOrgId(); + var userId = exportDTO.getUserId(); + var allList = extBusinessTitleMapper.getListByIds(ids, userId, orgId); + baseService.setCreateAndUpdateUserName(allList); + return buildExportResult(allList, taskId, exportDTO); + } + + private List> buildExportResult(List responses, String taskId, ExportDTO exportDTO) throws InterruptedException { + var headList = exportDTO.getHeadList(); + var result = new ArrayList>(responses.size()); + for (var response : responses) { + if (ExportThreadRegistry.isInterrupted(taskId)) { + throw new InterruptedException("线程已被中断,主动退出"); + } + result.add(buildData(headList, response)); + } + return result; + } + + private List buildData(List headList, BusinessTitleListResponse data) { + var dataList = new ArrayList(headList.size()); + var systemFieldMap = getSystemFieldMap(data); + return transModuleFieldValue(headList, systemFieldMap, new HashMap<>(), dataList, new HashMap<>()); + + } + + public LinkedHashMap getSystemFieldMap(BusinessTitleListResponse data) { + LinkedHashMap systemFieldMap = new LinkedHashMap<>(); + systemFieldMap.put("name", data.getName()); + systemFieldMap.put("identificationNumber", data.getIdentificationNumber()); + systemFieldMap.put("openingBank", data.getOpeningBank()); + systemFieldMap.put("type", Translator.get(data.getType())); + systemFieldMap.put("bankAccount", data.getBankAccount()); + systemFieldMap.put("registrationAddress", data.getRegistrationAddress()); + systemFieldMap.put("phoneNumber", data.getPhoneNumber()); + systemFieldMap.put("registeredCapital", data.getRegisteredCapital()); + systemFieldMap.put("companySize", data.getCompanySize()); + systemFieldMap.put("registrationNumber", data.getRegistrationNumber()); + systemFieldMap.put("unapprovedReason", data.getUnapprovedReason()); + systemFieldMap.put("province", data.getProvince()); + systemFieldMap.put("city", data.getCity()); + systemFieldMap.put("scale", data.getScale()); + systemFieldMap.put("industry", data.getIndustry()); + systemFieldMap.put("remark", data.getRemark()); + + + systemFieldMap.put("createUser", data.getCreateUserName()); + systemFieldMap.put("createTime", TimeUtils.getDateTimeStr(data.getCreateTime())); + systemFieldMap.put("updateUser", data.getUpdateUserName()); + systemFieldMap.put("updateTime", TimeUtils.getDateTimeStr(data.getUpdateTime())); + systemFieldMap.put("companyNumber", data.getCompanyNumber()); + return systemFieldMap; + } + + + /** + * 构建导出的数据 + * + * @return 导出数据列表 + */ + @Override + public List> getExportData(String taskId, ExportDTO exportDTO) throws InterruptedException { + var pageRequest = (BusinessTitlePageRequest) exportDTO.getPageRequest(); + var orgId = exportDTO.getOrgId(); + PageHelper.startPage(pageRequest.getCurrent(), pageRequest.getPageSize()); + var dataList = extBusinessTitleMapper.list(pageRequest, orgId, exportDTO.getUserId()); + baseService.setCreateAndUpdateUserName(dataList); + return buildExportResult(dataList, taskId, exportDTO); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/BusinessTitleService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/BusinessTitleService.java new file mode 100644 index 0000000..8f58721 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/BusinessTitleService.java @@ -0,0 +1,659 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.ThirdConfigTypeConstants; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.uid.utils.EnumUtils; +import cn.cordys.common.util.*; +import cn.cordys.common.utils.BeanCopyUtils; +import cn.cordys.crm.approval.constants.ApprovalState; +import cn.cordys.crm.contract.constants.BusinessTitleType; +import cn.cordys.crm.contract.constants.ContractApprovalStatus; +import cn.cordys.crm.contract.domain.BusinessTitle; +import cn.cordys.crm.contract.domain.BusinessTitleConfig; +import cn.cordys.crm.contract.domain.ContractInvoice; +import cn.cordys.crm.contract.dto.request.*; +import cn.cordys.crm.contract.dto.response.BusinessTitleListResponse; +import cn.cordys.crm.contract.excel.constants.BusinessTitleImportType; +import cn.cordys.crm.contract.excel.domain.BusinessTitleExcelDataFactory; +import cn.cordys.crm.contract.excel.handler.BusinessTitleTemplateWriteHandler; +import cn.cordys.crm.contract.excel.listener.BusinessTitleCheckEventListener; +import cn.cordys.crm.contract.excel.listener.BusinessTitleImportEventListener; +import cn.cordys.crm.contract.mapper.ExtBusinessTitleMapper; +import cn.cordys.crm.integration.common.dto.ThirdConfigBaseDTO; +import cn.cordys.crm.integration.common.request.QccThirdConfigRequest; +import cn.cordys.crm.integration.common.utils.HttpClientUtils; +import cn.cordys.crm.integration.qcc.constant.QccApiPaths; +import cn.cordys.crm.integration.qcc.dto.*; +import cn.cordys.crm.system.constants.SheetKey; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.excel.domain.UserExcelDataFactory; +import cn.cordys.crm.system.excel.handler.CustomHeadColWidthStyleStrategy; +import cn.cordys.crm.system.service.IntegrationConfigService; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.excel.utils.EasyExcelExporter; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import cn.idev.excel.FastExcelFactory; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.ibatis.session.ExecutorType; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.mybatis.spring.SqlSessionUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.*; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class BusinessTitleService { + + @Resource + private BaseMapper businessTitleMapper; + @Resource + private BaseMapper contractInvoiceMapper; + @Resource + private ExtBusinessTitleMapper extBusinessTitleMapper; + @Resource + private LogService logService; + @Resource + private BaseService baseService; + @Resource + private BaseMapper businessTitleConfigMapper; + @Resource + private IntegrationConfigService integrationConfigService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private SqlSessionFactory sqlSessionFactory; + + + /** + * 添加工商抬头 + * + * @param request + * @param userId + * @param orgId + * @return + */ + @OperationLog(module = LogModule.CONTRACT_BUSINESS_TITLE, type = LogType.ADD, resourceName = "{#request.name}") + public BusinessTitle add(BusinessTitleAddRequest request, String userId, String orgId) { + checkName(request.getName(), orgId, null); + + BusinessTitle businessTitle = BeanUtils.copyBean(new BusinessTitle(), request); + if (Strings.CI.equals(BusinessTitleType.CUSTOM.name(), businessTitle.getType())) { + businessTitle.setApprovalStatus(ContractApprovalStatus.APPROVING.name()); + } else { + businessTitle.setApprovalStatus(ContractApprovalStatus.APPROVED.name()); + } + businessTitle.setCreateTime(System.currentTimeMillis()); + businessTitle.setCreateUser(userId); + businessTitle.setUpdateTime(System.currentTimeMillis()); + businessTitle.setUpdateUser(userId); + businessTitle.setId(IDGenerator.nextStr()); + businessTitle.setOrganizationId(orgId); + + businessTitleMapper.insert(businessTitle); + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(businessTitle.getId()) + .resourceName(businessTitle.getName()) + .modifiedValue(businessTitle) + .build() + ); + return businessTitle; + } + + + /** + * 校验名称 + * + * @param businessName + * @param orgId + * @param id + */ + private void checkName(String businessName, String orgId, String id) { + if (extBusinessTitleMapper.countByName(businessName, orgId, id) > 0) { + throw new GenericException(Translator.get("business_title.exist")); + } + } + + + /** + * 编辑工商抬头 + * + * @param request + * @param userId + * @param orgId + * @return + */ + @OperationLog(module = LogModule.CONTRACT_BUSINESS_TITLE, type = LogType.UPDATE, resourceId = "{#request.id}") + public BusinessTitle update(BusinessTitleUpdateRequest request, String userId, String orgId) { + BusinessTitle oldTitle = checkTitle(request.getId()); + checkName(request.getName(), orgId, request.getId()); + + BusinessTitle newTitle = BeanUtils.copyBean(new BusinessTitle(), request); + if (Strings.CI.equals(BusinessTitleType.CUSTOM.name(), newTitle.getType())) { + newTitle.setApprovalStatus(ContractApprovalStatus.APPROVING.name()); + } else { + newTitle.setApprovalStatus(ContractApprovalStatus.APPROVED.name()); + } + newTitle.setUpdateTime(System.currentTimeMillis()); + newTitle.setUpdateUser(userId); + businessTitleMapper.update(newTitle); + + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceName(request.getName()) + .originalValue(oldTitle) + .modifiedValue(newTitle) + .build() + ); + return newTitle; + } + + private BusinessTitle checkTitle(String id) { + BusinessTitle title = businessTitleMapper.selectByPrimaryKey(id); + if (title == null) { + throw new GenericException(Translator.get("business_title.not.exist")); + } + return title; + } + + + /** + * 删除 + * + * @param id + */ + @OperationLog(module = LogModule.CONTRACT_BUSINESS_TITLE, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + BusinessTitle businessTitle = checkTitle(id); + if (!checkHasInvoice(id)) { + businessTitleMapper.deleteByPrimaryKey(id); + OperationLogContext.setResourceName(businessTitle.getName()); + } + } + + + /** + * 校验是否开过票 + * + * @param id + * @return + */ + public boolean checkHasInvoice(String id) { + LambdaQueryWrapper invoiceLambdaQueryWrapper = new LambdaQueryWrapper<>(); + invoiceLambdaQueryWrapper.eq(ContractInvoice::getBusinessTitleId, id); + List contractInvoices = contractInvoiceMapper.selectListByLambda(invoiceLambdaQueryWrapper); + return !CollectionUtils.isEmpty(contractInvoices); + } + + + /** + * 列表 + * + * @param request + * @param userId + * @param orgId + * @return + */ + public Pager> list(BusinessTitlePageRequest request, String userId, String orgId) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extBusinessTitleMapper.list(request, orgId, userId); + baseService.setCreateAndUpdateUserName(list); + return PageUtils.setPageInfo(page, list); + } + + + /** + * 详情 + * + * @param id + * @return + */ + public BusinessTitleListResponse get(String id) { + BusinessTitle businessTitle = businessTitleMapper.selectByPrimaryKey(id); + businessTitle.setCompanyNumber("CO.NO." + String.format("%08d", Long.parseLong(businessTitle.getCompanyNumber()))); + if (businessTitle == null) { + throw new GenericException(Translator.get("business_title.not.exist")); + } + BusinessTitleListResponse businessTitleListResponse = BeanUtils.copyBean(new BusinessTitleListResponse(), businessTitle); + baseService.setCreateAndUpdateUserName(List.of(businessTitleListResponse)); + return businessTitleListResponse; + } + + /** + * 获取工商抬头详情 (⚠️反射调用; 勿修改入参, 返回, 方法名!) + * + * @param id 抬头ID + * @return 工商抬头详情 + */ + public BusinessTitleListResponse getSimple(String id) { + BusinessTitle businessTitle = businessTitleMapper.selectByPrimaryKey(id); + businessTitle.setCompanyNumber("CO.NO." + String.format("%08d", Long.parseLong(businessTitle.getCompanyNumber()))); + if (businessTitle == null) { + return null; + } + return BeanUtils.copyBean(new BusinessTitleListResponse(), businessTitle); + } + + /** + * 批量获取工商抬头详情 (用于数据源批量查询优化) + * + * @param ids 抬头ID集合 + * @return 工商抬头详情列表 + */ + public List batchGetSimpleByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return Collections.emptyList(); + } + List titles = businessTitleMapper.selectByIds(ids); + if (CollectionUtils.isEmpty(titles)) { + return Collections.emptyList(); + } + for (BusinessTitle title : titles) { + title.setCompanyNumber("CO.NO." + String.format("%08d", Long.parseLong(title.getCompanyNumber()))); + } + return titles.stream().map(title -> BeanUtils.copyBean(new BusinessTitleListResponse(), title)).toList(); + } + + + /** + * 审核通过/不通过 + * + * @param request + * @param userId + * @param orgId + */ + public void approvalContract(BusinessTitleApprovalRequest request, String userId, String orgId) { + BusinessTitle businessTitle = checkTitle(request.getId()); + + String state = businessTitle.getApprovalStatus(); + businessTitle.setApprovalStatus(request.getApprovalStatus()); + businessTitle.setUpdateTime(System.currentTimeMillis()); + businessTitle.setUpdateUser(userId); + businessTitleMapper.update(businessTitle); + // 添加日志上下文 + LogDTO logDTO = getApprovalLogDTO(orgId, request.getId(), userId, businessTitle.getName(), state, request.getApprovalStatus()); + logService.add(logDTO); + } + + + private LogDTO getApprovalLogDTO(String orgId, String id, String userId, String response, String state, String newState) { + LogDTO logDTO = new LogDTO(orgId, id, userId, LogType.APPROVAL, LogModule.CONTRACT_BUSINESS_TITLE, response); + Map oldMap = new HashMap<>(); + oldMap.put("approvalStatus", Translator.get("contract.approval_status." + state.toLowerCase())); + logDTO.setOriginalValue(oldMap); + Map newMap = new HashMap<>(); + newMap.put("approvalStatus", Translator.get("contract.approval_status." + newState.toLowerCase())); + logDTO.setModifiedValue(newMap); + return logDTO; + } + + + /** + * 撤销审核 + * + * @param id + * @param userId + * @param orgId + * @return + */ + public String revoke(String id, String userId, String orgId) { + BusinessTitle businessTitle = checkTitle(id); + + String originApprovalStatus = businessTitle.getApprovalStatus(); + + businessTitle.setApprovalStatus(ApprovalState.REVOKED.toString()); + businessTitle.setUpdateUser(userId); + businessTitle.setUpdateTime(System.currentTimeMillis()); + businessTitleMapper.update(businessTitle); + + + // 添加日志上下文 + LogDTO logDTO = getApprovalLogDTO(orgId, id, userId, businessTitle.getName(), originApprovalStatus, ApprovalState.REVOKED.toString()); + logService.add(logDTO); + + return businessTitle.getApprovalStatus(); + } + + /** + * 下载模板 + * + * @param response + * @param orgId + */ + public void downloadImportTpl(HttpServletResponse response, String orgId) { + //获取表头字段 + List> heads = getTemplateHead(); + + new EasyExcelExporter() + .exportMultiSheetTplWithSharedHandler(response, heads, + Translator.get("business_title_import_tpl.name"), Translator.get(SheetKey.DATA), Translator.get(SheetKey.COMMENT), + new BusinessTitleTemplateWriteHandler(heads, getBusinessTitleConfig(orgId)), new CustomHeadColWidthStyleStrategy()); + } + + + private Map getBusinessTitleConfig(String orgId) { + LambdaQueryWrapper configWrapper = new LambdaQueryWrapper<>(); + configWrapper.eq(BusinessTitleConfig::getOrganizationId, orgId); + List businessTitleConfigs = businessTitleConfigMapper.selectListByLambda(configWrapper); + return businessTitleConfigs.stream().collect(Collectors.toMap(BusinessTitleConfig::getField, BusinessTitleConfig::getRequired)); + } + + private List> getTemplateHead() { + return new BusinessTitleExcelDataFactory().getExcelDataLocal().getHead(); + } + + + /** + * 导入检查 + * + * @param file + * @param orgId + * @return + */ + public ImportResponse importPreCheck(MultipartFile file, String orgId, BusinessTitleImportRequest request) { + if (file == null) { + throw new GenericException(Translator.get("file_cannot_be_null")); + } + return checkImportExcel(file, orgId, request); + } + + + /** + * 检查导入文件 + * + * @param file + * @param orgId + * @return + */ + private ImportResponse checkImportExcel(MultipartFile file, String orgId, BusinessTitleImportRequest request) { + try { + Class clazz = new UserExcelDataFactory().getExcelDataByLocal(); + BusinessTitleCheckEventListener eventListener = new BusinessTitleCheckEventListener(clazz, getBusinessTitleConfig(orgId), orgId, getTemplateHead(), request); + FastExcelFactory.read(file.getInputStream(), eventListener).headRowNumber(1).ignoreEmptyRow(true).sheet().doRead(); + return ImportResponse.builder().errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccess()).failCount(eventListener.getErrList().size()).build(); + } catch (Exception e) { + log.error("import pre-check error: {}", e.getMessage()); + throw new GenericException(e.getMessage()); + } + } + + + /** + * 导入 + * + * @param file + * @param userId + * @param orgId + * @return + */ + public ImportResponse realImport(MultipartFile file, String userId, String orgId, BusinessTitleImportRequest request) { + if (file == null) { + throw new GenericException(Translator.get("file_cannot_be_null")); + } + try { + Class clazz = new UserExcelDataFactory().getExcelDataByLocal(); + + BusinessTitleImportType businessTitleImportType = EnumUtils.valueOf(BusinessTitleImportType.class, request.getImportType()); + Consumer> afterDto = null; + switch (businessTitleImportType) { + case BusinessTitleImportType.ADD -> { + afterDto = (businessTitles) -> { + List logs = new ArrayList<>(); + businessTitles.forEach(title -> { + title.setType(BusinessTitleType.CUSTOM.name()); + title.setApprovalStatus(ContractApprovalStatus.APPROVING.name()); + logs.add(new LogDTO(orgId, title.getId(), userId, LogType.ADD, LogModule.CONTRACT_BUSINESS_TITLE, title.getName())); + }); + businessTitleMapper.batchInsert(businessTitles); + logService.batchAdd(logs); + }; + } + case BusinessTitleImportType.UPDATE -> { + SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); + ExtBusinessTitleMapper mapper = sqlSession.getMapper(ExtBusinessTitleMapper.class); + afterDto = (businessTitles) -> { + List titleList = extBusinessTitleMapper.selectByNames(businessTitles.stream().map(BusinessTitle::getName).toList()); + Map nameNap = titleList.stream().collect(Collectors.toMap(BusinessTitle::getName, Function.identity())); + List logs = new ArrayList<>(); + Set titleSet = new HashSet<>(); + businessTitles.removeIf(user -> !titleSet.add(user.getName())); + businessTitles.forEach(title -> { + //1.通过name查询id + //2.setId 并更新 + if (nameNap.containsKey(title.getName())) { + BusinessTitle originTitle = nameNap.get(title.getName()); + BeanCopyUtils.fillEmptyFields(title, originTitle); + title.setId(originTitle.getId()); + title.setUpdateTime(System.currentTimeMillis()); + title.setUpdateUser(userId); + mapper.updateById(title); + LogDTO logDTO = new LogDTO(orgId, title.getId(), userId, LogType.UPDATE, LogModule.CONTRACT_BUSINESS_TITLE, title.getName()); + logDTO.setOriginalValue(originTitle); + logDTO.setModifiedValue(title); + logs.add(logDTO); + } + }); + sqlSession.flushStatements(); + SqlSessionUtils.closeSqlSession(sqlSession, sqlSessionFactory); + logService.batchAdd(logs); + }; + } + } + + var eventListener = new BusinessTitleImportEventListener(clazz, BusinessTitle.class, getBusinessTitleConfig(orgId), orgId, userId, afterDto, getTemplateHead(), request); + FastExcelFactory.read(file.getInputStream(), eventListener).headRowNumber(1).ignoreEmptyRow(true).sheet().doRead(); + return ImportResponse.builder().errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccessCount()).failCount(eventListener.getErrList().size()).build(); + } catch (Exception e) { + log.error("importExcel error", e); + throw new GenericException(e.getMessage()); + } + } + + + /** + * 三方查询 + * + * @param keyword + * @param orgId + */ + public BusinessTitle thirdQuery(String keyword, String orgId) { + BusinessTitle businessTitle = new BusinessTitle(); + ThirdConfigBaseDTO thirdConfig = integrationConfigService.getThirdConfigForPublic(ThirdConfigTypeConstants.QCC.name(), orgId); + if (thirdConfig.getVerify()) { + QccThirdConfigRequest qccConfig = JSON.MAPPER.convertValue(thirdConfig.getConfig(), QccThirdConfigRequest.class); + if (qccConfig != null && qccConfig.getQccEnable()) { + businessTitle = getTitleInfo(keyword, qccConfig); + } + } + + return businessTitle; + } + + private BusinessTitle getTitleInfo(String keyword, QccThirdConfigRequest qccConfig) { + Map headers = buildHeaders(qccConfig); + try { + String url = HttpClientUtils.urlTransfer(qccConfig.getQccAddress().concat(QccApiPaths.ENTERPRISE_INFO_VERIFY_API), qccConfig.getQccAccessKey(), keyword); + String json = HttpClientUtils.sendGetRequest(url, headers); + QccEnterpriseInfo qccEnterpriseInfo = JSON.parseObject(json, QccEnterpriseInfo.class); + if (!Strings.CI.equals("200", qccEnterpriseInfo.getStatus())) { + throw new GenericException(qccEnterpriseInfo.getMessage()); + } + return buildBusinessTitle(qccEnterpriseInfo.getResult()); + } catch (Exception e) { + log.error("查询企业信息异常", e); + throw new GenericException(e.getMessage()); + } + } + + private BusinessTitle buildBusinessTitle(EnterpriseInfo enterpriseInfo) { + BusinessTitle businessTitle = new BusinessTitle(); + if (enterpriseInfo != null) { + InfoData data = enterpriseInfo.getData(); + businessTitle.setName(data.getName()); + businessTitle.setIdentificationNumber(data.getTaxNo()); + if (data.getBankInfo() != null) { + businessTitle.setOpeningBank(data.getBankInfo().getBank()); + businessTitle.setBankAccount(data.getBankInfo().getBankAccount()); + } + businessTitle.setRegistrationAddress(data.getAddress()); + if (data.getContactInfo() != null) { + businessTitle.setPhoneNumber(data.getContactInfo().getTel()); + } + businessTitle.setRegisteredCapital(data.getRegisterCapi()); + businessTitle.setCompanySize(data.getPersonScope()); + businessTitle.setRegistrationNumber(data.getNo()); + if (data.getArea() != null) { + String province = data.getArea().getProvince(); + String city = data.getArea().getCity(); + businessTitle.setProvince(province); + businessTitle.setCity(city); + } + businessTitle.setScale(data.getScale()); + if (data.getIndustry() != null) { + businessTitle.setIndustry(data.getIndustry().getSubIndustry()); + } + } + return businessTitle; + } + + + /** + * 分页查询 + * + * @param keyword + * @param pageIndex + * @param orgId + * @return + */ + public Pager> thirdQueryOption(String keyword, Integer pageIndex, String orgId) { + Pager> page = new Pager<>(); + ThirdConfigBaseDTO thirdConfig = integrationConfigService.getThirdConfigForPublic(ThirdConfigTypeConstants.QCC.name(), orgId); + if (thirdConfig.getVerify()) { + QccThirdConfigRequest qccConfig = JSON.MAPPER.convertValue(thirdConfig.getConfig(), QccThirdConfigRequest.class); + if (qccConfig != null && qccConfig.getQccEnable()) { + getNameList(keyword, qccConfig, String.valueOf(pageIndex), page); + } + } + return page; + } + + private void getNameList(String keyword, QccThirdConfigRequest qccConfig, String pageIndex, Pager> page) { + Map headers = buildHeaders(qccConfig); + try { + String url = HttpClientUtils.urlTransfer(qccConfig.getQccAddress().concat(QccApiPaths.FUZZY_SEARCH_LIST_API), qccConfig.getQccAccessKey(), keyword, pageIndex); + String json = HttpClientUtils.sendGetRequest(url, headers); + QccFuzzyQueryInfo queryInfo = JSON.parseObject(json, QccFuzzyQueryInfo.class); + if (!Strings.CI.equals("200", queryInfo.getStatus())) { + throw new GenericException(queryInfo.getMessage()); + } + buildPageInfo(queryInfo, page); + } catch (Exception e) { + log.error("查询企业信息异常", e); + throw new GenericException(e.getMessage()); + } + } + + private void buildPageInfo(QccFuzzyQueryInfo queryInfo, Pager> page) { + if (queryInfo.getPaging() != null) { + Paging paging = queryInfo.getPaging(); + page.setTotal(paging.getTotalRecords()); + page.setPageSize(paging.getPageSize()); + page.setCurrent(paging.getPageIndex()); + } + + if (queryInfo.getResult() != null) { + List names = queryInfo.getResult().stream().map(NameInfo::getName).toList(); + page.setList(names); + } + } + + private Map buildHeaders(QccThirdConfigRequest qccConfig) { + long time = System.currentTimeMillis() / 1000; + String token = CodingUtils.md5(qccConfig.getQccAccessKey() + time + qccConfig.getQccSecretKey()).toUpperCase(); + Map headers = new HashMap<>(); + headers.put("Token", token); + headers.put("Timespan", String.valueOf(time)); + return headers; + } + + public ModuleFormConfigDTO getBusinessFormConfig() { + ModuleFormConfigDTO moduleFormConfigDTO = new ModuleFormConfigDTO(); + List fields = moduleFormService.initBusinessTitleFields(); + moduleFormConfigDTO.setFields(fields); + return moduleFormConfigDTO; + } + + public List selectByIds(List ids) { + return businessTitleMapper.selectByIds(ids); + } + + public BusinessTitle selectById(String id) { + if (StringUtils.isBlank(id)) { + return null; + } + return businessTitleMapper.selectByPrimaryKey(id); + } + + public String getBusinessTitleName(String string) { + BusinessTitle businessTitle = businessTitleMapper.selectByPrimaryKey(string); + return Optional.ofNullable(businessTitle).map(BusinessTitle::getName).orElse(null); + } + + /** + * 通过名称获取工商表头集合 + * + * @param names 名称 + * @return 工商表头集合 + */ + public List getBusinessTitleListByNames(List names) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.in(BusinessTitle::getName, names); + return businessTitleMapper.selectListByLambda(lambdaQueryWrapper); + } + + /** + * 通过ID集合获取工商表头名称 + * + * @param ids id集合 + * @return 工商表头名称 + */ + public String getTitleNameByIds(List ids) { + List titles = businessTitleMapper.selectByIds(ids); + if (CollectionUtils.isNotEmpty(titles)) { + List names = titles.stream().map(BusinessTitle::getName).toList(); + return String.join(",", names); + } + return StringUtils.EMPTY; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractExportService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractExportService.java new file mode 100644 index 0000000..4688321 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractExportService.java @@ -0,0 +1,225 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.ExportDTO; +import cn.cordys.common.dto.ExportFieldParam; +import cn.cordys.common.dto.FieldExportMeta; +import cn.cordys.common.dto.stage.StageConfigResponse; +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.common.resolver.field.ModuleFieldResolverFactory; +import cn.cordys.common.service.BaseExportService; +import cn.cordys.common.util.TimeUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.approval.service.ApprovalFlowService; +import com.github.pagehelper.PageHelper; +import cn.cordys.crm.contract.dto.request.ContractPageRequest; +import cn.cordys.crm.contract.dto.response.ContractListResponse; +import cn.cordys.crm.contract.mapper.ExtContractMapper; +import cn.cordys.crm.contract.mapper.ExtContractStageConfigMapper; +import cn.cordys.crm.system.excel.domain.MergeResult; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.registry.ExportThreadRegistry; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class ContractExportService extends BaseExportService { + + @Resource + private ContractService contractService; + @Resource + private ExtContractMapper extContractMapper; + @Resource + private ModuleFormService moduleFormService; + @Resource + private ApprovalFlowService approvalFlowService; + + private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + @Resource + private ExtContractStageConfigMapper extContractStageConfigMapper; + + @Override + protected MergeResult getExportMergeData(String taskId, ExportDTO exportParam) { + var collectResult = collectExportList(exportParam); + var exportList = collectResult.getLeft(); + var queryCount = collectResult.getRight(); + if (CollectionUtils.isEmpty(exportList)) { + return MergeResult.builder().dataList(new ArrayList<>()).mergeRegions(new ArrayList<>()).handleCount(0).build(); + } + var dataList = contractService.buildList(exportList, exportParam.getOrgId()); + moduleFormService.getBaseModuleFieldValues(dataList, ContractListResponse::getModuleFields); + var exportFieldParam = exportParam.getExportFieldParam(); + Map stageConfigMap = + extContractStageConfigMapper.getStageConfigList(exportParam.getOrgId()) + .stream() + .collect(Collectors.toMap( + StageConfigResponse::getId, + StageConfigResponse::getName + )); + return parallelBuildMergeResult(taskId, exportParam, dataList, exportFieldParam, stageConfigMap, queryCount); + } + + private Pair, Integer> collectExportList(ExportDTO exportParam) { + var orgId = exportParam.getOrgId(); + var userId = exportParam.getUserId(); + var deptDataPermission = exportParam.getDeptDataPermission(); + if (CollectionUtils.isNotEmpty(exportParam.getSelectIds())) { + // 勾选导出:先查询勾选的数据,然后过滤导出权限 + List exportList = extContractMapper.getListByIds(exportParam.getSelectIds(), userId, orgId, deptDataPermission); + int queryCount = exportList.size(); + return Pair.of(filterExportPermission(exportList, orgId), queryCount); + } + // 全量导出:先查询分页数据,然后过滤导出权限 + var request = (ContractPageRequest) exportParam.getPageRequest(); + // offsetAsPageNum=true 时,第一个参数是偏移量而非页码,需要手动计算 + int offset = (request.getCurrent() - 1) * request.getPageSize(); + PageHelper.startPage(offset, request.getPageSize()); + List exportList = extContractMapper.list(request, orgId, userId, deptDataPermission, false); + int queryCount = exportList.size(); + return Pair.of(filterExportPermission(exportList, orgId), queryCount); + } + + /** + * 根据审批流状态权限过滤可导出的数据 + * + * @param exportList 原始导出数据列表 + * @param orgId 组织ID + * @return 过滤后可导出的数据列表 + */ + private List filterExportPermission(List exportList, String orgId) { + return filterApprovalExportPermission(exportList, orgId, FormKey.CONTRACT.getKey(), + ContractListResponse::getId, ContractListResponse::getApprovalStatus, + approvalFlowService); + } + + /** + * 并行构建导出数据及合并区域 + * + * @param taskId 导出任务ID + * @param exportParam 导出参数 + * @param dataList 数据列表 + * @param exportFieldParam 导出字段参数 + * @param queryCount 原始查询数量(过滤前) + * @return 合并结果 + */ + private MergeResult parallelBuildMergeResult(String taskId, ExportDTO exportParam, + List dataList, + ExportFieldParam exportFieldParam, Map stageConfigMap, int queryCount) { + int size = dataList.size(); + var cacheMap = new ConcurrentHashMap<>(); + List> mergeRowData = new ArrayList<>(size); + List mergeRegions = new ArrayList<>(); + + Semaphore dbSemaphore = new Semaphore(100); + List>>>> futures = new ArrayList<>(size); + + for (int i = 0; i < size; i++) { + final int idx = i; + ContractListResponse detail = dataList.get(i); + futures.add(executor.submit(() -> { + if (ExportThreadRegistry.isInterrupted(taskId)) { + throw new InterruptedException("导出中断"); + } + // 获取数据库访问许可 + dbSemaphore.acquire(); + try { + List> buildData = buildData(detail, exportFieldParam, exportParam.getExportMetas(), cacheMap, stageConfigMap); + return Pair.of(idx, buildData); + } finally { + dbSemaphore.release(); // 确保释放 + } + })); + } + + // 收集结果(阻塞) + List>>> results = new ArrayList<>(size); + for (Future>>> f : futures) { + try { + results.add(f.get()); + } catch (Exception e) { + log.error("Parse row data error: {}", e.getMessage()); + } + } + + results.sort(Comparator.comparingInt(Pair::getLeft)); + + int offset = 0; + for (Pair>> r : results) { + List> buildData = r.getRight(); + if (buildData.size() > 1) { + mergeRegions.add(new int[]{offset, offset + buildData.size() - 1}); + } + offset += buildData.size(); + mergeRowData.addAll(buildData); + } + + cacheMap.clear(); + + return MergeResult.builder() + .mergeRegions(mergeRegions) + .dataList(mergeRowData) + .handleCount(queryCount) + .build(); + } + + private List> buildData(ContractListResponse detail, ExportFieldParam exportFieldParam, List exportMetas, Map cacheMap, Map stageConfigMap) { + return buildDataWithSub(detail.getModuleFields(), exportFieldParam, exportMetas, getSystemFieldMap(detail, exportMetas, stageConfigMap), cacheMap); + } + + public LinkedHashMap getSystemFieldMap(ContractListResponse data, List exportMetas, Map stageConfigMap) { + LinkedHashMap systemFieldMap = new LinkedHashMap<>(); + systemFieldMap.put("name", data.getName()); + systemFieldMap.put("owner", data.getOwnerName()); + systemFieldMap.put("departmentId", data.getDepartmentName()); + systemFieldMap.put("customerId", data.getCustomerName()); + systemFieldMap.put("amount", data.getAmount()); + systemFieldMap.put("alreadyPayAmount", data.getAlreadyPayAmount()); + systemFieldMap.put("number", data.getNumber()); + + if (StringUtils.isNotBlank(data.getApprovalStatus())) { + systemFieldMap.put("approvalStatus", Translator.get("contract.approval_status." + data.getApprovalStatus().toLowerCase(), Locale.SIMPLIFIED_CHINESE)); + } + if (StringUtils.isNotBlank(data.getStage())) { + systemFieldMap.put("stage", stageConfigMap.get(data.getStage())); + } + + systemFieldMap.put("createUser", data.getCreateUserName()); + systemFieldMap.put("createTime", TimeUtils.getDateTimeStr(data.getCreateTime())); + systemFieldMap.put("updateUser", data.getUpdateUserName()); + systemFieldMap.put("updateTime", TimeUtils.getDateTimeStr(data.getUpdateTime())); + systemFieldMap.put("voidReason", data.getVoidReason()); + + // 将 exportMetas 转为 Map,避免重复遍历 + Map metaMap = exportMetas.stream() + .collect(Collectors.toMap(FieldExportMeta::getBusinessKey, Function.identity(), (a, b) -> a)); + + resolveAndPutTimeField(systemFieldMap, metaMap, "startTime", String.valueOf(data.getStartTime())); + resolveAndPutTimeField(systemFieldMap, metaMap, "endTime", String.valueOf(data.getEndTime())); + + return systemFieldMap; + } + + private void resolveAndPutTimeField(LinkedHashMap map, + Map metaMap, + String businessKey, + String rawValue) { + FieldExportMeta meta = metaMap.get(businessKey); + if (meta != null) { + AbstractModuleFieldResolver resolver = ModuleFieldResolverFactory.getResolver(meta.getField().getType()); + map.put(businessKey, resolver.transformToValue(meta.getField(), rawValue)); + } + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractFieldService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractFieldService.java new file mode 100644 index 0000000..d54b4c9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractFieldService.java @@ -0,0 +1,35 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.crm.contract.domain.ContractField; +import cn.cordys.crm.contract.domain.ContractFieldBlob; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(rollbackFor = Exception.class) +public class ContractFieldService extends BaseResourceFieldService { + + @Resource + private BaseMapper contractFieldMapper; + @Resource + private BaseMapper contractFieldBlobMapper; + + @Override + protected String getFormKey() { + return FormKey.CONTRACT.getKey(); + } + + @Override + protected BaseMapper getResourceFieldMapper() { + return contractFieldMapper; + } + + @Override + protected BaseMapper getResourceFieldBlobMapper() { + return contractFieldBlobMapper; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractInvoiceExportService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractInvoiceExportService.java new file mode 100644 index 0000000..8cda298 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractInvoiceExportService.java @@ -0,0 +1,162 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.ExportDTO; +import cn.cordys.common.dto.ExportHeadDTO; +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.common.resolver.field.ModuleFieldResolverFactory; +import cn.cordys.common.service.BaseExportService; +import cn.cordys.common.util.TimeUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.approval.service.ApprovalFlowService; +import cn.cordys.crm.contract.dto.request.ContractInvoicePageRequest; +import cn.cordys.crm.contract.dto.response.ContractInvoiceListResponse; +import cn.cordys.crm.contract.mapper.ExtContractInvoiceMapper; +import cn.cordys.crm.system.dto.field.SelectField; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.registry.ExportThreadRegistry; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class ContractInvoiceExportService extends BaseExportService { + + @Resource + private ContractInvoiceService contractInvoiceService; + @Resource + private ExtContractInvoiceMapper extContractInvoiceMapper; + @Resource + private ApprovalFlowService approvalFlowService; + + /** + * 构建导出的数据 + * + * @return 导出数据列表 + */ + @Override + public List> getExportData(String taskId, ExportDTO exportDTO) throws InterruptedException { + ContractInvoicePageRequest pageRequest = (ContractInvoicePageRequest) exportDTO.getPageRequest(); + String orgId = exportDTO.getOrgId(); + PageHelper.startPage(pageRequest.getCurrent(), pageRequest.getPageSize()); + //获取数据 + List allList = extContractInvoiceMapper.list(pageRequest, orgId, exportDTO.getUserId(), exportDTO.getDeptDataPermission()); + // 根据审批状态导出权限过滤 + allList = filterExportPermission(allList, orgId); + if (CollectionUtils.isEmpty(allList)) { + return new ArrayList<>(); + } + List dataList = contractInvoiceService.buildList(allList, orgId); + Map fieldConfigMap = getFieldConfigMap(FormKey.INVOICE.getKey(), orgId); + + + //构建导出数据 + List> data = new ArrayList<>(); + for (ContractInvoiceListResponse response : dataList) { + if (ExportThreadRegistry.isInterrupted(taskId)) { + throw new InterruptedException("线程已被中断,主动退出"); + } + List value = buildData(exportDTO.getHeadList(), response, fieldConfigMap); + data.add(value); + } + + return data; + } + + private List buildData(List headList, ContractInvoiceListResponse data, + Map fieldConfigMap) { + //固定字段map + LinkedHashMap systemFieldMap = getSystemFieldMap(data, fieldConfigMap); + //自定义字段map + Map moduleFieldMap = getFieldIdValueMap(data.getModuleFields()); + //处理数据转换 + return transModuleFieldValue(headList, systemFieldMap, moduleFieldMap, new ArrayList<>(), fieldConfigMap); + } + + public LinkedHashMap getSystemFieldMap(ContractInvoiceListResponse data, Map fieldConfigMap) { + LinkedHashMap systemFieldMap = new LinkedHashMap<>(); + systemFieldMap.put("contractId", data.getContractName()); + systemFieldMap.put("owner", data.getOwnerName()); + systemFieldMap.put("name", data.getName()); + systemFieldMap.put("departmentId", data.getDepartmentName()); + systemFieldMap.put("amount", data.getAmount()); + + BaseField taxRate = fieldConfigMap.values().stream().filter(field -> Strings.CI.equals(field.getBusinessKey(), "taxRate")).findFirst().orElse(null); + if (taxRate != null && data.getTaxRate() != null) { + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(taxRate.getType()); + systemFieldMap.put("taxRate", customFieldResolver.transformToValue(taxRate, data.getTaxRate().stripTrailingZeros().toPlainString())); + } + systemFieldMap.put("businessTitleId", data.getBusinessTitleName()); + systemFieldMap.put("approvalStatus", data.getApprovalStatus() == null ? null : Translator.get("contract.approval_status." + data.getApprovalStatus().toLowerCase())); + + for (BaseField field : fieldConfigMap.values()) { + if (Strings.CS.equals(BusinessModuleField.INVOICE_INVOICE_TYPE.getBusinessKey(), field.getBusinessKey()) + && field instanceof SelectField invoiceTypeField) { + String invoiceTypeName = getOptionLabel(data.getInvoiceType(), invoiceTypeField.getOptions()); + systemFieldMap.put("invoiceType", invoiceTypeName); + } + } + + systemFieldMap.put("createUser", data.getCreateUserName()); + systemFieldMap.put("createTime", TimeUtils.getDateTimeStr(data.getCreateTime())); + systemFieldMap.put("updateUser", data.getUpdateUserName()); + systemFieldMap.put("updateTime", TimeUtils.getDateTimeStr(data.getUpdateTime())); + return systemFieldMap; + } + + + /** + * 选中回款计划数据 + * + * @return 导出数据列表 + */ + @Override + public List> getSelectExportData(List ids, String taskId, ExportDTO exportDTO) throws InterruptedException { + String orgId = exportDTO.getOrgId(); + String userId = exportDTO.getUserId(); + //获取数据 + List allList = extContractInvoiceMapper.getListByIds(ids, userId, orgId, exportDTO.getDeptDataPermission()); + // 根据审批状态导出权限过滤 + allList = filterExportPermission(allList, orgId); + if (CollectionUtils.isEmpty(allList)) { + return new ArrayList<>(); + } + List dataList = contractInvoiceService.buildList(allList, orgId); + Map fieldConfigMap = getFieldConfigMap(FormKey.INVOICE.getKey(), orgId); + //构建导出数据 + List> data = new ArrayList<>(); + for (ContractInvoiceListResponse response : dataList) { + if (ExportThreadRegistry.isInterrupted(taskId)) { + throw new InterruptedException("线程已被中断,主动退出"); + } + List value = buildData(exportDTO.getHeadList(), response, fieldConfigMap); + data.add(value); + } + return data; + } + + /** + * 根据审批流状态权限过滤可导出的数据 + * + * @param exportList 原始导出数据列表 + * @param orgId 组织ID + * @return 过滤后可导出的数据列表 + */ + private List filterExportPermission(List exportList, String orgId) { + return filterApprovalExportPermission(exportList, orgId, FormKey.INVOICE.getKey(), + ContractInvoiceListResponse::getId, ContractInvoiceListResponse::getApprovalStatus, + approvalFlowService); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractInvoiceFieldService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractInvoiceFieldService.java new file mode 100644 index 0000000..6bb51b3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractInvoiceFieldService.java @@ -0,0 +1,35 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.crm.contract.domain.ContractInvoiceField; +import cn.cordys.crm.contract.domain.ContractInvoiceFieldBlob; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(rollbackFor = Exception.class) +public class ContractInvoiceFieldService extends BaseResourceFieldService { + + @Resource + private BaseMapper contractInvoiceFieldMapper; + @Resource + private BaseMapper contractInvoiceFieldBlobMapper; + + @Override + protected String getFormKey() { + return FormKey.INVOICE.getKey(); + } + + @Override + protected BaseMapper getResourceFieldMapper() { + return contractInvoiceFieldMapper; + } + + @Override + protected BaseMapper getResourceFieldBlobMapper() { + return contractInvoiceFieldBlobMapper; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractInvoiceResourceAccessContextProvider.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractInvoiceResourceAccessContextProvider.java new file mode 100644 index 0000000..a5e2664 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractInvoiceResourceAccessContextProvider.java @@ -0,0 +1,47 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.permission.ResourceAccessContext; +import cn.cordys.common.permission.ResourceAccessContextProvider; +import cn.cordys.crm.contract.domain.ContractInvoice; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class ContractInvoiceResourceAccessContextProvider implements ResourceAccessContextProvider { + + @Resource + private BaseMapper contractInvoiceMapper; + + @Override + public String getFormType() { + return FormKey.INVOICE.getKey(); + } + + @Override + public ResourceAccessContext getAccessContext(String resourceId, String orgId) { + ContractInvoice invoice = contractInvoiceMapper.selectByPrimaryKey(resourceId); + if (invoice == null) { + return null; + } + ResourceAccessContext context = new ResourceAccessContext(); + context.setOwnerId(invoice.getOwner()); + context.setApprovalStatus(invoice.getApprovalStatus()); + return context; + } + + @Override + public Map batchGetOwnerIds(List resourceIds, String orgId) { + if (resourceIds == null || resourceIds.isEmpty()) { + return Map.of(); + } + return contractInvoiceMapper.selectByIds(resourceIds).stream() + .filter(invoice -> invoice.getOwner() != null) + .collect(Collectors.toMap(ContractInvoice::getId, ContractInvoice::getOwner, (a, b) -> a)); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractInvoiceService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractInvoiceService.java new file mode 100644 index 0000000..5b09499 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractInvoiceService.java @@ -0,0 +1,744 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.*; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.permission.PermissionUtils; +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.common.resolver.field.ModuleFieldResolverFactory; +import cn.cordys.common.response.result.CrmHttpResultCode; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.approval.annotation.HitApproval; +import cn.cordys.crm.approval.constants.ApprovalFormTypeEnum; +import cn.cordys.crm.approval.constants.ApprovalStatus; +import cn.cordys.crm.approval.constants.ExecuteTimingEnum; +import cn.cordys.crm.approval.dto.ResourceApprovalFieldUpdateParam; +import cn.cordys.crm.approval.dto.ResourceApprovalPostUpdateParam; +import cn.cordys.crm.approval.dto.ResourceSnapshotApprovalParam; +import cn.cordys.crm.approval.handler.ApprovalResourceHandler; +import cn.cordys.crm.approval.service.ApprovalFlowService; +import cn.cordys.crm.approval.service.ApprovalResourceService; +import cn.cordys.crm.contract.constants.BusinessTitleConstants; +import cn.cordys.crm.contract.domain.*; +import cn.cordys.crm.contract.dto.request.ContractInvoiceAddRequest; +import cn.cordys.crm.contract.dto.request.ContractInvoicePageRequest; +import cn.cordys.crm.contract.dto.request.ContractInvoiceUpdateRequest; +import cn.cordys.crm.contract.dto.response.ContractInvoiceGetResponse; +import cn.cordys.crm.contract.dto.response.ContractInvoiceListResponse; +import cn.cordys.crm.contract.mapper.ExtContractInvoiceMapper; +import cn.cordys.crm.system.constants.DictModule; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.DictService; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class ContractInvoiceService implements ApprovalResourceHandler { + + @Resource + private ContractInvoiceFieldService invoiceFieldService; + @Resource + private BaseMapper invoiceMapper; + @Resource + private BaseService baseService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private BaseMapper snapshotBaseMapper; + @Resource + private ExtContractInvoiceMapper extContractInvoiceMapper; + @Resource + private BaseMapper contractInvoiceMapper; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private PermissionCache permissionCache; + @Resource + private BaseMapper contractMapper; + @Resource + private LogService logService; + @Resource + private BusinessTitleService businessTitleService; + @Resource + private DictService dictService; + @Resource + private ApprovalFlowService approvalFlowService; + + /** + * 合同列表 + * + * @param request + * @param userId + * @param orgId + * @param deptDataPermission + * @return + */ + public PagerWithOption> list(ContractInvoicePageRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extContractInvoiceMapper.list(request, orgId, userId, deptDataPermission); + List results = buildList(list, orgId); + Map> optionMap = buildOptionMap(list, results, orgId); + + return PageUtils.setPageInfoWithOption(page, results, optionMap); + } + + private Map> buildOptionMap(List list, List buildList, String orgId) { + ModuleFormConfigDTO formConfig = getFormConfig(orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, ContractInvoiceListResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(formConfig, moduleFieldValues); + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(buildList, + ContractInvoiceListResponse::getOwner, ContractInvoiceListResponse::getOwnerName); + optionMap.put(BusinessModuleField.INVOICE_OWNER.getBusinessKey(), ownerFieldOption); + // 补充工商抬头选项 + List businessTitleFieldOption = moduleFormService.getBusinessFieldOption(buildList, + ContractInvoiceListResponse::getBusinessTitleId, ContractInvoiceListResponse::getBusinessTitleName); + optionMap.put(BusinessModuleField.INVOICE_BUSINESS_TITLE_ID.getBusinessKey(), businessTitleFieldOption); + return optionMap; + } + + /** + * 新建合同 + * + * @param request + * @param operatorId + * @param orgId + * @return + */ + @OperationLog(module = LogModule.CONTRACT_INVOICE, type = LogType.ADD) + @HitApproval(formKey = FormKey.INVOICE, executeType = ExecuteTimingEnum.CREATE, resourceId = "#{request.id}", operatorId = "{#operatorId}") + public ContractInvoice add(ContractInvoiceAddRequest request, String operatorId, String orgId) { + List moduleFields = request.getModuleFields(); + ModuleFormConfigDTO moduleFormConfigDTO = request.getModuleFormConfigDTO(); + if (CollectionUtils.isEmpty(moduleFields)) { + throw new GenericException(Translator.get("invoice.field.required")); + } + if (moduleFormConfigDTO == null) { + throw new GenericException(Translator.get("invoice.form.config.required")); + } + + Contract contract = contractMapper.selectByPrimaryKey(request.getContractId()); + BigDecimal contractInvoiceValidAmount = extContractInvoiceMapper.calculateContractInvoiceValidAmount(request.getContractId(), operatorId, orgId, null); + if (request.getAmount() != null && contract != null && request.getAmount().compareTo(contract.getAmount().subtract(contractInvoiceValidAmount)) > 0) { + // 校验发票金额 + throw new GenericException(Translator.get("invoice.amount.exceed")); + } + + ModuleFormConfigDTO saveModuleFormConfigDTO = JSON.parseObject(JSON.toJSONString(moduleFormConfigDTO), ModuleFormConfigDTO.class); + ContractInvoice invoice = BeanUtils.copyBean(new ContractInvoice(), request); + String id = IDGenerator.nextStr(); + invoice.setId(id); + invoice.setOrganizationId(orgId); + invoice.setCreateTime(System.currentTimeMillis()); + invoice.setCreateUser(operatorId); + invoice.setUpdateTime(System.currentTimeMillis()); + invoice.setUpdateUser(operatorId); + invoice.setApprovalStatus(ApprovalStatus.NONE.name()); + + if (StringUtils.isBlank(request.getOwner())) { + invoice.setOwner(operatorId); + } + + //自定义字段 + invoiceFieldService.saveModuleField(invoice, orgId, operatorId, moduleFields, false); + invoiceMapper.insert(invoice); + + baseService.handleAddLog(invoice, request.getModuleFields()); + OperationLogContext.getContext().setResourceName(invoice.getName()); + OperationLogContext.getContext().setResourceId(invoice.getId()); + + // 保存表单配置快照 + List resolveFieldValues = moduleFormService.resolveSnapshotFields(moduleFields, moduleFormConfigDTO, invoiceFieldService, invoice.getId()); + ContractInvoiceGetResponse response = get(invoice, resolveFieldValues, moduleFormConfigDTO); + saveSnapshot(invoice, saveModuleFormConfigDTO, response); + + return invoice; + } + + /** + * 保存合同快照 + * + * @param invoice + * @param moduleFormConfigDTO + * @param response + */ + private void saveSnapshot(ContractInvoice invoice, ModuleFormConfigDTO moduleFormConfigDTO, ContractInvoiceGetResponse response) { + ContractInvoiceSnapshot snapshot = new ContractInvoiceSnapshot(); + snapshot.setId(IDGenerator.nextStr()); + snapshot.setInvoiceId(invoice.getId()); + snapshot.setInvoiceProp(JSON.toJSONString(moduleFormConfigDTO)); + snapshot.setInvoiceValue(JSON.toJSONString(response)); + snapshotBaseMapper.insert(snapshot); + } + + /** + * 编辑合同 + * + * @param request + * @param userId + * @param orgId + * @return + */ + @OperationLog(module = LogModule.CONTRACT_INVOICE, type = LogType.UPDATE, resourceId = "{#request.id}") + @HitApproval(formKey = FormKey.INVOICE, executeType = ExecuteTimingEnum.UPDATE, resourceId = "{#request.id}", updateType = "{#request.updateType}", operatorId = "{#userId}", comment = "{#request.comment}") + public ContractInvoice update(ContractInvoiceUpdateRequest request, String userId, String orgId) { + ContractInvoice originContractInvoice = invoiceMapper.selectByPrimaryKey(request.getId()); + List moduleFields = request.getModuleFields(); + ModuleFormConfigDTO moduleFormConfigDTO = request.getModuleFormConfigDTO(); + if (CollectionUtils.isEmpty(moduleFields)) { + throw new GenericException(Translator.get("invoice.field.required")); + } + if (moduleFormConfigDTO == null) { + throw new GenericException(Translator.get("invoice.form.config.required")); + } + + String contractId = request.getContractId() == null ? originContractInvoice.getContractId() : request.getContractId(); + Contract contract = contractMapper.selectByPrimaryKey(contractId); + BigDecimal contractInvoiceValidAmount = extContractInvoiceMapper.calculateContractInvoiceValidAmount(request.getContractId(), userId, orgId, request.getId()); + if (request.getAmount() != null && contract != null && request.getAmount().compareTo(contract.getAmount().subtract(contractInvoiceValidAmount)) > 0) { + // 校验发票金额 + throw new GenericException(Translator.get("invoice.amount.exceed")); + } + + ModuleFormConfigDTO saveModuleFormConfigDTO = JSON.parseObject(JSON.toJSONString(moduleFormConfigDTO), ModuleFormConfigDTO.class); + Optional.of(originContractInvoice).ifPresentOrElse(item -> { + List originFields = invoiceFieldService.getModuleFieldValuesByResourceId(request.getId()); + ContractInvoice invoice = BeanUtils.copyBean(new ContractInvoice(), request); + invoice.setUpdateTime(System.currentTimeMillis()); + invoice.setUpdateUser(userId); + // 保留不可更改的字段 + invoice.setCreateUser(originContractInvoice.getCreateUser()); + invoice.setCreateTime(originContractInvoice.getCreateTime()); + invoice.setApprovalStatus(originContractInvoice.getApprovalStatus()); + + updateFields(moduleFields, invoice, orgId, userId); + invoiceMapper.update(invoice); + //删除快照 + LambdaQueryWrapper delWrapper = new LambdaQueryWrapper<>(); + delWrapper.eq(ContractInvoiceSnapshot::getInvoiceId, request.getId()); + List invoiceSnapshots = snapshotBaseMapper.selectListByLambda(delWrapper); + if (CollectionUtils.isNotEmpty(invoiceSnapshots)) { + ContractInvoiceSnapshot first = invoiceSnapshots.getFirst(); + if (first != null) { + ContractInvoiceGetResponse response = JSON.parseObject(first.getInvoiceValue(), ContractInvoiceGetResponse.class); + List originModuleFields = response.getModuleFields(); + if (CollectionUtils.isNotEmpty(originModuleFields)) { + originFields.addAll(originModuleFields); + } + } + } + snapshotBaseMapper.deleteByLambda(delWrapper); + //保存快照 + List resolveFieldValues = moduleFormService.resolveSnapshotFields(moduleFields, moduleFormConfigDTO, invoiceFieldService, invoice.getId()); + // get 方法需要使用orgId + invoice.setOrganizationId(orgId); + ContractInvoiceGetResponse response = get(invoice, resolveFieldValues, moduleFormConfigDTO); + saveSnapshot(invoice, saveModuleFormConfigDTO, response); + + // 处理日志上下文 + baseService.handleUpdateLog(originContractInvoice, invoice, originFields, moduleFields, request.getId(), invoice.getName()); + }, () -> { + throw new GenericException(Translator.get("invoice.not.exist")); + }); + return invoiceMapper.selectByPrimaryKey(request.getId()); + } + + + /** + * 更新自定义字段 + * + * @param moduleFields + * @param invoice + * @param orgId + * @param userId + */ + private void updateFields(List moduleFields, ContractInvoice invoice, String orgId, String userId) { + if (moduleFields == null) { + return; + } + invoiceFieldService.deleteByResourceId(invoice.getId()); + invoiceFieldService.saveModuleField(invoice, orgId, userId, moduleFields, true); + } + + + /** + * 删除合同 + * + * @param id + */ + @OperationLog(module = LogModule.CONTRACT_INVOICE, type = LogType.DELETE, resourceId = "{#id}") + @HitApproval(formKey = FormKey.INVOICE, executeType = ExecuteTimingEnum.DELETE, resourceId = "{#id}", operatorId = "{#userId}") + public void delete(String id, String userId) { + ContractInvoice invoice = invoiceMapper.selectByPrimaryKey(id); + if (invoice == null) { + throw new GenericException(Translator.get("invoice.not.exist")); + } + + invoiceFieldService.deleteByResourceId(id); + invoiceMapper.deleteByPrimaryKey(id); + + //删除快照 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ContractInvoiceSnapshot::getInvoiceId, id); + snapshotBaseMapper.deleteByLambda(wrapper); + + // 添加日志上下文 + OperationLogContext.setResourceName(invoice.getName()); + } + + @Override + public void deleteForResource(String resourceId, String userId, String organizationId) { + delete(resourceId, userId); + } + + @Override + public FormKey getFormKey() { + return FormKey.INVOICE; + } + + private ContractInvoiceGetResponse get(ContractInvoice contractInvoice, List contractInvoiceFields, ModuleFormConfigDTO contractInvoiceFormConfig) { + ContractInvoiceGetResponse contractInvoiceGetResponse = BeanUtils.copyBean(new ContractInvoiceGetResponse(), contractInvoice); + contractInvoiceGetResponse = baseService.setCreateUpdateOwnerUserName(contractInvoiceGetResponse); + + // 获取模块字段 + moduleFormService.processBusinessFieldValues(contractInvoiceGetResponse, contractInvoiceFields, contractInvoiceFormConfig); + contractInvoiceFields = invoiceFieldService.setBusinessRefFieldValue(List.of(contractInvoiceGetResponse), + moduleFormService.getFlattenFormFields(FormKey.INVOICE.getKey(), contractInvoice.getOrganizationId()), + new HashMap<>(Map.of(contractInvoice.getId(), contractInvoiceFields))).get(contractInvoice.getId()); + + Map> optionMap = moduleFormService.getOptionMap(contractInvoiceFormConfig, contractInvoiceFields); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(contractInvoiceGetResponse, + ContractInvoiceGetResponse::getOwner, ContractInvoiceGetResponse::getOwnerName); + optionMap.put(BusinessModuleField.INVOICE_OWNER.getBusinessKey(), ownerFieldOption); + + Contract contract = contractMapper.selectByPrimaryKey(contractInvoice.getContractId()); + if (contract != null) { + contractInvoiceGetResponse.setContractName(contract.getName()); + optionMap.put(BusinessModuleField.INVOICE_CONTRACT_ID.getBusinessKey(), Collections.singletonList(new OptionDTO(contract.getId(), contract.getName()))); + } + + BusinessTitle businessTitle = businessTitleService.selectById(contractInvoiceGetResponse.getBusinessTitleId()); + if (businessTitle != null) { + contractInvoiceGetResponse.setBusinessTitleName(businessTitle.getName()); + optionMap.put(BusinessModuleField.INVOICE_BUSINESS_TITLE_ID.getBusinessKey(), Collections.singletonList(new OptionDTO(businessTitle.getId(), businessTitle.getName()))); + } + + contractInvoiceGetResponse.setOptionMap(optionMap); + contractInvoiceGetResponse.setModuleFields(contractInvoiceFields); + + if (contractInvoiceGetResponse.getOwner() != null) { + UserDeptDTO userDeptDTO = baseService.getUserDeptMapByUserId(contractInvoiceGetResponse.getOwner(), contractInvoice.getOrganizationId()); + if (userDeptDTO != null) { + contractInvoiceGetResponse.setDepartmentId(userDeptDTO.getDeptId()); + contractInvoiceGetResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + } + + // 附件信息 + contractInvoiceGetResponse.setAttachmentMap(moduleFormService.getAttachmentMap(contractInvoiceFormConfig, contractInvoiceFields)); + + return contractInvoiceGetResponse; + } + + /** + * 获取合同详情 + * + * @param id + * @return + */ + public ContractInvoiceGetResponse get(String id, String orgId) { + ContractInvoice contractInvoice = contractInvoiceMapper.selectByPrimaryKey(id); + // 获取模块字段 + ModuleFormConfigDTO contractInvoiceFormConfig = getFormConfig(contractInvoice.getOrganizationId()); + List contractInvoiceFields = invoiceFieldService.getModuleFieldValuesByResourceId(id); + ContractInvoiceGetResponse getResponse = get(contractInvoice, contractInvoiceFields, contractInvoiceFormConfig); + + if (Strings.CI.equals(getResponse.getApprovalStatus(), ApprovalStatus.APPROVING.name())) { + Map firstNodeApproved = baseService.getApprovingResourceFirstNodeApproved(List.of(getResponse.getId()), orgId); + getResponse.setFirstApproved(firstNodeApproved.get(getResponse.getId())); + } + return getResponse; + } + + /** + * 从快照中获取合同详情 + * + * @param id 合同ID + * @return 合同详情 + */ + public ContractInvoiceGetResponse getSnapshot(String id, String orgId) { + ContractInvoice contractInvoice = contractInvoiceMapper.selectByPrimaryKey(id); + if (contractInvoice == null) { + throw new GenericException(CrmHttpResultCode.NOT_FOUND); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ContractInvoiceSnapshot::getInvoiceId, id); + ContractInvoiceSnapshot snapshot = snapshotBaseMapper.selectListByLambda(wrapper).stream().findFirst().orElse(null); + if (snapshot != null) { + ContractInvoiceGetResponse getResponse = JSON.parseObject(snapshot.getInvoiceValue(), ContractInvoiceGetResponse.class); + if (Strings.CI.equals(getResponse.getApprovalStatus(), ApprovalStatus.APPROVING.name())) { + Map firstNodeApproved = baseService.getApprovingResourceFirstNodeApproved(List.of(getResponse.getId()), orgId); + getResponse.setFirstApproved(firstNodeApproved.get(getResponse.getId())); + } + getResponse.setApproved(contractInvoice.getApproved()); + return getResponse; + } + return null; + } + + private ModuleFormConfigDTO getFormConfig(String orgId) { + return moduleFormCacheService.getBusinessFormConfig(FormKey.INVOICE.getKey(), orgId); + } + + public List buildList(List list, String orgId) { + if (CollectionUtils.isEmpty(list)) { + return list; + } + + List invoiceIds = list.stream().map(ContractInvoiceListResponse::getId) + .collect(Collectors.toList()); + + List businessTitleIds = list.stream().map(ContractInvoiceListResponse::getBusinessTitleId) + .collect(Collectors.toList()); + + Map> invoiceFiledMap = invoiceFieldService.getResourceFieldMap(invoiceIds, true); + Map> resolvefieldValueMap = invoiceFieldService.setBusinessRefFieldValue(list, moduleFormService.getFlattenFormFields(FormKey.INVOICE.getKey(), orgId), invoiceFiledMap); + Map businessTitleNameMap = businessTitleService.selectByIds(businessTitleIds).stream() + .collect(Collectors.toMap(BusinessTitle::getId, BusinessTitle::getName)); + + List ownerIds = list.stream() + .map(ContractInvoiceListResponse::getOwner) + .distinct() + .toList(); + + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + + List approvingResourceIds = list.stream().filter(item -> Strings.CI.contains(item.getApprovalStatus(), ApprovalStatus.APPROVING.name())).map(ContractInvoiceListResponse::getId).toList(); + Map firstNodeApprovedMap = baseService.getApprovingResourceFirstNodeApproved(approvingResourceIds, orgId); + + list.forEach(item -> { + UserDeptDTO userDeptDTO = userDeptMap.get(item.getOwner()); + if (userDeptDTO != null) { + item.setDepartmentId(userDeptDTO.getDeptId()); + item.setDepartmentName(userDeptDTO.getDeptName()); + } + String businessTitleName = businessTitleNameMap.get(item.getBusinessTitleId()); + if (StringUtils.isNotBlank(businessTitleName)) { + item.setBusinessTitleName(businessTitleName); + } + // 获取自定义字段 + List invoiceFields = resolvefieldValueMap.get(item.getId()); + item.setModuleFields(invoiceFields); + item.setFirstApproved(firstNodeApprovedMap.get(item.getId())); + }); + return baseService.setCreateUpdateOwnerUserName(list); + } + + /** + * 获取表单快照 + * + * @param id + * @param orgId + * @return + */ + public ModuleFormConfigDTO getFormSnapshot(String id, String orgId) { + ContractInvoice invoice = invoiceMapper.selectByPrimaryKey(id); + if (invoice == null) { + throw new GenericException(Translator.get("resource.not.exist")); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ContractInvoiceSnapshot::getInvoiceId, id); + ContractInvoiceSnapshot snapshot = snapshotBaseMapper.selectListByLambda(wrapper).stream().findFirst().orElse(null); + if (snapshot != null) { + return JSON.parseObject(snapshot.getInvoiceProp(), ModuleFormConfigDTO.class); + } else { + return moduleFormCacheService.getBusinessFormConfig(FormKey.INVOICE.getKey(), orgId); + } + } + + /** + * 获取发票详情(⚠️反射调用; 勿修改入参, 返回, 方法名!) + * @param id 发票ID + * @return 发票详情 + */ + public ContractInvoiceGetResponse getSimple(String id) { + ContractInvoice contractInvoice = contractInvoiceMapper.selectByPrimaryKey(id); + if (contractInvoice == null) { + return null; + } + ContractInvoiceGetResponse response = BeanUtils.copyBean(new ContractInvoiceGetResponse(), contractInvoice); + List fvs = invoiceFieldService.getModuleFieldValuesByResourceId(id); + ModuleFormConfigDTO formConfig = getFormConfig(contractInvoice.getOrganizationId()); + moduleFormService.processBusinessFieldValues(response, fvs, formConfig); + return response; + } + + /** + * 获取字段详情 (⚠️反射调用; 勿修改入参, 返回, 方法名!) + * @param id 发票ID + * @return 发票详情 + */ + public ContractInvoiceGetResponse getFieldValues(String id) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ContractInvoiceSnapshot::getInvoiceId, id); + ContractInvoiceSnapshot snapshot = snapshotBaseMapper.selectListByLambda(wrapper).stream().findFirst().orElse(null); + if (snapshot != null) { + ContractInvoiceGetResponse getResponse = JSON.parseObject(snapshot.getInvoiceValue(), ContractInvoiceGetResponse.class); + return getResponse; + } + return null; + } + + public ResourceTabEnableDTO getTabEnableConfig(String userId, String orgId) { + List rolePermissions = permissionCache.getRolePermissions(userId, orgId); + return PermissionUtils.getTabEnableConfig(userId, PermissionConstants.CONTRACT_INVOICE_READ, rolePermissions); + } + + public void batchDelete(List ids, String userId, String orgId) { + List invoices = contractInvoiceMapper.selectByIds(ids); + + // 校验状态权限,过滤出有权限操作的发票 + List permittedIds = approvalFlowService.filterResourcesWithPermission( + ApprovalFormTypeEnum.INVOICE.getValue(), + invoices, + PermissionConstants.CONTRACT_INVOICE_DELETE, + orgId, + ContractInvoice::getId, + ContractInvoice::getApprovalStatus + ); + + if (CollectionUtils.isEmpty(permittedIds)) { + return; + } + + List permittedInvoices = invoices.stream() + .filter(i -> permittedIds.contains(i.getId())) + .collect(Collectors.toList()); + + Map nameMap = invoices.stream().collect(Collectors.toMap(ContractInvoice::getId, ContractInvoice::getName)); + + ApprovalResourceService approvalResourceService = CommonBeanFactory.getBean(ApprovalResourceService.class); + // 触发批量删除审批流,命中审批流的资源不执行删除,进入审批 + List approvalIds = approvalResourceService.batchDeleteTriggerApproval(permittedIds, FormKey.INVOICE, orgId, userId, nameMap); + + // 过滤出未命中审批流的资源,直接删除 + List deleteIds = approvalIds.isEmpty() + ? permittedIds + : permittedIds.stream().filter(id -> !approvalIds.contains(id)).toList(); + if (CollectionUtils.isEmpty(deleteIds)) { + return; + } + + List deleteInvoices = permittedInvoices.stream() + .filter(i -> deleteIds.contains(i.getId())) + .toList(); + contractInvoiceMapper.deleteByIds(deleteIds); + + List logs = deleteInvoices.stream() + .map(invoice -> + new LogDTO(orgId, invoice.getId(), userId, LogType.DELETE, LogModule.CONTRACT_INVOICE, invoice.getName()) + ) + .toList(); + logService.batchAdd(logs); + + // 消息通知 todo +// invoices.forEach(invoice -> +// commonNoticeSendService.sendNotice(NotificationConstants.Module.CUSTOMER, +// NotificationConstants.Event.CUSTOMER_DELETED, invoice.getName(), userId, +// orgId, List.of(invoice.getOwner()), true) +// ); + } + + public BigDecimal calculateCustomerInvoiceAmount(String customerId, String userId, String orgId) { + if (dictService.isDictConfigEnable(DictModule.INVOICE_APPROVAL.name(), orgId)) { + return extContractInvoiceMapper.calculateCustomerInvoiceAmount(customerId, userId, orgId, true); + } else { + return extContractInvoiceMapper.calculateCustomerInvoiceAmount(customerId, userId, orgId, false); + } + } + + public BigDecimal calculateContractInvoiceAmount(String contractId, String userId, String orgId) { + if (dictService.isDictConfigEnable(DictModule.INVOICE_APPROVAL.name(), orgId)) { + return extContractInvoiceMapper.calculateContractInvoiceAmount(contractId, userId, orgId, true); + } else { + return extContractInvoiceMapper.calculateContractInvoiceAmount(contractId, userId, orgId, false); + } + } + + public ModuleFormConfigDTO getBusinessFormConfig(String organizationId) { + ModuleFormConfigDTO businessFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.INVOICE.getKey(), organizationId); + Set businessTitleKeySet = Arrays.stream(BusinessTitleConstants.values()) + .map(BusinessTitleConstants::getKey) + .collect(Collectors.toSet()); + + for (BaseField field : businessFormConfig.getFields()) { + if (businessTitleKeySet.contains(field.getId()) || Strings.CS.equals(field.getId(), BusinessModuleField.CONTRACT_TOTAL_AMOUNT.getKey())) { + // 特殊表单,设置可见 + field.setReadable(true); + } + } + return businessFormConfig; + } + + /** + * ⚠️反射调用: 由审批执行操作统一调用, 勿修改 + * @param param 参数 + */ + public void updateSnapshotApprovalStatus(ResourceSnapshotApprovalParam param) { + ContractInvoiceSnapshot snapshotCriteria = new ContractInvoiceSnapshot(); + snapshotCriteria.setInvoiceId(param.getResourceId()); + ContractInvoiceSnapshot snapshot = snapshotBaseMapper.selectOne(snapshotCriteria); + if (snapshot != null) { + ContractInvoiceGetResponse response = JSON.parseObject(snapshot.getInvoiceValue(), ContractInvoiceGetResponse.class); + response.setApprovalStatus(param.getApprovalStatus()); + snapshot.setInvoiceValue(JSON.toJSONString(response)); + snapshotBaseMapper.update(snapshot); + } + } + + /** + * ⚠️反射调用: 由审批执行后置操作统一调用, 勿修改 + * @param postFieldParam 参数 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public void updateApprovalPostField(ResourceApprovalPostUpdateParam postFieldParam) { + ModuleFormConfigDTO formConfig = getFormConfig(OrganizationContext.getOrganizationId()); + List fields = formConfig.getFields(); + Map fieldConfigMap = fields.stream().collect(Collectors.toMap(BaseField::getId, f -> f)); + ContractInvoice contractInvoice = contractInvoiceMapper.selectByPrimaryKey(postFieldParam.getResourceId()); + // 保存原始数据用于日志记录 + ContractInvoice originInvoice = BeanUtils.copyBean(new ContractInvoice(), contractInvoice); + List originFields = invoiceFieldService.getModuleFieldValuesByResourceId(postFieldParam.getResourceId()); + List contractInvoiceFields = new ArrayList<>(); + List contractInvoiceFieldBlobs = new ArrayList<>(); + ContractInvoiceSnapshot snapshotCriteria = new ContractInvoiceSnapshot(); + snapshotCriteria.setInvoiceId(postFieldParam.getResourceId()); + ContractInvoiceSnapshot snapshot = snapshotBaseMapper.selectOne(snapshotCriteria); + ContractInvoiceGetResponse response = new ContractInvoiceGetResponse(); + if (snapshot != null) { + response = JSON.parseObject(snapshot.getInvoiceValue(), ContractInvoiceGetResponse.class); + } + for (ResourceApprovalFieldUpdateParam fieldUpdateParam : postFieldParam.getFields()) { + if (!fieldConfigMap.containsKey(fieldUpdateParam.getFieldId()) || fieldUpdateParam.getFieldValue() == null) { + return; + } + BaseField fieldConfig = fieldConfigMap.get(fieldUpdateParam.getFieldId()); + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(fieldConfig.getType()); + if (fieldConfig.hasBusinessKey()) { + // 业务主表字段 + invoiceFieldService.setResourceFieldValue(contractInvoice, fieldConfig.getBusinessKey(), fieldUpdateParam.getFieldValue()); + } else { + // 快照自定义字段 + Optional findField = response.getModuleFields().stream().filter(fieldValue -> Strings.CI.equals(fieldValue.getFieldId(), fieldUpdateParam.getFieldId())).findAny(); + if (findField.isPresent()) { + findField.get().setFieldValue(fieldUpdateParam.getFieldValue()); + } else { + BaseModuleFieldValue fv = new BaseModuleFieldValue(); + fv.setFieldId(fieldUpdateParam.getFieldId()); + fv.setFieldValue(fieldUpdateParam.getFieldValue()); + response.getModuleFields().add(fv); + } + if (fieldConfig.isBlob()) { + // 自定义大表 + invoiceFieldService.getResourceFieldBlobMapper().deleteByLambda(new LambdaQueryWrapper() + .eq(ContractInvoiceFieldBlob::getFieldId, fieldUpdateParam.getFieldId()).eq(ContractInvoiceFieldBlob::getResourceId, postFieldParam.getResourceId())); + ContractInvoiceFieldBlob field = new ContractInvoiceFieldBlob(); + field.setId(IDGenerator.nextStr()); + field.setResourceId(postFieldParam.getResourceId()); + field.setFieldId(fieldUpdateParam.getFieldId()); + field.setFieldValue(customFieldResolver.convertToString(fieldConfig, fieldUpdateParam.getFieldValue())); + contractInvoiceFieldBlobs.add(field); + } else { + // 自定义表 + invoiceFieldService.getResourceFieldMapper().deleteByLambda(new LambdaQueryWrapper() + .eq(ContractInvoiceField::getFieldId, fieldUpdateParam.getFieldId()).eq(ContractInvoiceField::getResourceId, postFieldParam.getResourceId())); + ContractInvoiceField field = new ContractInvoiceField(); + field.setId(IDGenerator.nextStr()); + field.setResourceId(postFieldParam.getResourceId()); + field.setFieldId(fieldUpdateParam.getFieldId()); + field.setFieldValue(customFieldResolver.convertToString(fieldConfig, fieldUpdateParam.getFieldValue())); + contractInvoiceFields.add(field); + } + } + } + contractInvoiceMapper.updateById(contractInvoice); + if (CollectionUtils.isNotEmpty(contractInvoiceFields)) { + invoiceFieldService.getResourceFieldMapper().batchInsert(contractInvoiceFields); + } + if (CollectionUtils.isNotEmpty(contractInvoiceFieldBlobs)) { + invoiceFieldService.getResourceFieldBlobMapper().batchInsert(contractInvoiceFieldBlobs); + } + // 更新快照 + if (snapshot != null) { + ContractInvoiceGetResponse snapshotRes = get(contractInvoice, response.getModuleFields(), formConfig); + snapshot.setInvoiceValue(JSON.toJSONString(snapshotRes)); + snapshotBaseMapper.update(snapshot); + } + // 记录审批后置字段更新日志 + baseService.handleUpdateLogWithSubTable(originInvoice, contractInvoice, originFields, invoiceFieldService.getModuleFieldValuesByResourceId(postFieldParam.getResourceId()), + postFieldParam.getResourceId(), contractInvoice.getName(), Translator.get("products_info"), formConfig); + // 从 OperationLogContext 中获取日志信息并手动记录 + LogContextInfo contextInfo = OperationLogContext.getContext(); + if (contextInfo != null) { + String orgId = OrganizationContext.getOrganizationId(); + LogDTO logDTO = new LogDTO(orgId, postFieldParam.getResourceId(), postFieldParam.getOperator(), LogType.UPDATE, LogModule.CONTRACT_INVOICE, contractInvoice.getName()); + logDTO.setOriginalValue(contextInfo.getOriginalValue()); + logDTO.setModifiedValue(contextInfo.getModifiedValue()); + logService.add(logDTO); + OperationLogContext.clear(); + } + } + + /** + * 处理旧版本审批状态 (APPROVING => NONE) + */ + public void handleOldApprovalData() { + List invoices = contractInvoiceMapper.selectListByLambda(new LambdaQueryWrapper().eq(ContractInvoice::getApprovalStatus, ApprovalStatus.APPROVING.name())); + invoices.forEach(invoice -> { + ResourceSnapshotApprovalParam param = ResourceSnapshotApprovalParam.builder().resourceId(invoice.getId()).approvalStatus(ApprovalStatus.NONE.name()).build(); + updateSnapshotApprovalStatus(param); + }); + extContractInvoiceMapper.updateOldApprovalStatusNone(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractLogService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractLogService.java new file mode 100644 index 0000000..6c5f44c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractLogService.java @@ -0,0 +1,64 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.crm.contract.constants.ContractApprovalStatus; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.system.service.BaseModuleLogService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; + +@Service +@Transactional(rollbackFor = Exception.class) +public class ContractLogService extends BaseModuleLogService { + + @Resource + private BaseMapper customerMapper; + + @Override + public List handleLogField(List differences, String orgId) { + differences = super.handleModuleLogField(differences, orgId, FormKey.CONTRACT.getKey()); + for (var differ : differences) { + var column = differ.getColumn(); + if (Strings.CS.equals(column, BusinessModuleField.CONTRACT_OWNER.getBusinessKey())) { + setUserFieldName(differ); + continue; + } + if (Strings.CS.equals(column, BusinessModuleField.CONTRACT_CUSTOMER_NAME.getBusinessKey())) { + resolveCustomerName(differ); + continue; + } + if (Strings.CI.equals(column, "approvalStatus")) { + setApprovalName(differ); + } + if (column != null && column.contains("-")) { + differ.setColumnName(column); + } + } + return differences; + } + + private void resolveCustomerName(JsonDifferenceDTO differ) { + if (differ.getOldValue() != null) { + resolveCustomer(differ.getOldValue().toString(), differ::setOldValueName); + } + if (differ.getNewValue() != null) { + resolveCustomer(differ.getNewValue().toString(), differ::setNewValueName); + } + } + + private void resolveCustomer(String id, Consumer nameConsumer) { + var customer = customerMapper.selectByPrimaryKey(id); + if (customer != null) { + nameConsumer.accept(customer.getName()); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentPlanExportService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentPlanExportService.java new file mode 100644 index 0000000..94a367f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentPlanExportService.java @@ -0,0 +1,122 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.ExportDTO; +import cn.cordys.common.dto.ExportHeadDTO; +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.common.resolver.field.ModuleFieldResolverFactory; +import cn.cordys.common.service.BaseExportService; +import cn.cordys.common.util.TimeUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.contract.dto.request.ContractPaymentPlanPageRequest; +import cn.cordys.crm.contract.dto.response.ContractPaymentPlanListResponse; +import cn.cordys.crm.contract.mapper.ExtContractPaymentPlanMapper; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.registry.ExportThreadRegistry; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class ContractPaymentPlanExportService extends BaseExportService { + + @Resource + private ContractPaymentPlanService contractPaymentPlanService; + @Resource + private ExtContractPaymentPlanMapper extContractPaymentPlanMapper; + + /** + * 构建导出的数据 + * + * @return 导出数据列表 + */ + @Override + public List> getExportData(String taskId, ExportDTO exportDTO) throws InterruptedException { + ContractPaymentPlanPageRequest pageRequest = (ContractPaymentPlanPageRequest) exportDTO.getPageRequest(); + String orgId = exportDTO.getOrgId(); + PageHelper.startPage(pageRequest.getCurrent(), pageRequest.getPageSize()); + //获取数据 + List allList = extContractPaymentPlanMapper.list(pageRequest, exportDTO.getUserId(), orgId, exportDTO.getDeptDataPermission()); + List dataList = contractPaymentPlanService.buildListData(allList, orgId); + Map fieldConfigMap = getFieldConfigMap(FormKey.CONTRACT_PAYMENT_PLAN.getKey(), orgId); + //构建导出数据 + List> data = new ArrayList<>(); + for (ContractPaymentPlanListResponse response : dataList) { + if (ExportThreadRegistry.isInterrupted(taskId)) { + throw new InterruptedException("线程已被中断,主动退出"); + } + List value = buildData(exportDTO.getHeadList(), response, fieldConfigMap); + data.add(value); + } + + return data; + } + + private List buildData(List headList, ContractPaymentPlanListResponse data, Map fieldConfigMap) { + List dataList = new ArrayList<>(); + //固定字段map + LinkedHashMap systemFieldMap = getSystemFieldMap(data, fieldConfigMap); + //自定义字段map + Map moduleFieldMap = getFieldIdValueMap(data.getModuleFields()); + //处理数据转换 + return transModuleFieldValue(headList, systemFieldMap, moduleFieldMap, dataList, fieldConfigMap); + } + + public LinkedHashMap getSystemFieldMap(ContractPaymentPlanListResponse data, Map fieldConfigMap) { + LinkedHashMap systemFieldMap = new LinkedHashMap<>(); + systemFieldMap.put("name", data.getName()); + systemFieldMap.put("contractId", data.getContractName()); + systemFieldMap.put("owner", data.getOwnerName()); + systemFieldMap.put("departmentId", data.getDepartmentName()); + systemFieldMap.put("planAmount", data.getPlanAmount()); + + BaseField planEndTime = fieldConfigMap.values().stream().filter(field -> Strings.CI.equals(field.getBusinessKey(), "planEndTime")).findFirst().orElse(null); + if (planEndTime != null) { + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(planEndTime.getType()); + systemFieldMap.put("planEndTime", customFieldResolver.transformToValue(planEndTime, String.valueOf(data.getPlanEndTime()))); + } + systemFieldMap.put("planStatus", Translator.get("contract.payment_plan.status." + data.getPlanStatus().toLowerCase())); + + systemFieldMap.put("createUser", data.getCreateUserName()); + systemFieldMap.put("createTime", TimeUtils.getDateTimeStr(data.getCreateTime())); + systemFieldMap.put("updateUser", data.getUpdateUserName()); + systemFieldMap.put("updateTime", TimeUtils.getDateTimeStr(data.getUpdateTime())); + return systemFieldMap; + } + + + /** + * 选中回款计划数据 + * + * @return 导出数据列表 + */ + @Override + public List> getSelectExportData(List ids, String taskId, ExportDTO exportDTO) throws InterruptedException { + String orgId = exportDTO.getOrgId(); + String userId = exportDTO.getUserId(); + //获取数据 + List allList = extContractPaymentPlanMapper.getListByIds(ids, userId, orgId, exportDTO.getDeptDataPermission()); + List dataList = contractPaymentPlanService.buildListData(allList, orgId); + Map fieldConfigMap = getFieldConfigMap(FormKey.CONTRACT_PAYMENT_PLAN.getKey(), orgId); + //构建导出数据 + List> data = new ArrayList<>(); + for (ContractPaymentPlanListResponse response : dataList) { + if (ExportThreadRegistry.isInterrupted(taskId)) { + throw new InterruptedException("线程已被中断,主动退出"); + } + List value = buildData(exportDTO.getHeadList(), response, fieldConfigMap); + data.add(value); + } + return data; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentPlanFieldService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentPlanFieldService.java new file mode 100644 index 0000000..f4cdbef --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentPlanFieldService.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.crm.contract.domain.ContractPaymentPlanField; +import cn.cordys.crm.contract.domain.ContractPaymentPlanFieldBlob; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class ContractPaymentPlanFieldService extends BaseResourceFieldService { + @Resource + private BaseMapper contractPaymentPlanFieldMapper; + @Resource + private BaseMapper contractPaymentPlanFieldBlobMapper; + + @Override + protected String getFormKey() { + return FormKey.CONTRACT_PAYMENT_PLAN.getKey(); + } + + @Override + protected BaseMapper getResourceFieldMapper() { + return contractPaymentPlanFieldMapper; + } + + @Override + protected BaseMapper getResourceFieldBlobMapper() { + return contractPaymentPlanFieldBlobMapper; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentPlanResourceAccessContextProvider.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentPlanResourceAccessContextProvider.java new file mode 100644 index 0000000..d3eeb0c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentPlanResourceAccessContextProvider.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.permission.ResourceAccessContext; +import cn.cordys.common.permission.ResourceAccessContextProvider; +import cn.cordys.crm.contract.domain.ContractPaymentPlan; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class ContractPaymentPlanResourceAccessContextProvider implements ResourceAccessContextProvider { + + @Resource + private BaseMapper contractPaymentPlanMapper; + + @Override + public String getFormType() { + return FormKey.CONTRACT_PAYMENT_PLAN.getKey(); + } + + @Override + public ResourceAccessContext getAccessContext(String resourceId, String orgId) { + ContractPaymentPlan plan = contractPaymentPlanMapper.selectByPrimaryKey(resourceId); + if (plan == null) { + return null; + } + ResourceAccessContext context = new ResourceAccessContext(); + context.setOwnerId(plan.getOwner()); + return context; + } + + @Override + public Map batchGetOwnerIds(List resourceIds, String orgId) { + if (resourceIds == null || resourceIds.isEmpty()) { + return Map.of(); + } + return contractPaymentPlanMapper.selectByIds(resourceIds).stream() + .filter(plan -> plan.getOwner() != null) + .collect(Collectors.toMap(ContractPaymentPlan::getId, ContractPaymentPlan::getOwner, (a, b) -> a)); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentPlanService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentPlanService.java new file mode 100644 index 0000000..3e6624b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentPlanService.java @@ -0,0 +1,360 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.*; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.permission.PermissionUtils; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.crm.contract.constants.ContractPaymentPlanStatus; +import cn.cordys.crm.contract.domain.Contract; +import cn.cordys.crm.contract.domain.ContractPaymentPlan; +import cn.cordys.crm.contract.dto.request.ContractPaymentPlanAddRequest; +import cn.cordys.crm.contract.dto.request.ContractPaymentPlanPageRequest; +import cn.cordys.crm.contract.dto.request.ContractPaymentPlanUpdateRequest; +import cn.cordys.crm.contract.dto.response.ContractPaymentPlanGetResponse; +import cn.cordys.crm.contract.dto.response.ContractPaymentPlanListResponse; +import cn.cordys.crm.contract.dto.response.CustomerPaymentPlanStatisticResponse; +import cn.cordys.crm.contract.mapper.ExtContractPaymentPlanMapper; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * + * @author jianxing + * @date 2025-11-21 15:11:29 + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class ContractPaymentPlanService { + @Resource + private BaseMapper contractMapper; + @Resource + private BaseMapper contractPaymentPlanMapper; + @Resource + private ExtContractPaymentPlanMapper extContractPaymentPlanMapper; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private ContractPaymentPlanFieldService contractPaymentPlanFieldService; + @Resource + private PermissionCache permissionCache; + @Resource + private DataScopeService dataScopeService; + @Resource + private BaseService baseService; + + public PagerWithOption> list(ContractPaymentPlanPageRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extContractPaymentPlanMapper.list(request, userId, orgId, deptDataPermission); + list = buildListData(list, orgId); + Map> optionMap = buildOptionMap(orgId, list); + return PageUtils.setPageInfoWithOption(page, list, optionMap); + } + + public ModuleFormConfigDTO getFormConfig(String orgId) { + return moduleFormCacheService.getBusinessFormConfig(FormKey.CONTRACT_PAYMENT_PLAN.getKey(), orgId); + } + + public Map> buildOptionMap(String orgId, List list) { + // 处理自定义字段选项数据 + ModuleFormConfigDTO formConfig = getFormConfig(orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, ContractPaymentPlanListResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(formConfig, moduleFieldValues); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(list, + ContractPaymentPlanListResponse::getOwner, ContractPaymentPlanListResponse::getOwnerName); + optionMap.put(BusinessModuleField.CONTRACT_PAYMENT_PLAN_OWNER.getBusinessKey(), ownerFieldOption); + + // 合同 + List contractFieldOption = moduleFormService.getBusinessFieldOption(list, + ContractPaymentPlanListResponse::getContractId, ContractPaymentPlanListResponse::getContractName); + optionMap.put(BusinessModuleField.CONTRACT_PAYMENT_PLAN_CONTRACT.getBusinessKey(), contractFieldOption); + + return optionMap; + } + + public List buildListData(List list, String orgId) { + if (CollectionUtils.isEmpty(list)) { + return list; + } + List planIds = list.stream().map(ContractPaymentPlanListResponse::getId) + .collect(Collectors.toList()); + + Map> caseCustomFiledMap = contractPaymentPlanFieldService.getResourceFieldMap(planIds, true); + Map> resolvefieldValueMap = contractPaymentPlanFieldService.setBusinessRefFieldValue(list, + moduleFormService.getFlattenFormFields(FormKey.CONTRACT_PAYMENT_PLAN.getKey(), orgId), caseCustomFiledMap); + + + List ownerIds = list.stream() + .map(ContractPaymentPlanListResponse::getOwner) + .distinct() + .toList(); + + List createUserIds = list.stream() + .map(ContractPaymentPlanListResponse::getCreateUser) + .distinct() + .toList(); + List updateUserIds = list.stream() + .map(ContractPaymentPlanListResponse::getUpdateUser) + .distinct() + .toList(); + List userIds = Stream.of(ownerIds, createUserIds, updateUserIds) + .flatMap(Collection::stream) + .distinct() + .toList(); + Map userNameMap = baseService.getUserNameMap(userIds); + + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + + list.forEach(planListResponse -> { + // 获取自定义字段 + List contractPaymentPlanFields = resolvefieldValueMap.get(planListResponse.getId()); + planListResponse.setModuleFields(contractPaymentPlanFields); + + UserDeptDTO userDeptDTO = userDeptMap.get(planListResponse.getOwner()); + if (userDeptDTO != null) { + planListResponse.setDepartmentId(userDeptDTO.getDeptId()); + planListResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + + String createUserName = baseService.getAndCheckOptionName(userNameMap.get(planListResponse.getCreateUser())); + planListResponse.setCreateUserName(createUserName); + String updateUserName = baseService.getAndCheckOptionName(userNameMap.get(planListResponse.getUpdateUser())); + planListResponse.setUpdateUserName(updateUserName); + planListResponse.setOwnerName(userNameMap.get(planListResponse.getOwner())); + }); + + return list; + } + + public ContractPaymentPlanGetResponse get(String id) { + ContractPaymentPlan contractPaymentPlan = contractPaymentPlanMapper.selectByPrimaryKey(id); + ContractPaymentPlanGetResponse contractPaymentPlanGetResponse = BeanUtils.copyBean(new ContractPaymentPlanGetResponse(), contractPaymentPlan); + contractPaymentPlanGetResponse = baseService.setCreateUpdateOwnerUserName(contractPaymentPlanGetResponse); + Contract contract = contractMapper.selectByPrimaryKey(contractPaymentPlanGetResponse.getContractId()); + // 获取模块字段 + List contractPaymentPlanFields = contractPaymentPlanFieldService.getModuleFieldValuesByResourceId(id); + contractPaymentPlanFields = contractPaymentPlanFieldService.setBusinessRefFieldValue(List.of(contractPaymentPlanGetResponse), + moduleFormService.getFlattenFormFields(FormKey.CONTRACT_PAYMENT_PLAN.getKey(), contractPaymentPlan.getOrganizationId()), new HashMap<>(Map.of(id, contractPaymentPlanFields))).get(id); + ModuleFormConfigDTO contractPaymentPlanFormConfig = getFormConfig(contractPaymentPlan.getOrganizationId()); + + Map> optionMap = moduleFormService.getOptionMap(contractPaymentPlanFormConfig, contractPaymentPlanFields); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(contractPaymentPlanGetResponse, + ContractPaymentPlanGetResponse::getOwner, ContractPaymentPlanGetResponse::getOwnerName); + optionMap.put(BusinessModuleField.CUSTOMER_OWNER.getBusinessKey(), ownerFieldOption); + + if (contract != null) { + contractPaymentPlanGetResponse.setContractName(contract.getName()); + // 合同 + List contractFieldOption = moduleFormService.getBusinessFieldOption(contractPaymentPlanGetResponse, + ContractPaymentPlanGetResponse::getContractId, ContractPaymentPlanGetResponse::getContractName); + optionMap.put(BusinessModuleField.CONTRACT_PAYMENT_PLAN_CONTRACT.getBusinessKey(), contractFieldOption); + } + + contractPaymentPlanGetResponse.setOptionMap(optionMap); + contractPaymentPlanGetResponse.setModuleFields(contractPaymentPlanFields); + + if (contractPaymentPlanGetResponse.getOwner() != null) { + UserDeptDTO userDeptDTO = baseService.getUserDeptMapByUserId(contractPaymentPlanGetResponse.getOwner(), contractPaymentPlan.getOrganizationId()); + if (userDeptDTO != null) { + contractPaymentPlanGetResponse.setDepartmentId(userDeptDTO.getDeptId()); + contractPaymentPlanGetResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + } + + // 附件信息 + contractPaymentPlanGetResponse.setAttachmentMap(moduleFormService.getAttachmentMap(contractPaymentPlanFormConfig, contractPaymentPlanFields)); + + return contractPaymentPlanGetResponse; + } + + /** + * 获取跟进计划详情 (⚠️反射调用; 勿修改入参, 返回, 方法名!) + * @param id 计划ID + * @return 计划详情 + */ + public ContractPaymentPlanGetResponse getSimple(String id) { + ContractPaymentPlan contractPaymentPlan = contractPaymentPlanMapper.selectByPrimaryKey(id); + if (contractPaymentPlan == null) { + return null; + } + ContractPaymentPlanGetResponse response = BeanUtils.copyBean(new ContractPaymentPlanGetResponse(), contractPaymentPlan); + List fvs = contractPaymentPlanFieldService.getModuleFieldValuesByResourceId(id); + response.setModuleFields(fvs); + return response; + } + + /** + * 批量获取回款计划详情 (用于数据源批量查询优化) + * @param ids 计划ID集合 + * @return 回款计划详情列表 + */ + public List batchGetSimpleByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return Collections.emptyList(); + } + List plans = contractPaymentPlanMapper.selectByIds(ids); + if (CollectionUtils.isEmpty(plans)) { + return Collections.emptyList(); + } + Map> fieldValueMap = contractPaymentPlanFieldService.getResourceFieldMap(ids, true); + + return plans.stream().map(plan -> { + ContractPaymentPlanGetResponse response = BeanUtils.copyBean(new ContractPaymentPlanGetResponse(), plan); + response.setModuleFields(fieldValueMap.get(plan.getId())); + return response; + }).toList(); + } + + @OperationLog(module = LogModule.CONTRACT_PAYMENT, type = LogType.ADD, operator = "{#userId}") + public ContractPaymentPlan add(ContractPaymentPlanAddRequest request, String userId, String orgId) { + ContractPaymentPlan contractPaymentPlan = BeanUtils.copyBean(new ContractPaymentPlan(), request); + if (StringUtils.isBlank(request.getOwner())) { + contractPaymentPlan.setOwner(userId); + } + if (StringUtils.isBlank(request.getPlanStatus())) { + contractPaymentPlan.setPlanStatus(ContractPaymentPlanStatus.PENDING.name()); + } + contractPaymentPlan.setCreateTime(System.currentTimeMillis()); + contractPaymentPlan.setUpdateTime(System.currentTimeMillis()); + contractPaymentPlan.setUpdateUser(userId); + contractPaymentPlan.setCreateUser(userId); + contractPaymentPlan.setOrganizationId(orgId); + contractPaymentPlan.setId(IDGenerator.nextStr()); + // 保存自定义字段 + contractPaymentPlanFieldService.saveModuleField(contractPaymentPlan, orgId, userId, request.getModuleFields(), false); + contractPaymentPlanMapper.insert(contractPaymentPlan); + // 日志 + baseService.handleAddLogWithResourceName(contractPaymentPlan, request.getModuleFields()); + return contractPaymentPlan; + } + + @OperationLog(module = LogModule.CONTRACT_PAYMENT, type = LogType.UPDATE, resourceId = "{#request.id}") + public ContractPaymentPlan update(ContractPaymentPlanUpdateRequest request, String userId, String orgId) { + ContractPaymentPlan originContractPaymentPlan = contractPaymentPlanMapper.selectByPrimaryKey(request.getId()); + ContractPaymentPlan contractPaymentPlan = BeanUtils.copyBean(new ContractPaymentPlan(), request); + contractPaymentPlan.setUpdateTime(System.currentTimeMillis()); + contractPaymentPlan.setUpdateUser(userId); + + // 获取模块字段 + List originContractPaymentPlanFields = List.of(); + if (request.getModuleFields() != null) { + originContractPaymentPlanFields = contractPaymentPlanFieldService.getModuleFieldValuesByResourceId(request.getId()); + } + + if (BooleanUtils.isTrue(request.getAgentInvoke())) { + contractPaymentPlanFieldService.updateModuleFieldByAgent(contractPaymentPlan, originContractPaymentPlanFields, request.getModuleFields(), orgId, userId); + } else { + // 更新模块字段 + updateModuleField(contractPaymentPlan, request.getModuleFields(), orgId, userId); + } + + contractPaymentPlanMapper.update(contractPaymentPlan); + + contractPaymentPlan = contractPaymentPlanMapper.selectByPrimaryKey(request.getId()); + + baseService.handleUpdateLog(originContractPaymentPlan, contractPaymentPlan, originContractPaymentPlanFields, request.getModuleFields(), originContractPaymentPlan.getId(), originContractPaymentPlan.getName()); + return contractPaymentPlan; + } + + private void updateModuleField(ContractPaymentPlan contractPaymentPlan, List moduleFields, String orgId, String userId) { + if (moduleFields == null) { + // 如果为 null,则不更新 + return; + } + // 先删除 + contractPaymentPlanFieldService.deleteByResourceId(contractPaymentPlan.getId()); + // 再保存 + contractPaymentPlanFieldService.saveModuleField(contractPaymentPlan, orgId, userId, moduleFields, true); + } + + @OperationLog(module = LogModule.CONTRACT_PAYMENT, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id, String userId, String orgId) { + ContractPaymentPlan originContractPaymentPlan = contractPaymentPlanMapper.selectByPrimaryKey(id); + Contract contract = contractMapper.selectByPrimaryKey(originContractPaymentPlan.getContractId()); + + String resourceName = contract == null ? originContractPaymentPlan.getContractId() : contract.getName(); + + contractPaymentPlanMapper.deleteByPrimaryKey(id); + + // 设置操作对象 + OperationLogContext.setResourceName(resourceName); + } + + public ResourceTabEnableDTO getTabEnableConfig(String userId, String orgId) { + List rolePermissions = permissionCache.getRolePermissions(userId, orgId); + return PermissionUtils.getTabEnableConfig(userId, PermissionConstants.CONTRACT_PAYMENT_PLAN_READ, rolePermissions); + } + + public CustomerPaymentPlanStatisticResponse calculateCustomerPaymentPlanStatistic(String accountId, String userId, String organizationId, DeptDataPermissionDTO deptDataPermission) { + return extContractPaymentPlanMapper.calculateCustomerPaymentPlanStatistic(accountId, userId, organizationId, deptDataPermission); + } + + /** + * 通过名称获取回款计划集合 + * + * @param names 名称集合 + * @return 回款计划集合 + */ + public List getPlanListByNames(List names) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.in(ContractPaymentPlan::getName, names); + return contractPaymentPlanMapper.selectListByLambda(lambdaQueryWrapper); + } + + public String getPlanName(String id) { + ContractPaymentPlan contractPaymentPlan = contractPaymentPlanMapper.selectByPrimaryKey(id); + if (contractPaymentPlan != null) { + return contractPaymentPlan.getName(); + } + return StringUtils.EMPTY; + } + + /** + * 通过ID集合获取回款计划名称 + * @param ids id集合 + * @return 回款计划名称 + */ + public String getPlanNameByIds(List ids) { + List plans = contractPaymentPlanMapper.selectByIds(ids); + if (CollectionUtils.isNotEmpty(plans)) { + List names = plans.stream().map(ContractPaymentPlan::getName).toList(); + return String.join(",", names); + } + return StringUtils.EMPTY; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentRecordExportService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentRecordExportService.java new file mode 100644 index 0000000..b3a9112 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentRecordExportService.java @@ -0,0 +1,157 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.ExportDTO; +import cn.cordys.common.dto.ExportHeadDTO; +import cn.cordys.common.service.BaseExportService; +import cn.cordys.common.util.TimeUtils; +import cn.cordys.crm.contract.dto.request.ContractPaymentRecordPageRequest; +import cn.cordys.crm.contract.dto.response.ContractPaymentRecordResponse; +import cn.cordys.crm.contract.mapper.ExtContractPaymentRecordMapper; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import cn.cordys.crm.system.service.ModuleFieldExtService; +import cn.cordys.registry.ExportThreadRegistry; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * @author song-cc-rock + */ +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class ContractPaymentRecordExportService extends BaseExportService { + + @Resource + private ModuleFieldExtService moduleFieldExtService; + @Resource + private ContractPaymentRecordService contractPaymentRecordService; + @Resource + private ExtContractPaymentRecordMapper extContractPaymentRecordMapper; + + @Override + public List> getExportData(String taskId, ExportDTO exportDTO) throws InterruptedException { + // 分页查询数据 + ContractPaymentRecordPageRequest request = (ContractPaymentRecordPageRequest) exportDTO.getPageRequest(); + PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extContractPaymentRecordMapper.list(request, exportDTO.getUserId(), exportDTO.getOrgId(), exportDTO.getDeptDataPermission()); + return expandList(list, taskId, exportDTO); + } + + /** + * 获取系统字段的值 + * @param data 详情 + * @return 字段值映射 + */ + public LinkedHashMap getSystemFieldMap(ContractPaymentRecordResponse data) { + LinkedHashMap systemFieldMap = new LinkedHashMap<>(); + systemFieldMap.put("name", data.getName()); + systemFieldMap.put("no", data.getNo()); + systemFieldMap.put("contractId", data.getContractName()); + systemFieldMap.put("paymentPlanId", data.getPaymentPlanName()); + systemFieldMap.put("owner", data.getOwnerName()); + systemFieldMap.put("departmentId", data.getDepartmentName()); + systemFieldMap.put("recordAmount", data.getRecordAmount()); + systemFieldMap.put("recordEndTime", getInternalDateStr(data.getRecordEndTime(), FormKey.CONTRACT_PAYMENT_RECORD.getKey(), + data.getOrganizationId(), BusinessModuleField.CONTRACT_PAYMENT_RECORD_END_TIME.getKey())); + + systemFieldMap.put("createUser", data.getCreateUserName()); + systemFieldMap.put("createTime", TimeUtils.getDateTimeStr(data.getCreateTime())); + systemFieldMap.put("updateUser", data.getUpdateUserName()); + systemFieldMap.put("updateTime", TimeUtils.getDateTimeStr(data.getUpdateTime())); + return systemFieldMap; + } + + /** + * 获取勾选的回款记录导出数据 + * @return 导出数据列表 + */ + @Override + public List> getSelectExportData(List ids, String taskId, ExportDTO exportDTO) throws InterruptedException { + List allList = extContractPaymentRecordMapper.getListByIds(ids, exportDTO.getUserId(), exportDTO.getOrgId(), exportDTO.getDeptDataPermission()); + return expandList(allList, taskId, exportDTO); + } + + /** + * 展开列表数据 + * @param list 列表数据 + * @param taskId 任务ID + * @param exportDTO 导出参数 + * @return 导出数据 + * @throws InterruptedException 异常信息 + */ + private List> expandList(List list, String taskId, ExportDTO exportDTO) throws InterruptedException { + String orgId = exportDTO.getOrgId(); + List dataList = contractPaymentRecordService.buildListExtra(list, orgId); + Map fieldConfigMap = getFieldConfigMap(FormKey.CONTRACT_PAYMENT_RECORD.getKey(), orgId); + //构建导出数据 + List> data = new ArrayList<>(); + for (ContractPaymentRecordResponse response : dataList) { + if (ExportThreadRegistry.isInterrupted(taskId)) { + throw new InterruptedException("线程已被中断,主动退出"); + } + List value = buildData(exportDTO.getHeadList(), response, fieldConfigMap); + data.add(value); + } + return data; + } + + /** + * 构建导出数据 + * @param headList 表头集合 + * @param data 数据详情 + * @param fieldConfigMap 字段配置 + * @return 导出数据 + */ + private List buildData(List headList, ContractPaymentRecordResponse data, Map fieldConfigMap) { + LinkedHashMap systemFieldMap = getSystemFieldMap(data); + Map moduleFieldMap = getFieldIdValueMap(data.getModuleFields()); + return transModuleFieldValue(headList, systemFieldMap, moduleFieldMap, new ArrayList<>(), fieldConfigMap); + } + + /** + * 处理内部选项值 + * @param value 选项值 + * @param formKey 表单Key + * @param orgId 组织ID + * @param internalKey 字段内部Key + * @return 名称 + */ + private String processInternalOptions(String value, String formKey, String orgId, String internalKey) { + List options = moduleFieldExtService.getFieldOptions(formKey, orgId, internalKey); + for (OptionProp option : options) { + if (Strings.CS.equals(option.getValue(), value)) { + return option.getLabel(); + } + } + return value; + } + + /** + * 获取内部日期字符串 + * @param timestamp 毫秒 + * @param formKey 表单Key + * @param orgId 组织ID + * @param internalKey 内部Key + * @return 日期字符串 + */ + private String getInternalDateStr(Long timestamp, String formKey, String orgId, String internalKey) { + String dateType = moduleFieldExtService.getDateFieldType(formKey, orgId, internalKey); + return switch (dateType) { + case "date" -> TimeUtils.getDateStr(timestamp); + case "month" -> TimeUtils.getMonthStr(timestamp); + default -> TimeUtils.getDateTimeStr(timestamp); + }; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentRecordFieldService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentRecordFieldService.java new file mode 100644 index 0000000..fe88a30 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentRecordFieldService.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.crm.contract.domain.ContractPaymentRecordField; +import cn.cordys.crm.contract.domain.ContractPaymentRecordFieldBlob; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author song-cc-rock + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class ContractPaymentRecordFieldService extends BaseResourceFieldService { + + @Resource + private BaseMapper contractPaymentRecordFieldMapper; + @Resource + private BaseMapper contractPaymentRecordFieldBlobMapper; + + @Override + protected String getFormKey() { + return FormKey.CONTRACT_PAYMENT_RECORD.getKey(); + } + + @Override + protected BaseMapper getResourceFieldMapper() { + return contractPaymentRecordFieldMapper; + } + + @Override + protected BaseMapper getResourceFieldBlobMapper() { + return contractPaymentRecordFieldBlobMapper; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentRecordResourceAccessContextProvider.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentRecordResourceAccessContextProvider.java new file mode 100644 index 0000000..568ad72 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentRecordResourceAccessContextProvider.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.permission.ResourceAccessContext; +import cn.cordys.common.permission.ResourceAccessContextProvider; +import cn.cordys.crm.contract.domain.ContractPaymentRecord; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class ContractPaymentRecordResourceAccessContextProvider implements ResourceAccessContextProvider { + + @Resource + private BaseMapper contractPaymentRecordMapper; + + @Override + public String getFormType() { + return FormKey.CONTRACT_PAYMENT_RECORD.getKey(); + } + + @Override + public ResourceAccessContext getAccessContext(String resourceId, String orgId) { + ContractPaymentRecord record = contractPaymentRecordMapper.selectByPrimaryKey(resourceId); + if (record == null) { + return null; + } + ResourceAccessContext context = new ResourceAccessContext(); + context.setOwnerId(record.getOwner()); + return context; + } + + @Override + public Map batchGetOwnerIds(List resourceIds, String orgId) { + if (resourceIds == null || resourceIds.isEmpty()) { + return Map.of(); + } + return contractPaymentRecordMapper.selectByIds(resourceIds).stream() + .filter(record -> record.getOwner() != null) + .collect(Collectors.toMap(ContractPaymentRecord::getId, ContractPaymentRecord::getOwner, (a, b) -> a)); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentRecordService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentRecordService.java new file mode 100644 index 0000000..8b80b98 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractPaymentRecordService.java @@ -0,0 +1,531 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.domain.BaseResourceSubField; +import cn.cordys.common.dto.*; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.permission.PermissionUtils; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.uid.SerialNumGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.contract.domain.*; +import cn.cordys.crm.contract.dto.request.ContractPaymentRecordAddRequest; +import cn.cordys.crm.contract.dto.request.ContractPaymentRecordPageRequest; +import cn.cordys.crm.contract.dto.request.ContractPaymentRecordStatisticRequest; +import cn.cordys.crm.contract.dto.request.ContractPaymentRecordUpdateRequest; +import cn.cordys.crm.contract.dto.response.ContractPaymentRecordGetResponse; +import cn.cordys.crm.contract.dto.response.ContractPaymentRecordResponse; +import cn.cordys.crm.contract.dto.response.ContractPaymentRecordStatisticResponse; +import cn.cordys.crm.contract.dto.response.CustomerPaymentRecordStatisticResponse; +import cn.cordys.crm.contract.mapper.ExtContractPaymentRecordMapper; +import cn.cordys.crm.system.constants.SheetKey; +import cn.cordys.crm.system.dto.field.SerialNumberField; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.excel.CustomImportAfterDoConsumer; +import cn.cordys.crm.system.excel.handler.CustomHeadColWidthStyleStrategy; +import cn.cordys.crm.system.excel.handler.CustomTemplateWriteHandler; +import cn.cordys.crm.system.excel.listener.CustomFieldCheckEventListener; +import cn.cordys.crm.system.excel.listener.CustomFieldImportEventListener; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.ModuleFieldExtService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.excel.utils.EasyExcelExporter; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import cn.idev.excel.FastExcelFactory; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * @author song-cc-rock + */ +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class ContractPaymentRecordService { + + @Resource + private BaseService baseService; + @Resource + private LogService logService; + @Resource + private PermissionCache permissionCache; + @Resource + private DataScopeService dataScopeService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private ModuleFieldExtService moduleFieldExtService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private BaseMapper contractMapper; + @Resource + private SerialNumGenerator serialNumGenerator; + @Resource + private BaseMapper contractPaymentPlanMapper; + @Resource + private BaseMapper contractPaymentRecordMapper; + @Resource + private BaseMapper contractPaymentRecordFieldMapper; + @Resource + private BaseMapper contractPaymentRecordFieldBlobMapper; + @Resource + private ExtContractPaymentRecordMapper extContractPaymentRecordMapper; + @Resource + private ContractPaymentRecordFieldService contractPaymentRecordFieldService; + + /** + * 获取回款记录列表 + * + * @param request 请求参数 + * @param currentUser 当前用户 + * @param currentOrg 当前组织 + * @param deptDataPermission 数据权限 + * @return 回款记录列表 + */ + public PagerWithOption> list(ContractPaymentRecordPageRequest request, String currentUser, String currentOrg, + DeptDataPermissionDTO deptDataPermission) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extContractPaymentRecordMapper.list(request, currentUser, currentOrg, deptDataPermission); + List buildList = buildListExtra(list, currentOrg); + // 处理自定义字段选项 + Map> optionMap = buildOptionMap(buildList, currentOrg); + return PageUtils.setPageInfoWithOption(page, buildList, optionMap); + } + + @OperationLog(module = LogModule.CONTRACT_PAYMENT_RECORD, type = LogType.ADD, operator = "{#currentUser}") + public ContractPaymentRecord add(ContractPaymentRecordAddRequest request, String currentUser, String currentOrg) { + checkContractPaymentAmount(request.getContractId(), request.getRecordAmount(), null); + ContractPaymentRecord paymentRecord = BeanUtils.copyBean(new ContractPaymentRecord(), request); + paymentRecord.setId(IDGenerator.nextStr()); + if (StringUtils.isEmpty(paymentRecord.getOwner())) { + paymentRecord.setOwner(currentUser); + } + paymentRecord.setCreateUser(currentUser); + paymentRecord.setCreateTime(System.currentTimeMillis()); + paymentRecord.setUpdateUser(currentUser); + paymentRecord.setUpdateTime(System.currentTimeMillis()); + paymentRecord.setOrganizationId(currentOrg); + // 保存自定义字段值&回款记录 + contractPaymentRecordFieldService.saveModuleField(paymentRecord, currentOrg, currentUser, request.getModuleFields(), false); + contractPaymentRecordMapper.insert(paymentRecord); + // 日志 + baseService.handleAddLogWithResourceName(paymentRecord, request.getModuleFields()); + return paymentRecord; + } + + @OperationLog(module = LogModule.CONTRACT_PAYMENT_RECORD, type = LogType.UPDATE, operator = "{#currentUser}") + public ContractPaymentRecord update(ContractPaymentRecordUpdateRequest request, String currentUser, String currentOrg) { + ContractPaymentRecord oldRecord = contractPaymentRecordMapper.selectByPrimaryKey(request.getId()); + if (oldRecord == null) { + throw new GenericException(Translator.get("record.not.exist")); + } + checkContractPaymentAmount(request.getContractId(), request.getRecordAmount(), oldRecord.getId()); + ContractPaymentRecord contractPaymentRecord = BeanUtils.copyBean(new ContractPaymentRecord(), request); + contractPaymentRecord.setNo(oldRecord.getNo()); + contractPaymentRecord.setUpdateTime(System.currentTimeMillis()); + contractPaymentRecord.setUpdateUser(currentUser); + contractPaymentRecordMapper.update(contractPaymentRecord); + List oldFvs = contractPaymentRecordFieldService.getModuleFieldValuesByResourceId(request.getId()); + updateModuleField(contractPaymentRecord, request.getModuleFields(), currentOrg, currentUser); + baseService.handleUpdateLog(oldRecord, contractPaymentRecord, oldFvs, request.getModuleFields(), oldRecord.getId(), oldRecord.getName()); + return contractPaymentRecord; + } + + @OperationLog(module = LogModule.CONTRACT_PAYMENT_RECORD, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + ContractPaymentRecord oldRecord = contractPaymentRecordMapper.selectByPrimaryKey(id); + if (oldRecord == null) { + throw new GenericException(Translator.get("record.not.exist")); + } + contractPaymentRecordMapper.deleteByPrimaryKey(id); + contractPaymentRecordFieldService.deleteByResourceId(id); + OperationLogContext.setResourceName(oldRecord.getName()); + } + + public ContractPaymentRecordGetResponse get(String id) { + ContractPaymentRecord paymentRecord = contractPaymentRecordMapper.selectByPrimaryKey(id); + if (paymentRecord == null) { + throw new GenericException(Translator.get("record.not.exist")); + } + ContractPaymentRecordGetResponse recordDetail = BeanUtils.copyBean(new ContractPaymentRecordGetResponse(), paymentRecord); + recordDetail = baseService.setCreateUpdateOwnerUserName(recordDetail); + Contract contract = contractMapper.selectByPrimaryKey(recordDetail.getContractId()); + ContractPaymentPlan contractPaymentPlan = contractPaymentPlanMapper.selectByPrimaryKey(recordDetail.getPaymentPlanId()); + // 自定义字段值 & 选项值 + List fvs = contractPaymentRecordFieldService.getModuleFieldValuesByResourceId(id); + fvs = contractPaymentRecordFieldService.setBusinessRefFieldValue(List.of(recordDetail), + moduleFormService.getFlattenFormFields(FormKey.CONTRACT_PAYMENT_RECORD.getKey(), paymentRecord.getOrganizationId()), new HashMap<>(Map.of(id, fvs))).get(id); + ModuleFormConfigDTO recordFormConf = moduleFormCacheService.getBusinessFormConfig(FormKey.CONTRACT_PAYMENT_RECORD.getKey(), paymentRecord.getOrganizationId()); + Map> optionMap = moduleFormService.getOptionMap(recordFormConf, fvs); + optionMap.put(BusinessModuleField.CONTRACT_PAYMENT_RECORD_OWNER.getBusinessKey(), moduleFormService.getBusinessFieldOption(List.of(recordDetail), + ContractPaymentRecordGetResponse::getOwner, ContractPaymentRecordGetResponse::getOwnerName)); + if (contract != null) { + recordDetail.setContractName(contract.getName()); + optionMap.put(BusinessModuleField.CONTRACT_PAYMENT_RECORD_CONTRACT.getBusinessKey(), moduleFormService.getBusinessFieldOption(List.of(recordDetail), + ContractPaymentRecordGetResponse::getContractId, ContractPaymentRecordGetResponse::getContractName)); + } + if (contractPaymentPlan != null) { + recordDetail.setPaymentPlanName(contractPaymentPlan.getName()); + optionMap.put(BusinessModuleField.CONTRACT_PAYMENT_RECORD_PLAN.getBusinessKey(), moduleFormService.getBusinessFieldOption(List.of(recordDetail), + ContractPaymentRecordGetResponse::getPaymentPlanId, ContractPaymentRecordGetResponse::getPaymentPlanName)); + } + recordDetail.setModuleFields(fvs); + recordDetail.setOptionMap(optionMap); + recordDetail.setAttachmentMap(moduleFormService.getAttachmentMap(recordFormConf, recordDetail.getModuleFields())); + + if (recordDetail.getOwner() != null) { + UserDeptDTO userDeptDTO = baseService.getUserDeptMapByUserId(recordDetail.getOwner(), paymentRecord.getOrganizationId()); + if (userDeptDTO != null) { + recordDetail.setDepartmentId(userDeptDTO.getDeptId()); + recordDetail.setDepartmentName(userDeptDTO.getDeptName()); + } + } + return recordDetail; + } + + /** + * 获取回款记录详情(⚠️反射调用; 勿修改入参, 返回, 方法名!) + * @param id 回款记录ID + * @return 回款记录详情 + */ + public ContractPaymentRecordGetResponse getSimple(String id) { + ContractPaymentRecord paymentRecord = contractPaymentRecordMapper.selectByPrimaryKey(id); + if (paymentRecord == null) { + return null; + } + ContractPaymentRecordGetResponse response = BeanUtils.copyBean(new ContractPaymentRecordGetResponse(), paymentRecord); + List fvs = contractPaymentRecordFieldService.getModuleFieldValuesByResourceId(id); + response.setModuleFields(fvs); + return response; + } + + /** + * 批量获取回款记录详情 (用于数据源批量查询优化) + * @param ids 回款记录ID集合 + * @return 回款记录详情列表 + */ + public List batchGetSimpleByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return Collections.emptyList(); + } + List records = contractPaymentRecordMapper.selectByIds(ids); + if (CollectionUtils.isEmpty(records)) { + return Collections.emptyList(); + } + Map> fieldValueMap = contractPaymentRecordFieldService.getResourceFieldMap(ids, true); + + return records.stream().map(record -> { + ContractPaymentRecordGetResponse response = BeanUtils.copyBean(new ContractPaymentRecordGetResponse(), record); + response.setModuleFields(fieldValueMap.get(record.getId())); + return response; + }).toList(); + } + + + public ResourceTabEnableDTO getTabEnableConfig(String userId, String orgId) { + List rolePermissions = permissionCache.getRolePermissions(userId, orgId); + return PermissionUtils.getTabEnableConfig(userId, PermissionConstants.CONTRACT_PAYMENT_RECORD_READ, rolePermissions); + } + + /** + * 下载导入的模板 + * + * @param response 响应 + */ + public void downloadImportTpl(HttpServletResponse response, String currentOrg) { + new EasyExcelExporter().exportMultiSheetTplWithSharedHandler(response, + moduleFormService.getCustomImportHeadsNoRef(FormKey.CONTRACT_PAYMENT_RECORD.getKey(), currentOrg), + Translator.get("payment.record.import_tpl.name"), Translator.get(SheetKey.DATA), Translator.get(SheetKey.COMMENT), + new CustomTemplateWriteHandler(moduleFormService.getAllCustomImportFields(FormKey.CONTRACT_PAYMENT_RECORD.getKey(), currentOrg)), + new CustomHeadColWidthStyleStrategy()); + } + + /** + * 导入检查 + * + * @param file 导入文件 + * @param currentOrg 当前组织 + * @return 导入检查信息 + */ + public ImportResponse importPreCheck(MultipartFile file, String currentOrg) { + if (file == null) { + throw new GenericException(Translator.get("file_cannot_be_null")); + } + return checkImportExcel(file, currentOrg); + } + + /** + * 检查导入的文件 + * + * @param file 文件 + * @param currentOrg 当前组织 + * @return 检查信息 + */ + private ImportResponse checkImportExcel(MultipartFile file, String currentOrg) { + try { + List fields = moduleFormService.getAllCustomImportFields(FormKey.CONTRACT_PAYMENT_RECORD.getKey(), currentOrg); + CustomFieldCheckEventListener eventListener = new CustomFieldCheckEventListener(fields, "contract_payment_record", "contract_payment_record_field", currentOrg); + FastExcelFactory.read(file.getInputStream(), eventListener).headRowNumber(1).ignoreEmptyRow(true).sheet().doRead(); + return ImportResponse.builder().errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccess()).failCount(eventListener.getErrList().size()).build(); + } catch (Exception e) { + log.error("Payment record import pre-check error", e); + throw new GenericException(e.getMessage()); + } + } + + /** + * 回款记录导入 + * + * @param file 导入文件 + * @param currentOrg 当前组织 + * @param currentUser 当前用户 + * @return 导入返回信息 + */ + public ImportResponse realImport(MultipartFile file, String currentOrg, String currentUser) { + try { + List fields = moduleFormService.getAllFields(FormKey.CONTRACT_PAYMENT_RECORD.getKey(), currentOrg); + Optional serialOptional = fields.stream().filter(field -> Strings.CI.equals(field.getInternalKey(), BusinessModuleField.CONTRACT_PAYMENT_RECORD_NO.getKey())).findAny(); + CustomImportAfterDoConsumer afterDo = (records, recordFields, recordFieldBlobs) -> { + List logs = new ArrayList<>(); + records.forEach(record -> { + if (serialOptional.isPresent()) { + List serialNumberRules = ((SerialNumberField) serialOptional.get()).getSerialNumberRules(); + record.setNo(serialNumGenerator.generateByRules(serialNumberRules, currentOrg, FormKey.CONTRACT_PAYMENT_RECORD.getKey())); + } + logs.add(new LogDTO(currentOrg, record.getId(), currentUser, LogType.ADD, LogModule.CONTRACT_PAYMENT_RECORD, record.getName())); + }); + contractPaymentRecordMapper.batchInsert(records); + contractPaymentRecordFieldMapper.batchInsert(recordFields.stream().map(field -> BeanUtils.copyBean(new ContractPaymentRecordField(), field)).toList()); + contractPaymentRecordFieldBlobMapper.batchInsert(recordFieldBlobs.stream().map(field -> BeanUtils.copyBean(new ContractPaymentRecordFieldBlob(), field)).toList()); + // record logs + logService.batchAdd(logs); + }; + CustomFieldImportEventListener eventListener = new CustomFieldImportEventListener<>(fields, ContractPaymentRecord.class, currentOrg, currentUser, + "contract_payment_record_field", afterDo, 2000, null, null); + FastExcelFactory.read(file.getInputStream(), eventListener).headRowNumber(1).ignoreEmptyRow(true).sheet().doRead(); + return ImportResponse.builder().errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccessCount()).failCount(eventListener.getErrList().size()).build(); + } catch (Exception e) { + log.error("Payment record import error", e); + throw new GenericException(e.getMessage()); + } + } + + /** + * 汇总客户回款记录列表金额 + * + * @param customerId 客户ID + * @param userId 用户ID + * @param organizationId 组织ID + * @param deptDataPermission 数据权限 + * @return 汇总结果 + */ + public CustomerPaymentRecordStatisticResponse sumCustomerPaymentAmount(String customerId, String userId, String organizationId, DeptDataPermissionDTO deptDataPermission) { + return extContractPaymentRecordMapper.sumCustomerRecordAmount(customerId, userId, organizationId, deptDataPermission); + } + + /** + * 更新自定义字段值 + * + * @param contractPaymentRecord 回款记录 + * @param moduleFields 自定义字段值 + * @param currentOrg 当前组织 + * @param currentUser 当前用户 + */ + private void updateModuleField(ContractPaymentRecord contractPaymentRecord, List moduleFields, String currentOrg, String currentUser) { + if (moduleFields == null) { + return; + } + // 删除已有的再保存 + contractPaymentRecordFieldService.deleteByResourceId(contractPaymentRecord.getId()); + contractPaymentRecordFieldService.saveModuleField(contractPaymentRecord, currentOrg, currentUser, moduleFields, true); + } + + /** + * 构建列表扩展数据 + * + * @param list 列表数据 + * @param currentOrg 当前组织 + * @return 列表扩展数据 + */ + public List buildListExtra(List list, String currentOrg) { + if (CollectionUtils.isEmpty(list)) { + return list; + } + List recordIds = list.stream().map(ContractPaymentRecordResponse::getId).filter(StringUtils::isNotBlank).toList(); + List refPlanIds = distinctNonBlank(list.stream().map(ContractPaymentRecordResponse::getPaymentPlanId).collect(Collectors.toList())); + List contractPaymentPlans = CollectionUtils.isEmpty(refPlanIds) ? Collections.emptyList() : contractPaymentPlanMapper.selectByIds(refPlanIds); + Map paymentPlanMap = contractPaymentPlans.stream().collect(Collectors.toMap(ContractPaymentPlan::getId, ContractPaymentPlan::getName)); + Map> resourceFieldMap = CollectionUtils.isEmpty(recordIds) ? Collections.emptyMap() : contractPaymentRecordFieldService.getResourceFieldMap(recordIds, true); + Map> resolvefieldValueMap = contractPaymentRecordFieldService.setBusinessRefFieldValue(list, + moduleFormService.getFlattenFormFields(FormKey.CONTRACT_PAYMENT_RECORD.getKey(), currentOrg), resourceFieldMap); + + List ownerIds = distinctNonBlank(list.stream().map(ContractPaymentRecordResponse::getOwner).collect(Collectors.toList())); + List createUserIds = distinctNonBlank(list.stream().map(ContractPaymentRecordResponse::getCreateUser).collect(Collectors.toList())); + List updateUserIds = distinctNonBlank(list.stream().map(ContractPaymentRecordResponse::getUpdateUser).collect(Collectors.toList())); + List userIds = Stream.of(ownerIds, createUserIds, updateUserIds) + .flatMap(Collection::stream).distinct().toList(); + Map userNameMap = baseService.getUserNameMap(userIds); + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, currentOrg); + list.forEach(item -> { + item.setModuleFields(resolvefieldValueMap.getOrDefault(item.getId(), Collections.emptyList())); + item.setCreateUserName(baseService.getAndCheckOptionName(userNameMap.get(item.getCreateUser()))); + item.setUpdateUserName(baseService.getAndCheckOptionName(userNameMap.get(item.getUpdateUser()))); + item.setOwnerName(baseService.getAndCheckOptionName(userNameMap.get(item.getOwner()))); + item.setPaymentPlanName(paymentPlanMap.get(item.getPaymentPlanId())); + if (userDeptMap.containsKey(item.getOwner())) { + UserDeptDTO userDept = userDeptMap.get(item.getOwner()); + item.setDepartmentId(userDept.getDeptId()); + item.setDepartmentName(userDept.getDeptName()); + } + }); + return list; + } + + /** + * 处理选项数据 + * + * @param list 列表数据 + * @param currentOrg 当前组织 + * @return 选项数据 + */ + private Map> buildOptionMap(List list, String currentOrg) { + // 处理自定义字段选项数据 + ModuleFormConfigDTO formConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CONTRACT_PAYMENT_RECORD.getKey(), currentOrg); + Map> optionMap = moduleFormService.getOptionMap(formConfig, moduleFormService.getBaseModuleFieldValues(list, ContractPaymentRecordResponse::getModuleFields)); + + // 补充业务字段选项数据 {负责人, 合同, 回款计划} + optionMap.put(BusinessModuleField.CONTRACT_PAYMENT_RECORD_OWNER.getBusinessKey(), moduleFormService.getBusinessFieldOption(list, + ContractPaymentRecordResponse::getOwner, ContractPaymentRecordResponse::getOwnerName)); + optionMap.put(BusinessModuleField.CONTRACT_PAYMENT_RECORD_CONTRACT.getBusinessKey(), moduleFormService.getBusinessFieldOption(list, + ContractPaymentRecordResponse::getContractId, ContractPaymentRecordResponse::getContractName)); + optionMap.put(BusinessModuleField.CONTRACT_PAYMENT_RECORD_PLAN.getBusinessKey(), moduleFormService.getBusinessFieldOption(list, + ContractPaymentRecordResponse::getPaymentPlanId, ContractPaymentRecordResponse::getPaymentPlanName)); + + return optionMap; + } + + /** + * 校验回款金额是否超出 && 大于0 + * + * @param contractId 合同ID + * @param payAmount 回款金额 + */ + private void checkContractPaymentAmount(String contractId, BigDecimal payAmount, String excludeRecordId) { + if (payAmount == null || payAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw new GenericException(Translator.getWithArgs("record.amount.illegal")); + } + LambdaQueryWrapper recordLambdaQueryWrapper = new LambdaQueryWrapper<>(); + recordLambdaQueryWrapper.eq(ContractPaymentRecord::getContractId, contractId); + if (StringUtils.isNotEmpty(excludeRecordId)) { + recordLambdaQueryWrapper.nq(ContractPaymentRecord::getId, excludeRecordId); + } + Contract contract = contractMapper.selectByPrimaryKey(contractId); + if (contract == null) { + throw new GenericException(Translator.get("contract.not.exist")); + } + } + + /** + * 获取回款记录名称 + * + * @param id 回款记录ID + * @return 回款记录名称 + */ + public String getRecordNameById(String id) { + ContractPaymentRecord record = contractPaymentRecordMapper.selectByPrimaryKey(id); + if (record != null) { + return record.getName(); + } + return null; + } + + /** + * 通过名称获取回款记录集合 + * + * @param names 名称集合 + * @return 回款记录集合 + */ + public List getRecordListByNames(List names) { + if (CollectionUtils.isEmpty(names)) { + return Collections.emptyList(); + } + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.in(ContractPaymentRecord::getName, names); + return contractPaymentRecordMapper.selectListByLambda(lambdaQueryWrapper); + } + + /** + * 通过ID集合获取回款记录名称 + * + * @param ids id集合 + * @return 回款记录名称 + */ + public String getRecordNameByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return StringUtils.EMPTY; + } + List records = contractPaymentRecordMapper.selectByIds(ids); + if (CollectionUtils.isNotEmpty(records)) { + List names = records.stream().map(ContractPaymentRecord::getName).toList(); + return String.join(",", names); + } + return StringUtils.EMPTY; + } + + private List distinctNonBlank(Collection values) { + if (CollectionUtils.isEmpty(values)) { + return Collections.emptyList(); + } + return values.stream().filter(StringUtils::isNotBlank).distinct().toList(); + } + + + /** + * 统计 + * + * @param request + * @param userId + * @param orgId + * @param deptDataPermission + * @return + */ + public ContractPaymentRecordStatisticResponse searchStatistic(ContractPaymentRecordStatisticRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { + ContractPaymentRecordStatisticResponse response = extContractPaymentRecordMapper.searchStatistic(request, orgId, userId, deptDataPermission); + return Optional.ofNullable(response).orElse(new ContractPaymentRecordStatisticResponse()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractResourceAccessContextProvider.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractResourceAccessContextProvider.java new file mode 100644 index 0000000..fd35121 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractResourceAccessContextProvider.java @@ -0,0 +1,47 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.permission.ResourceAccessContext; +import cn.cordys.common.permission.ResourceAccessContextProvider; +import cn.cordys.crm.contract.domain.Contract; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class ContractResourceAccessContextProvider implements ResourceAccessContextProvider { + + @Resource + private BaseMapper contractMapper; + + @Override + public String getFormType() { + return FormKey.CONTRACT.getKey(); + } + + @Override + public ResourceAccessContext getAccessContext(String resourceId, String orgId) { + Contract contract = contractMapper.selectByPrimaryKey(resourceId); + if (contract == null) { + return null; + } + ResourceAccessContext context = new ResourceAccessContext(); + context.setOwnerId(contract.getOwner()); + context.setApprovalStatus(contract.getApprovalStatus()); + return context; + } + + @Override + public Map batchGetOwnerIds(List resourceIds, String orgId) { + if (resourceIds == null || resourceIds.isEmpty()) { + return Map.of(); + } + return contractMapper.selectByIds(resourceIds).stream() + .filter(contract -> contract.getOwner() != null) + .collect(Collectors.toMap(Contract::getId, Contract::getOwner, (a, b) -> a)); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractService.java new file mode 100644 index 0000000..0048593 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractService.java @@ -0,0 +1,1142 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.*; +import cn.cordys.common.dto.condition.BaseCondition; +import cn.cordys.common.dto.stage.CirculationFieldValue; +import cn.cordys.common.dto.stage.StageConfigResponse; +import cn.cordys.common.dto.stage.StageSortRequest; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.permission.PermissionUtils; +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.common.resolver.field.ModuleFieldResolverFactory; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.approval.annotation.HitApproval; +import cn.cordys.crm.approval.constants.ApprovalFormTypeEnum; +import cn.cordys.crm.approval.constants.ApprovalStatus; +import cn.cordys.crm.approval.constants.ExecuteTimingEnum; +import cn.cordys.crm.approval.dto.ResourceApprovalFieldUpdateParam; +import cn.cordys.crm.approval.dto.ResourceApprovalPostUpdateParam; +import cn.cordys.crm.approval.dto.ResourceSnapshotApprovalParam; +import cn.cordys.crm.approval.handler.ApprovalResourceHandler; +import cn.cordys.crm.approval.service.ApprovalFlowService; +import cn.cordys.crm.approval.service.ApprovalResourceService; +import cn.cordys.crm.contract.constants.ContractApprovalStatus; +import cn.cordys.crm.contract.constants.ContractStage; +import cn.cordys.crm.contract.domain.*; +import cn.cordys.crm.contract.dto.request.ContractAddRequest; +import cn.cordys.crm.contract.dto.request.ContractPageRequest; +import cn.cordys.crm.contract.dto.request.ContractStageRequest; +import cn.cordys.crm.contract.dto.request.ContractUpdateRequest; +import cn.cordys.crm.contract.dto.response.ContractGetResponse; +import cn.cordys.crm.contract.dto.response.ContractListResponse; +import cn.cordys.crm.contract.dto.response.ContractStatisticResponse; +import cn.cordys.crm.contract.dto.response.CustomerContractStatisticResponse; +import cn.cordys.crm.contract.mapper.ExtContractInvoiceMapper; +import cn.cordys.crm.contract.mapper.ExtContractMapper; +import cn.cordys.crm.contract.mapper.ExtContractStageConfigMapper; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.order.domain.Order; +import cn.cordys.crm.order.domain.OrderSnapshot; +import cn.cordys.crm.order.dto.response.OrderGetResponse; +import cn.cordys.crm.system.constants.CirculationFieldValueTypeEnum; +import cn.cordys.crm.system.constants.CirculationTypeEnum; +import cn.cordys.crm.system.constants.DictModule; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.domain.MessageTaskConfig; +import cn.cordys.crm.system.domain.StageAdvancedConfig; +import cn.cordys.crm.system.dto.MessageTaskConfigDTO; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.BatchAffectReasonResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.mapper.ExtStageAdvancedConfigMapper; +import cn.cordys.crm.system.notice.CommonNoticeSendService; +import cn.cordys.crm.system.service.*; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class ContractService implements ApprovalResourceHandler { + + @Resource + private ContractFieldService contractFieldService; + @Resource + private BaseMapper contractMapper; + @Resource + private BaseService baseService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private BaseMapper snapshotBaseMapper; + @Resource + private ExtContractMapper extContractMapper; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private PermissionCache permissionCache; + @Resource + private BaseMapper customerBaseMapper; + @Resource + private CommonNoticeSendService commonNoticeSendService; + @Resource + private BaseMapper messageTaskConfigMapper; + @Resource + private BaseMapper contractPaymentRecordMapper; + @Resource + private ExtContractInvoiceMapper extContractInvoiceMapper; + @Resource + private DictService dictService; + @Resource + private ExtContractStageConfigMapper extContractStageConfigMapper; + @Resource + private ApprovalFlowService approvalFlowService; + @Resource + private LogService logService; + @Resource + private StageAdvancedConfigService stageAdvancedConfigService; + @Resource + private ExtStageAdvancedConfigMapper extStageAdvancedConfigMapper; + private static final BigDecimal MAX_AMOUNT = new BigDecimal("9999999999"); + public static final Long DEFAULT_POS = 1L; + + /** + * 新建合同 + * + * @param request + * @param operatorId + * @param orgId + * @return + */ + @OperationLog(module = LogModule.CONTRACT_INDEX, type = LogType.ADD, resourceName = "{#request.name}") + @HitApproval(formKey = FormKey.CONTRACT, executeType = ExecuteTimingEnum.CREATE, operatorId = "{#operatorId}") + public Contract add(ContractAddRequest request, String operatorId, String orgId) { + List moduleFields = request.getModuleFields(); + ModuleFormConfigDTO moduleFormConfigDTO = request.getModuleFormConfigDTO(); + if (CollectionUtils.isEmpty(moduleFields)) { + throw new GenericException(Translator.get("contract.field.required")); + } + if (moduleFormConfigDTO == null) { + throw new GenericException(Translator.get("contract.form.config.required")); + } + ModuleFormConfigDTO saveModuleFormConfigDTO = JSON.parseObject(JSON.toJSONString(moduleFormConfigDTO), ModuleFormConfigDTO.class); + List stageConfigList = extContractStageConfigMapper.getStageConfigList(orgId); + Long nextPos = getNextPos(orgId, stageConfigList.getFirst().getId()); + Contract contract = new Contract(); + String id = IDGenerator.nextStr(); + contract.setId(id); + contract.setName(request.getName()); + contract.setCustomerId(request.getCustomerId()); + contract.setOwner(request.getOwner()); + contract.setNumber(request.getNumber()); + contract.setStage(stageConfigList.getFirst().getId()); + contract.setPos(nextPos); + contract.setOrganizationId(orgId); + contract.setApprovalStatus(ApprovalStatus.NONE.name()); + contract.setStartTime(request.getStartTime()); + contract.setEndTime(request.getEndTime()); + contract.setCreateTime(System.currentTimeMillis()); + contract.setCreateUser(operatorId); + contract.setUpdateTime(System.currentTimeMillis()); + contract.setUpdateUser(operatorId); + + if (!dictService.isDictConfigEnable(DictModule.CONTRACT_APPROVAL.name(), orgId)) { + contract.setApprovalStatus(ContractApprovalStatus.NONE.name()); + } + + //判断总金额 + setAmount(request.getAmount(), contract); + + // 设置子表格字段值 + moduleFields.add(new BaseModuleFieldValue("products", request.getProducts())); + //自定义字段 + contractFieldService.saveModuleField(contract, orgId, operatorId, moduleFields, false); + contractMapper.insert(contract); + + baseService.handleAddLogWithSubTable(contract, moduleFields, Translator.get("products_info"), moduleFormConfigDTO); + + // 保存表单配置快照 + List resolveFieldValues = moduleFormService.resolveSnapshotFields(moduleFields, moduleFormConfigDTO, contractFieldService, contract.getId()); + ContractGetResponse response = get(contract, resolveFieldValues, moduleFormConfigDTO); + saveSnapshot(contract, saveModuleFormConfigDTO, response); + + return contract; + } + + private Long getNextPos(String orgId, String stage) { + Long pos = extContractMapper.selectNextPos(orgId, stage); + return pos == null ? 1 : pos + 1; + } + + + /** + * 保存合同快照 + * + * @param contract + * @param moduleFormConfigDTO + * @param response + */ + private void saveSnapshot(Contract contract, ModuleFormConfigDTO moduleFormConfigDTO, ContractGetResponse response) { + //移除response中moduleFields 集合里 的 BaseModuleFieldValue 的 fieldId="products"的数据,避免快照数据过大 + if (CollectionUtils.isNotEmpty(response.getModuleFields())) { + response.setModuleFields(response.getModuleFields().stream() + .filter(field -> (field.getFieldValue() != null && StringUtils.isNotBlank(field.getFieldValue().toString()) && !"[]".equals(field.getFieldValue().toString()))).toList()); + } + ContractSnapshot snapshot = new ContractSnapshot(); + snapshot.setId(IDGenerator.nextStr()); + snapshot.setContractId(contract.getId()); + snapshot.setContractProp(JSON.toJSONString(moduleFormConfigDTO)); + snapshot.setContractValue(JSON.toJSONString(response)); + snapshotBaseMapper.insert(snapshot); + + } + + private ContractGetResponse get(Contract contract, List contractFields, ModuleFormConfigDTO contractFormConfig) { + ContractGetResponse contractGetResponse = BeanUtils.copyBean(new ContractGetResponse(), contract); + contractGetResponse = baseService.setCreateUpdateOwnerUserName(contractGetResponse); + + String id = contract.getId(); + // 获取模块字段 + moduleFormService.processBusinessFieldValues(contractGetResponse, contractFields, contractFormConfig); + contractFields = contractFieldService.setBusinessRefFieldValue(List.of(contractGetResponse), + moduleFormService.getFlattenFormFields(FormKey.CONTRACT.getKey(), contract.getOrganizationId()), new HashMap<>(Map.of(id, contractFields))).get(id); + + Map> optionMap = moduleFormService.getOptionMap(contractFormConfig, contractFields); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(contractGetResponse, + ContractGetResponse::getOwner, ContractGetResponse::getOwnerName); + optionMap.put(BusinessModuleField.CONTRACT_OWNER.getBusinessKey(), ownerFieldOption); + + Customer customer = customerBaseMapper.selectByPrimaryKey(contract.getCustomerId()); + if (customer != null) { + contractGetResponse.setCustomerName(customer.getName()); + optionMap.put(BusinessModuleField.CONTRACT_CUSTOMER_NAME.getBusinessKey(), Collections.singletonList(new OptionDTO(customer.getId(), customer.getName()))); + } + + contractGetResponse.setOptionMap(optionMap); + contractGetResponse.setModuleFields(contractFields); + + if (contractGetResponse.getOwner() != null) { + UserDeptDTO userDeptDTO = baseService.getUserDeptMapByUserId(contractGetResponse.getOwner(), contract.getOrganizationId()); + if (userDeptDTO != null) { + contractGetResponse.setDepartmentId(userDeptDTO.getDeptId()); + contractGetResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + } + + // 附件信息 + contractGetResponse.setAttachmentMap(moduleFormService.getAttachmentMap(contractFormConfig, contractFields)); + contractGetResponse.setAlreadyPayAmount(sumContractRecordAmount(id)); + + return contractGetResponse; + } + + /** + * 获取合同详情 + * + * @param id + * @return + */ + public ContractGetResponse get(String id, String orgId) { + Contract contract = contractMapper.selectByPrimaryKey(id); + // 获取模块字段 + ModuleFormConfigDTO contractFormConfig = getFormConfig(contract.getOrganizationId()); + List contractFields = contractFieldService.getModuleFieldValuesByResourceId(id); + ContractGetResponse getResponse = get(contract, contractFields, contractFormConfig); + + if (Strings.CI.equals(getResponse.getApprovalStatus(), ApprovalStatus.APPROVING.name())) { + Map firstNodeApproved = baseService.getApprovingResourceFirstNodeApproved(List.of(getResponse.getId()), orgId); + getResponse.setFirstApproved(firstNodeApproved.get(getResponse.getId())); + } + return getResponse; + } + + /** + * 获取合同详情(⚠️反射调用; 勿修改入参, 返回, 方法名!) + * + * @param id 合同ID + * @return 合同详情 + */ + public ContractGetResponse getSimple(String id) { + Contract contract = contractMapper.selectByPrimaryKey(id); + if (contract == null) { + return null; + } + ContractGetResponse response = BeanUtils.copyBean(new ContractGetResponse(), contract); + List fvs = contractFieldService.getModuleFieldValuesByResourceId(id); + ModuleFormConfigDTO contractFormConfig = getFormConfig(contract.getOrganizationId()); + moduleFormService.processBusinessFieldValues(response, fvs, contractFormConfig); + return response; + } + + + /** + * 获取字段详情 (⚠️反射调用; 勿修改入参, 返回, 方法名!) + * @param id 合同ID + * @return 合同详情 + */ + public ContractGetResponse getFieldValues(String id) { + ContractGetResponse response = new ContractGetResponse(); + Contract contract = contractMapper.selectByPrimaryKey(id); + if (contract == null) { + return null; + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ContractSnapshot::getContractId, id); + ContractSnapshot snapshot = snapshotBaseMapper.selectListByLambda(wrapper).stream().findFirst().orElse(null); + if (snapshot != null) { + response = JSON.parseObject(snapshot.getContractValue(), ContractGetResponse.class); + Customer customer = customerBaseMapper.selectByPrimaryKey(contract.getCustomerId()); + if (customer != null) { + response.setInCustomerPool(customer.getInSharedPool()); + response.setPoolId(customer.getPoolId()); + } + response.setAlreadyPayAmount(sumContractRecordAmount(id)); + } + return response; + } + + /** + * 批量获取合同详情 (用于数据源批量查询优化) + * + * @param ids 合同ID集合 + * @return 合同详情列表 + */ + public List batchGetSimpleByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return Collections.emptyList(); + } + List contracts = contractMapper.selectByIds(ids); + if (CollectionUtils.isEmpty(contracts)) { + return Collections.emptyList(); + } + Map> fieldValueMap = contractFieldService.getResourceFieldMap(ids, true); + + return contracts.stream().map(contract -> { + ContractGetResponse response = BeanUtils.copyBean(new ContractGetResponse(), contract); + response.setModuleFields(fieldValueMap.get(contract.getId())); + return response; + }).toList(); + } + + + /** + * 编辑合同 + * + * @param request + * @param userId + * @param orgId + * @return + */ + @OperationLog(module = LogModule.CONTRACT_INDEX, type = LogType.UPDATE, resourceId = "{#request.id}") + @HitApproval(formKey = FormKey.CONTRACT, executeType = ExecuteTimingEnum.UPDATE, resourceId = "{#request.id}", updateType = "{#request.updateType}", operatorId = "{#userId}", comment = "{#request.comment}") + public Contract update(ContractUpdateRequest request, String userId, String orgId) { + Contract oldContract = contractMapper.selectByPrimaryKey(request.getId()); + List moduleFields = request.getModuleFields(); + ModuleFormConfigDTO moduleFormConfigDTO = request.getModuleFormConfigDTO(); + if (CollectionUtils.isEmpty(moduleFields)) { + throw new GenericException(Translator.get("contract.field.required")); + } + if (moduleFormConfigDTO == null) { + throw new GenericException(Translator.get("contract.form.config.required")); + } + ModuleFormConfigDTO saveModuleFormConfigDTO = JSON.parseObject(JSON.toJSONString(moduleFormConfigDTO), ModuleFormConfigDTO.class); + Optional.ofNullable(oldContract).ifPresentOrElse(item -> { + + List originFields = contractFieldService.getModuleFieldValuesByResourceId(request.getId()); + Contract contract = BeanUtils.copyBean(new Contract(), request); + contract.setStartTime(request.getStartTime()); + contract.setEndTime(request.getEndTime()); + contract.setUpdateTime(System.currentTimeMillis()); + contract.setUpdateUser(userId); + // 保留不可更改的字段 + contract.setNumber(oldContract.getNumber()); + contract.setCreateUser(oldContract.getCreateUser()); + contract.setCreateTime(oldContract.getCreateTime()); + contract.setStage(oldContract.getStage()); + contract.setApprovalStatus(oldContract.getApprovalStatus()); + + //判断总金额 + setAmount(request.getAmount(), contract); + moduleFields.add(new BaseModuleFieldValue("products", request.getProducts())); + updateFields(moduleFields, contract, orgId, userId); + contractMapper.update(contract); + //删除快照 + LambdaQueryWrapper delWrapper = new LambdaQueryWrapper<>(); + delWrapper.eq(ContractSnapshot::getContractId, request.getId()); + List contractSnapshots = snapshotBaseMapper.selectListByLambda(delWrapper); + if (CollectionUtils.isNotEmpty(contractSnapshots)) { + ContractSnapshot first = contractSnapshots.getFirst(); + if (first != null) { + ContractGetResponse response = JSON.parseObject(first.getContractValue(), ContractGetResponse.class); + List originModuleFields = response.getModuleFields(); + originModuleFields.add(new BaseModuleFieldValue("products", response.getProducts())); + originFields.addAll(originModuleFields); + } + } + snapshotBaseMapper.deleteByLambda(delWrapper); + //保存快照 + List resolveFieldValues = moduleFormService.resolveSnapshotFields(moduleFields, moduleFormConfigDTO, contractFieldService, contract.getId()); + // get 方法需要使用orgId + contract.setOrganizationId(orgId); + ContractGetResponse response = get(contract, resolveFieldValues, moduleFormConfigDTO); + saveSnapshot(contract, saveModuleFormConfigDTO, response); + // 处理日志上下文 + baseService.handleUpdateLogWithSubTable(oldContract, contract, originFields, moduleFields, request.getId(), contract.getName(), Translator.get("products_info"), moduleFormConfigDTO); + }, () -> { + throw new GenericException(Translator.get("contract.not.exist")); + }); + return contractMapper.selectByPrimaryKey(request.getId()); + } + + private void setAmount(String amount, Contract contract) { + if (StringUtils.isNotBlank(amount)) { + contract.setAmount(new BigDecimal(amount)); + if (contract.getAmount().compareTo(MAX_AMOUNT) > 0) { + throw new GenericException(Translator.get("contract.amount.exceed.max")); + } + } else { + contract.setAmount(BigDecimal.ZERO); + } + } + + + /** + * 更新自定义字段 + * + * @param moduleFields + * @param contract + * @param orgId + * @param userId + */ + private void updateFields(List moduleFields, Contract contract, String orgId, String userId) { + if (moduleFields == null) { + return; + } + contractFieldService.deleteByResourceId(contract.getId()); + contractFieldService.saveModuleField(contract, orgId, userId, moduleFields, true); + } + + + /** + * 删除合同 + * + * @param id 合同ID + */ + @OperationLog(module = LogModule.CONTRACT_INDEX, type = LogType.DELETE, resourceId = "{#id}") + @HitApproval(formKey = FormKey.CONTRACT, executeType = ExecuteTimingEnum.DELETE, resourceId = "{#id}", operatorId = "{#userId}") + public void delete(String id, String userId) { + Contract contract = contractMapper.selectByPrimaryKey(id); + if (contract == null) { + throw new GenericException(Translator.get("contract.not.exist")); + } + checkContractRelated(id); + + contractFieldService.deleteByResourceId(id); + contractMapper.deleteByPrimaryKey(id); + + //删除快照 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ContractSnapshot::getContractId, id); + snapshotBaseMapper.deleteByLambda(wrapper); + // 添加日志上下文 + OperationLogContext.setResourceName(contract.getName()); + } + + @Override + public void deleteForResource(String resourceId, String userId, String organizationId) { + delete(resourceId, userId); + } + + @Override + public FormKey getFormKey() { + return FormKey.CONTRACT; + } + + + /** + * ⚠️反射调用; 勿修改入参, 返回, 方法名! + * + * @param id 合同ID + * @return 合同详情 + */ + public ContractGetResponse getSnapshot(String id, String orgId) { + ContractGetResponse response = new ContractGetResponse(); + Contract contract = contractMapper.selectByPrimaryKey(id); + if (contract == null) { + return null; + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ContractSnapshot::getContractId, id); + ContractSnapshot snapshot = snapshotBaseMapper.selectListByLambda(wrapper).stream().findFirst().orElse(null); + if (snapshot != null) { + response = JSON.parseObject(snapshot.getContractValue(), ContractGetResponse.class); + Customer customer = customerBaseMapper.selectByPrimaryKey(contract.getCustomerId()); + if (customer != null) { + response.setInCustomerPool(customer.getInSharedPool()); + response.setPoolId(customer.getPoolId()); + } + response.setAlreadyPayAmount(sumContractRecordAmount(id)); + if (Strings.CI.equals(response.getApprovalStatus(), ApprovalStatus.APPROVING.name())) { + Map firstNodeApproved = baseService.getApprovingResourceFirstNodeApproved(List.of(response.getId()), orgId); + response.setFirstApproved(firstNodeApproved.get(response.getId())); + } + } + response.setApproved(contract.getApproved()); + return response; + } + + + /** + * 合同列表 + * + * @param request + * @param userId + * @param orgId + * @param deptDataPermission + * @return + */ + public PagerWithOption> list(ContractPageRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission, Boolean source) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extContractMapper.list(request, orgId, userId, deptDataPermission, source); + List results = buildList(list, orgId); + ModuleFormConfigDTO customerFormConfig = getFormConfig(orgId); + Map> optionMap = buildOptionMap(list, results, customerFormConfig); + + return PageUtils.setPageInfoWithOption(page, results, optionMap); + } + + private Map> buildOptionMap(List list, List buildList, + ModuleFormConfigDTO formConfig) { + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, ContractListResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(formConfig, moduleFieldValues); + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(buildList, + ContractListResponse::getOwner, ContractListResponse::getOwnerName); + optionMap.put(BusinessModuleField.CONTRACT_OWNER.getBusinessKey(), ownerFieldOption); + return optionMap; + } + + private ModuleFormConfigDTO getFormConfig(String orgId) { + return moduleFormCacheService.getBusinessFormConfig(FormKey.CONTRACT.getKey(), orgId); + } + + public List buildList(List list, String orgId) { + if (CollectionUtils.isEmpty(list)) { + return list; + } + + List contractIds = list.stream().map(ContractListResponse::getId) + .collect(Collectors.toList()); + Map> contractFiledMap = contractFieldService.getResourceFieldMap(contractIds, true); + Map> resolvefieldValueMap = contractFieldService.setBusinessRefFieldValue(list, moduleFormService.getFlattenFormFields(FormKey.CONTRACT.getKey(), orgId), contractFiledMap); + + + List ownerIds = list.stream() + .map(ContractListResponse::getOwner) + .distinct() + .toList(); + Map userNameMap = baseService.getUserNameMap(ownerIds); + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + + Map stageNameMap = extContractStageConfigMapper.getStageConfigList(orgId).stream() + .collect(Collectors.toMap(StageConfigResponse::getId, + StageConfigResponse::getName)); + + List approvingResourceIds = list.stream().filter(item -> Strings.CI.contains(item.getApprovalStatus(), ApprovalStatus.APPROVING.name())).map(ContractListResponse::getId).toList(); + Map firstNodeApprovedMap = baseService.getApprovingResourceFirstNodeApproved(approvingResourceIds, orgId); + + list.forEach(item -> { + item.setOwnerName(userNameMap.get(item.getOwner())); + UserDeptDTO userDeptDTO = userDeptMap.get(item.getOwner()); + if (userDeptDTO != null) { + item.setDepartmentId(userDeptDTO.getDeptId()); + item.setDepartmentName(userDeptDTO.getDeptName()); + } + item.setStageName(stageNameMap.get(item.getStage())); + // 获取自定义字段 + List contractFields = resolvefieldValueMap.get(item.getId()); + item.setModuleFields(contractFields); + item.setFirstApproved(firstNodeApprovedMap.get(item.getId())); + }); + return baseService.setCreateAndUpdateUserName(list); + } + + + /** + * 获取表单快照 + * + * @param id + * @param orgId + * @return + */ + public ModuleFormConfigDTO getFormSnapshot(String id, String orgId) { + ModuleFormConfigDTO moduleFormConfigDTO = new ModuleFormConfigDTO(); + Contract contract = contractMapper.selectByPrimaryKey(id); + if (contract == null) { + throw new GenericException(Translator.get("contract.not.exist")); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ContractSnapshot::getContractId, id); + ContractSnapshot snapshot = snapshotBaseMapper.selectListByLambda(wrapper).stream().findFirst().orElse(null); + if (snapshot != null) { + moduleFormConfigDTO = JSON.parseObject(snapshot.getContractProp(), ModuleFormConfigDTO.class); + } else { + moduleFormConfigDTO = moduleFormCacheService.getBusinessFormConfig(FormKey.CONTRACT.getKey(), orgId); + } + return moduleFormConfigDTO; + + } + + + public ResourceTabEnableDTO getTabEnableConfig(String userId, String orgId) { + List rolePermissions = permissionCache.getRolePermissions(userId, orgId); + return PermissionUtils.getTabEnableConfig(userId, PermissionConstants.CONTRACT_READ, rolePermissions); + } + + + /** + * 更新合同状态 + * + * @param request + * @param userId + */ + @OperationLog(module = LogModule.CONTRACT_INDEX, type = LogType.UPDATE, resourceId = "{#request.id}") + public void updateStage(ContractStageRequest request, String userId, String orgId) { + Contract contract = contractMapper.selectByPrimaryKey(request.getId()); + if (contract == null) { + throw new GenericException(Translator.get("contract.not.exist")); + } + + List stageConfigList = extContractStageConfigMapper.getStageConfigList(orgId); + + Map stageMap = stageConfigList.stream() + .collect(Collectors.toMap(StageConfigResponse::getId, StageConfigResponse::getName)); + + if (!stageAdvancedConfigService.checkStage(contract.getStage(), request.getStage(), FormKey.CONTRACT.getKey())) { + return; + } + contract.setStage(request.getStage()); + if (StringUtils.isNotBlank(request.getVoidReason())) { + contract.setVoidReason(request.getVoidReason()); + } + contractMapper.update(contract); + + updateFieldAndSnapshot(contract, request.getFields(),userId); + + final Map originalVal = new HashMap<>(1); + originalVal.put("contractStage", stageMap.get(contract.getStage())); + + if (Strings.CI.equals(request.getStage(), ContractStage.VOID.name()) || Strings.CI.equals(request.getStage(), ContractStage.ARCHIVED.name())) { + String event = Strings.CI.equals(request.getStage(), ContractStage.VOID.name()) ? + NotificationConstants.Event.CONTRACT_VOID : NotificationConstants.Event.CONTRACT_ARCHIVED; + Customer customer = customerBaseMapper.selectByPrimaryKey(contract.getCustomerId()); + sendNotice(contract, userId, orgId, event, customer.getName()); + } + + final Map modifiedVal = new HashMap<>(1); + modifiedVal.put("contractStage", stageMap.get(request.getStage())); + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceName(contract.getName()) + .originalValue(originalVal) + .modifiedValue(modifiedVal) + .build() + ); + + } + + private void updateFieldAndSnapshot(Contract contract, List requestFields, String userId) { + if (CollectionUtils.isNotEmpty(requestFields)) { + ModuleFormConfigDTO businessFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CONTRACT.getKey(), contract.getOrganizationId()); + List fields = businessFormConfig.getFields(); + requestFields.forEach(field -> { + BaseField baseField = fields.stream().filter(customField -> customField.getId().equals(field.getFieldId())).findFirst().orElse(null); + ResourceBatchEditRequest updateRequest = new ResourceBatchEditRequest(); + updateRequest.setIds(List.of(contract.getId())); + updateRequest.setFieldId(field.getFieldId()); + updateRequest.setFieldValue(field.getFieldValue()); + contractFieldService.batchUpdate(updateRequest, baseField, List.of(contract), Contract.class, LogModule.ORDER_INDEX, extContractMapper::batchUpdate, userId, contract.getOrganizationId()); + }); + } + ContractSnapshot snapshotCriteria = new ContractSnapshot(); + snapshotCriteria.setContractId(contract.getId()); + ContractSnapshot snapshot = snapshotBaseMapper.selectOne(snapshotCriteria); + if (snapshot != null) { + ModuleFormConfigDTO orderFormConfig = getFormConfig(contract.getOrganizationId()); + List orderFields = contractFieldService.getModuleFieldValuesByResourceId(contract.getId()); + Contract newContract = contractMapper.selectByPrimaryKey(contract.getId()); + ContractGetResponse snapshotRes = get(newContract, orderFields, orderFormConfig); + snapshot.setContractValue(JSON.toJSONString(snapshotRes)); + snapshotBaseMapper.update(snapshot); + } + } + + /** + * 发送通知 + * + * @param contract 合同实体 + * @param userId 用户ID + * @param orgId 组织ID + * @param event 事件类型 + */ + private void sendNotice(Contract contract, String userId, String orgId, String event, String customerName) { + //查询通知配置的接收范围 + List receiveUserIds = new ArrayList<>(); + List messageTaskConfigList = messageTaskConfigMapper.selectListByLambda(new LambdaQueryWrapper() + .eq(MessageTaskConfig::getOrganizationId, orgId) + .eq(MessageTaskConfig::getTaskType, NotificationConstants.Module.CONTRACT) + .eq(MessageTaskConfig::getEvent, event)); + if (CollectionUtils.isNotEmpty(messageTaskConfigList)) { + MessageTaskConfig messageTaskConfig = messageTaskConfigList.getFirst(); + MessageTaskConfigDTO messageTaskConfigDTO = JSON.parseObject(messageTaskConfig.getValue(), MessageTaskConfigDTO.class); + receiveUserIds = commonNoticeSendService.getNoticeReceiveUserIds(messageTaskConfigDTO, contract.getCreateUser(), contract.getOwner(), orgId); + } else { + //默认通知创建人 + receiveUserIds.add(contract.getOwner()); + } + + Map paramMap = new HashMap<>(); + paramMap.put("customerName", customerName); + paramMap.put("name", contract.getName()); + commonNoticeSendService.sendNotice(NotificationConstants.Module.CONTRACT, event, + paramMap, userId, orgId, receiveUserIds, true); + } + + private void updateStatusSnapshot(String id, String stage, String approvalStatus) { + LambdaQueryWrapper delWrapper = new LambdaQueryWrapper<>(); + delWrapper.eq(ContractSnapshot::getContractId, id); + List contractSnapshots = snapshotBaseMapper.selectListByLambda(delWrapper); + ContractSnapshot first = contractSnapshots.getFirst(); + if (first != null) { + ContractGetResponse response = JSON.parseObject(first.getContractValue(), ContractGetResponse.class); + if (StringUtils.isNotBlank(stage)) { + response.setStage(stage); + } + if (StringUtils.isNotBlank(approvalStatus)) { + response.setApprovalStatus(approvalStatus); + } + first.setContractValue(JSON.toJSONString(response)); + snapshotBaseMapper.update(first); + } + } + + /** + * ⚠️反射调用: 由审批执行操作统一调用, 勿修改 + * + * @param param 参数 + */ + public void updateSnapshotApprovalStatus(ResourceSnapshotApprovalParam param) { + ContractSnapshot snapshotCriteria = new ContractSnapshot(); + snapshotCriteria.setContractId(param.getResourceId()); + ContractSnapshot snapshot = snapshotBaseMapper.selectOne(snapshotCriteria); + if (snapshot != null) { + ContractGetResponse response = JSON.parseObject(snapshot.getContractValue(), ContractGetResponse.class); + response.setApprovalStatus(param.getApprovalStatus()); + snapshot.setContractValue(JSON.toJSONString(response)); + snapshotBaseMapper.update(snapshot); + } + } + + /** + * ⚠️反射调用: 由审批执行后置操作统一调用, 勿修改 + * + * @param postFieldParam 参数 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public void updateApprovalPostField(ResourceApprovalPostUpdateParam postFieldParam) { + ModuleFormConfigDTO formConfig = getFormConfig(OrganizationContext.getOrganizationId()); + List fields = formConfig.getFields(); + Map fieldConfigMap = fields.stream().collect(Collectors.toMap(BaseField::getId, f -> f)); + Contract contract = contractMapper.selectByPrimaryKey(postFieldParam.getResourceId()); + // 保存原始数据用于日志记录 + Contract originContract = BeanUtils.copyBean(new Contract(), contract); + List originFields = contractFieldService.getModuleFieldValuesByResourceId(postFieldParam.getResourceId()); + List contractFields = new ArrayList<>(); + List contractFieldBlobs = new ArrayList<>(); + ContractSnapshot snapshotCriteria = new ContractSnapshot(); + snapshotCriteria.setContractId(postFieldParam.getResourceId()); + ContractSnapshot snapshot = snapshotBaseMapper.selectOne(snapshotCriteria); + ContractGetResponse response = new ContractGetResponse(); + if (snapshot != null) { + response = JSON.parseObject(snapshot.getContractValue(), ContractGetResponse.class); + } + + ResourceApprovalFieldUpdateParam stageField = postFieldParam.getFields().stream().filter(param -> Strings.CS.equals(param.getFieldId(), "stage") && param.getFieldValue() != null).findFirst().orElse(null); + handleStageSetting(stageField, contract, postFieldParam); + + for (ResourceApprovalFieldUpdateParam fieldUpdateParam : postFieldParam.getFields()) { + if (Strings.CS.equals(fieldUpdateParam.getFieldId(), "stage") && fieldUpdateParam.getFieldValue() != null) { + contractFieldService.setResourceFieldValue(contract, "stage", fieldUpdateParam.getFieldValue()); + continue; + } + if (!fieldConfigMap.containsKey(fieldUpdateParam.getFieldId()) || fieldUpdateParam.getFieldValue() == null) { + return; + } + BaseField fieldConfig = fieldConfigMap.get(fieldUpdateParam.getFieldId()); + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(fieldConfig.getType()); + if (fieldConfig.hasBusinessKey()) { + // 业务主表字段 + contractFieldService.setResourceFieldValue(contract, fieldConfig.getBusinessKey(), fieldUpdateParam.getFieldValue()); + } else { + // 快照自定义字段 + Optional findField = response.getModuleFields().stream().filter(fieldValue -> Strings.CI.equals(fieldValue.getFieldId(), fieldUpdateParam.getFieldId())).findAny(); + if (findField.isPresent()) { + findField.get().setFieldValue(fieldUpdateParam.getFieldValue()); + } else { + BaseModuleFieldValue fv = new BaseModuleFieldValue(); + fv.setFieldId(fieldUpdateParam.getFieldId()); + fv.setFieldValue(fieldUpdateParam.getFieldValue()); + response.getModuleFields().add(fv); + } + if (fieldConfig.isBlob()) { + // 自定义大表 + contractFieldService.getResourceFieldBlobMapper().deleteByLambda(new LambdaQueryWrapper() + .eq(ContractFieldBlob::getFieldId, fieldUpdateParam.getFieldId()).eq(ContractFieldBlob::getResourceId, postFieldParam.getResourceId())); + ContractFieldBlob field = new ContractFieldBlob(); + field.setId(IDGenerator.nextStr()); + field.setResourceId(postFieldParam.getResourceId()); + field.setFieldId(fieldUpdateParam.getFieldId()); + field.setFieldValue(customFieldResolver.convertToString(fieldConfig, fieldUpdateParam.getFieldValue())); + contractFieldBlobs.add(field); + } else { + // 自定义表 + contractFieldService.getResourceFieldMapper().deleteByLambda(new LambdaQueryWrapper() + .eq(ContractField::getFieldId, fieldUpdateParam.getFieldId()).eq(ContractField::getResourceId, postFieldParam.getResourceId())); + ContractField field = new ContractField(); + field.setId(IDGenerator.nextStr()); + field.setResourceId(postFieldParam.getResourceId()); + field.setFieldId(fieldUpdateParam.getFieldId()); + field.setFieldValue(customFieldResolver.convertToString(fieldConfig, fieldUpdateParam.getFieldValue())); + contractFields.add(field); + } + } + } + contractMapper.updateById(contract); + if (CollectionUtils.isNotEmpty(contractFields)) { + contractFieldService.getResourceFieldMapper().batchInsert(contractFields); + } + if (CollectionUtils.isNotEmpty(contractFieldBlobs)) { + contractFieldService.getResourceFieldBlobMapper().batchInsert(contractFieldBlobs); + } + // 更新快照 + if (snapshot != null) { + ContractGetResponse snapshotRes = get(contract, response.getModuleFields(), formConfig); + snapshot.setContractValue(JSON.toJSONString(snapshotRes)); + snapshotBaseMapper.update(snapshot); + } + // 记录审批后置字段更新日志 + baseService.handleUpdateLogWithSubTable(originContract, contract, originFields, contractFieldService.getModuleFieldValuesByResourceId(postFieldParam.getResourceId()), + postFieldParam.getResourceId(), contract.getName(), Translator.get("products_info"), formConfig); + // 从 OperationLogContext 中获取日志信息并手动记录 + LogContextInfo contextInfo = OperationLogContext.getContext(); + if (contextInfo != null) { + String orgId = OrganizationContext.getOrganizationId(); + LogDTO logDTO = new LogDTO(orgId, postFieldParam.getResourceId(), postFieldParam.getOperator(), LogType.UPDATE, LogModule.CONTRACT_INDEX, contract.getName()); + logDTO.setOriginalValue(contextInfo.getOriginalValue()); + logDTO.setModifiedValue(contextInfo.getModifiedValue()); + logService.add(logDTO); + OperationLogContext.clear(); + } + } + + + /** + * 审批后置操作更新阶段配置 + * @param stageField + * @param originContract + * @param postFieldParam + */ + private void handleStageSetting(ResourceApprovalFieldUpdateParam stageField, Contract originContract, ResourceApprovalPostUpdateParam postFieldParam) { + if (stageField == null) { + return; + } + if (!stageAdvancedConfigService.checkStage(originContract.getStage(), stageField.getFieldValue().toString(), FormKey.ORDER.getKey())) { + return; + } + StageConfigResponse first = extContractStageConfigMapper.getStageConfigList(originContract.getOrganizationId()).getFirst(); + if (Strings.CI.equals(first.getCirculationType(), CirculationTypeEnum.ADVANCED.name())) { + StageAdvancedConfig config = extStageAdvancedConfigMapper.getConfigByOriginAndTarget(originContract.getStage(), stageField.getFieldValue().toString(), FormKey.ORDER.name()); + List circulationFieldValues = JSON.parseObject(config.getFieldConfig(), new TypeReference>() { + }); + List fields = new ArrayList<>(); + circulationFieldValues.forEach(field -> { + if (Strings.CI.equals(field.getValueType(), CirculationFieldValueTypeEnum.FIXED_VALUE.name())) { + if (field.getFieldValue() != null) { + ResourceApprovalFieldUpdateParam param = new ResourceApprovalFieldUpdateParam(); + param.setEnable(true); + param.setFieldId(field.getFieldId()); + param.setFieldValue(field.getFieldValue()); + fields.add(param); + } + } + }); + postFieldParam.getFields().addAll(fields); + } + } + + + public CustomerContractStatisticResponse calculateContractStatisticByCustomerId(String customerId, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { + return extContractMapper.calculateContractStatisticByCustomerId(customerId, userId, orgId, deptDataPermission); + } + + public String getContractName(String id) { + Contract contract = contractMapper.selectByPrimaryKey(id); + return Optional.ofNullable(contract).map(Contract::getName).orElse(null); + } + + public Contract selectByPrimaryKey(String id) { + return contractMapper.selectByPrimaryKey(id); + } + + /** + * 通过名称获取合同集合 + * + * @param names 名称集合 + * @return 合同集合 + */ + public List getContractListByNames(List names) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.in(Contract::getName, names); + return contractMapper.selectListByLambda(lambdaQueryWrapper); + } + + /** + * 计算合同已回款金额 + * + * @param contractId 合同ID + * @return 已回款金额 + */ + private BigDecimal sumContractRecordAmount(String contractId) { + LambdaQueryWrapper paymentRecordWrapper = new LambdaQueryWrapper<>(); + paymentRecordWrapper.eq(ContractPaymentRecord::getContractId, contractId); + List contractPaymentRecords = contractPaymentRecordMapper.selectListByLambda(paymentRecordWrapper); + if (CollectionUtils.isNotEmpty(contractPaymentRecords)) { + return contractPaymentRecords.stream() + .map(ContractPaymentRecord::getRecordAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } else { + return BigDecimal.ZERO; + } + } + + /** + * 批量更新合同 + * + * @param request 批量编辑参数 + * @param userId 当前用户ID + * @param organizationId 当前组织ID + */ + public BatchAffectReasonResponse batchUpdate(ResourceBatchEditRequest request, String userId, String organizationId) { + BaseField field = contractFieldService.getAndCheckField(request.getFieldId(), organizationId); + // getAndCheckField 走的是 getConfig(),不会设置 businessKey,需要手动补充 + moduleFormService.setFieldBusinessParam(field); + List originContracts = contractMapper.selectByIds(request.getIds()); + if (CollectionUtils.isEmpty(originContracts)) { + return BatchAffectReasonResponse.builder().success(0).fail(0).skip(0).errorMessages(Translator.get("contract.not.exist")).build(); + } + + // 校验状态权限,过滤出有权限操作的合同 + List permittedIds = approvalFlowService.filterResourcesWithPermission( + ApprovalFormTypeEnum.CONTRACT.getValue(), + originContracts, + PermissionConstants.CONTRACT_UPDATE, + organizationId, + Contract::getId, + Contract::getApprovalStatus + ); + + if (CollectionUtils.isEmpty(permittedIds)) { + return BatchAffectReasonResponse.builder().success(0).fail(originContracts.size()).skip(0).errorMessages(Translator.get("no.operation.permission")).build(); + } + ApprovalResourceService approvalResourceService = CommonBeanFactory.getBean(ApprovalResourceService.class); + approvalResourceService.batchEditTriggerApproval(permittedIds, request.getFieldId(), FormKey.CONTRACT, organizationId, userId, field.getName(), request.getFieldValue()); + List permittedContracts = originContracts.stream() + .filter(c -> permittedIds.contains(c.getId())) + .collect(Collectors.toList()); + + ResourceBatchEditRequest filteredRequest = new ResourceBatchEditRequest(); + filteredRequest.setIds(permittedIds); + filteredRequest.setFieldId(request.getFieldId()); + filteredRequest.setFieldValue(request.getFieldValue()); + + contractFieldService.batchUpdate(filteredRequest, field, permittedContracts, Contract.class, LogModule.CONTRACT_INDEX, extContractMapper::batchUpdate, userId, organizationId); + + // 批量更新后重建每条合同的快照 + ModuleFormConfigDTO moduleFormConfigDTO = getFormConfig(organizationId); + ModuleFormConfigDTO saveModuleFormConfigDTO = JSON.parseObject(JSON.toJSONString(moduleFormConfigDTO), ModuleFormConfigDTO.class); + + // 批量删除旧快照(1次) + LambdaQueryWrapper delWrapper = new LambdaQueryWrapper<>(); + delWrapper.in(ContractSnapshot::getContractId, permittedIds); + snapshotBaseMapper.deleteByLambda(delWrapper); + + // 批量重新获取最新合同数据,因为业务字段已更新(1次替代N次) + List latestContracts = contractMapper.selectByIds(permittedIds); + Map latestContractMap = latestContracts.stream() + .collect(Collectors.toMap(Contract::getId, c -> c)); + + // 批量获取所有合同的自定义字段值(1次替代N次) + Map> fieldMap = contractFieldService.getResourceFieldMap(permittedIds, true); + + // 逐条构建快照,批量写入 + List snapshots = new ArrayList<>(); + for (String id : permittedIds) { + Contract contract = latestContractMap.get(id); + if (contract == null) continue; + List contractFields = fieldMap.getOrDefault(id, Collections.emptyList()); + List resolveFieldValues = moduleFormService.resolveSnapshotFields(contractFields, moduleFormConfigDTO, contractFieldService, id); + ContractGetResponse response = get(contract, resolveFieldValues, moduleFormConfigDTO); + // 过滤空值(与 saveSnapshot 保持一致) + if (CollectionUtils.isNotEmpty(response.getModuleFields())) { + response.setModuleFields(response.getModuleFields().stream() + .filter(f -> f.getFieldValue() != null && StringUtils.isNotBlank(f.getFieldValue().toString()) && !"[]".equals(f.getFieldValue().toString())) + .toList()); + } + ContractSnapshot snapshot = new ContractSnapshot(); + snapshot.setId(IDGenerator.nextStr()); + snapshot.setContractId(id); + snapshot.setContractProp(JSON.toJSONString(saveModuleFormConfigDTO)); + snapshot.setContractValue(JSON.toJSONString(response)); + snapshots.add(snapshot); + } + if (CollectionUtils.isNotEmpty(snapshots)) { + snapshotBaseMapper.batchInsert(snapshots); + } + + return BatchAffectReasonResponse.builder().success(permittedIds.size()).fail(originContracts.size() - permittedIds.size()).skip(0).errorMessages(Translator.get("batch.update.reason")).build(); + } + + /** + * 校验合同是否存在关联数据 + * + * @param contractId 合同ID + */ + private void checkContractRelated(String contractId) { + LambdaQueryWrapper recordWrapper = new LambdaQueryWrapper<>(); + recordWrapper.eq(ContractPaymentRecord::getContractId, contractId); + List contractPaymentRecords = contractPaymentRecordMapper.selectListByLambda(recordWrapper); + if (CollectionUtils.isNotEmpty(contractPaymentRecords)) { + throw new GenericException(Translator.get("contract.has.payment.record")); + } + if (extContractInvoiceMapper.hasContractInvoice(contractId)) { + throw new GenericException(Translator.get("contract.has.invoice.cannot.delete")); + } + } + + + /** + * 统计 + * + * @param request + * @param userId + * @param orgId + * @param deptDataPermission + * @return + */ + public ContractStatisticResponse searchStatistic(BaseCondition request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { + ContractStatisticResponse response = extContractMapper.searchStatistic(request, orgId, userId, deptDataPermission); + return Optional.ofNullable(response).orElse(new ContractStatisticResponse()); + } + + /** + * 通过ID集合获取合同名称 + * + * @param ids id集合 + * @return 合同名称 + */ + public Object getContractNameByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return StringUtils.EMPTY; + } + List contracts = contractMapper.selectByIds(ids); + if (CollectionUtils.isNotEmpty(contracts)) { + List names = contracts.stream().map(Contract::getName).toList(); + return String.join(",", names); + } + return StringUtils.EMPTY; + } + + + /** + * 阶段看板排序 + * + * @param request + * @param userId + */ + public void sort(StageSortRequest request, String userId) { + //拖拽节点 + Contract contract = contractMapper.selectByPrimaryKey(request.getDragNodeId()); + if (contract == null) { + throw new GenericException(Translator.get("contract.not.exist")); + } + Long pos = DEFAULT_POS; + if (StringUtils.isNotBlank(request.getDropNodeId())) { + //放入节点 + Contract dropNode = contractMapper.selectByPrimaryKey(request.getDropNodeId()); + pos = dropNode.getPos(); + if (request.getDropPosition() == -1) { + + extContractMapper.moveUpStageContract(pos, request.getStage(), DEFAULT_POS); + pos = pos + 1; + } else { + extContractMapper.moveDownStageContract(pos, request.getStage(), DEFAULT_POS); + } + } + + contract.setPos(pos); + contract.setStage(request.getStage()); + contractMapper.updateById(contract); + updateFieldAndSnapshot(contract,request.getFields(),userId); + + } + + /** + * 处理旧版本审批状态 (APPROVING => NONE) + */ + public void handleOldApprovalData() { + List contracts = contractMapper.selectListByLambda(new LambdaQueryWrapper().eq(Contract::getApprovalStatus, ApprovalStatus.APPROVING.name())); + contracts.forEach(contract -> { + ResourceSnapshotApprovalParam param = ResourceSnapshotApprovalParam.builder().resourceId(contract.getId()).approvalStatus(ApprovalStatus.NONE.name()).build(); + updateSnapshotApprovalStatus(param); + }); + extContractMapper.updateOldApprovalStatusNone(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractStageService.java b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractStageService.java new file mode 100644 index 0000000..3eff0e1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/contract/service/ContractStageService.java @@ -0,0 +1,280 @@ +package cn.cordys.crm.contract.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.stage.*; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.contract.domain.ContractStageConfig; +import cn.cordys.crm.contract.mapper.ExtContractMapper; +import cn.cordys.crm.contract.mapper.ExtContractStageConfigMapper; +import cn.cordys.crm.system.domain.StageAdvancedConfig; +import cn.cordys.crm.system.mapper.ExtStageAdvancedConfigMapper; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class ContractStageService { + public static final Long DEFAULT_POS = 1L; + @Resource + private ExtContractStageConfigMapper extContractStageConfigMapper; + @Resource + private ExtContractMapper extContractMapper; + @Resource + private BaseMapper contractStageConfigMapper; + @Resource + private ExtStageAdvancedConfigMapper extStageAdvancedConfigMapper; + + /** + * 列表 + * + * @param orgId + * @return + */ + public StageConfigsResponse getStageConfigList(String orgId) { + StageConfigsResponse stageConfigListResponse = new StageConfigsResponse(); + List stageConfigList = extContractStageConfigMapper.getStageConfigList(orgId); + List advancedConfigs = extStageAdvancedConfigMapper.selectConfigByType(orgId, FormKey.CONTRACT.getKey()); + buildList(stageConfigList, stageConfigListResponse, advancedConfigs); + return stageConfigListResponse; + } + + private void buildList(List stageConfigList, StageConfigsResponse response, List advancedConfigs) { + response.setStageConfigList(stageConfigList); + if (CollectionUtils.isNotEmpty(stageConfigList)) { + var first = stageConfigList.getFirst(); + response.setEndRollBack(first.getEndRollBack()); + response.setAfootRollBack(first.getAfootRollBack()); + stageConfigList.forEach(sc -> sc.setStageHasData(extContractMapper.countByStage(sc.getId()) > 0)); + response.setCirculationType(first.getCirculationType()); + } + if (CollectionUtils.isNotEmpty(advancedConfigs)) { + Map> originIdMaps = advancedConfigs.stream().collect(Collectors.groupingBy(StageAdvancedConfig::getOriginId)); + List configs = new ArrayList<>(); + + originIdMaps.forEach((key, value) -> { + CirculationSetting configResponse = new CirculationSetting(); + List targetList = new ArrayList<>(); + configResponse.setOriginId(key); + value.forEach(item -> { + Target target = new Target(); + target.setTargetId(item.getTargetId()); + target.setEnable(item.getEnable()); + List circulationFieldValues = JSON.parseObject(item.getFieldConfig(), new TypeReference>() { + }); + target.setCirculationFieldValues(circulationFieldValues); + targetList.add(target); + }); + configResponse.setTargets(targetList); + configResponse.setModuleType(value.getFirst().getModuleType()); + configs.add(configResponse); + }); + Map configMap = configs.stream() + .collect(Collectors.toMap( + CirculationSetting::getOriginId, + Function.identity() + )); + + List sortedConfigs = stageConfigList.stream() + .map(stage -> configMap.get(stage.getId())) + .filter(Objects::nonNull) + .toList(); + + response.setAdvancedConfigs(sortedConfigs); + } + } + + + /** + * 添加合同状态流配置 + * + * @param request + * @param userId + * @param orgId + * @return + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.ADD) + public String addStageConfig(StageAddRequest request, String userId, String orgId) { + checkConfigCount(orgId); + Long pos = DEFAULT_POS; + Boolean afootRollBack = true; + Boolean endRollBack = false; + //源节点 + ContractStageConfig target = contractStageConfigMapper.selectByPrimaryKey(request.getTargetId()); + if (target != null) { + pos = target.getPos(); + //target正常不会为空 + if (request.getDropPosition() == -1) { + extContractStageConfigMapper.moveUpStageConfig(pos, orgId, DEFAULT_POS); + } else { + extContractStageConfigMapper.moveDownStageConfig(pos, orgId, DEFAULT_POS); + pos = pos + 1; + } + afootRollBack = target.getAfootRollBack(); + endRollBack = target.getEndRollBack(); + } + + ContractStageConfig stageConfig = new ContractStageConfig(); + stageConfig.setId(IDGenerator.nextStr()); + stageConfig.setName(request.getName()); + stageConfig.setType(request.getType()); + stageConfig.setAfootRollBack(afootRollBack); + stageConfig.setEndRollBack(endRollBack); + stageConfig.setPos(pos); + stageConfig.setOrganizationId(orgId); + stageConfig.setCreateUser(userId); + stageConfig.setUpdateUser(userId); + stageConfig.setCreateTime(System.currentTimeMillis()); + stageConfig.setUpdateTime(System.currentTimeMillis()); + contractStageConfigMapper.insert(stageConfig); + + OperationLogContext.setContext(LogContextInfo.builder() + .modifiedValue(stageConfig) + .resourceId(stageConfig.getId()) + .resourceName(Translator.get("contract_stage_setting").concat(":").concat(request.getName())) + .build()); + + return stageConfig.getId(); + } + + + /** + * 配置数量校验 + * + * @param orgId + */ + private void checkConfigCount(String orgId) { + if (extContractStageConfigMapper.countStageConfig(orgId) > 10) { + throw new GenericException(Translator.get("contract_stage_config_list")); + } + } + + + /** + * 删除 + * + * @param id + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + ContractStageConfig stageConfig = deletePreCheck(id); + contractStageConfigMapper.deleteByPrimaryKey(id); + // 设置操作对象 + OperationLogContext.setResourceName(Translator.get("contract_stage_setting").concat(":").concat(stageConfig.getName())); + } + + + private ContractStageConfig deletePreCheck(String id) { + ContractStageConfig stageConfig = contractStageConfigMapper.selectByPrimaryKey(id); + if (stageConfig == null) { + throw new GenericException(Translator.get("contract_stage_delete")); + } + return stageConfig; + } + + + /** + * 更新订单流阶段回退设置 + * + * @param request + * @param orgId + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE) + public void updateRollBack(StageRollBackRequest request, String orgId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ContractStageConfig::getOrganizationId, orgId); + List stageConfigList = contractStageConfigMapper.selectListByLambda(wrapper); + extContractStageConfigMapper.updateRollBack(request, orgId); + + Map originalVal = new HashMap<>(1); + originalVal.put("afootRollBack", Translator.get("log.enable.".concat(stageConfigList.getFirst().getAfootRollBack().toString()))); + originalVal.put("endRollBack", Translator.get("log.enable.".concat(stageConfigList.getFirst().getEndRollBack().toString()))); + Map modifiedVal = new HashMap<>(1); + modifiedVal.put("afootRollBack", Translator.get("log.enable.".concat(request.getAfootRollBack().toString()))); + modifiedVal.put("endRollBack", Translator.get("log.enable.".concat(request.getEndRollBack().toString()))); + OperationLogContext.setContext(LogContextInfo.builder() + .originalValue(originalVal) + .resourceName(Translator.get("contract_stage_setting")) + .modifiedValue(modifiedVal) + .resourceId(orgId) + .build()); + } + + + /** + * 更新配置 + * + * @param request + * @param userId + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE) + public void update(StageUpdateRequest request, String userId) { + ContractStageConfig oldStageConfig = contractStageConfigMapper.selectByPrimaryKey(request.getId()); + if (oldStageConfig == null) { + throw new GenericException(Translator.get("order_stage_not_exist")); + } + extContractStageConfigMapper.updateStageConfig(request, userId); + + Map originalVal = new HashMap<>(1); + originalVal.put("contractStage", oldStageConfig.getName()); + Map modifiedVal = new HashMap<>(1); + modifiedVal.put("contractStage", request.getName()); + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(request.getId()) + .resourceName(Translator.get("contract_stage_setting")) + .originalValue(originalVal) + .modifiedValue(modifiedVal) + .build() + ); + } + + + /** + * 排序 + * + * @param ids + * @param orgId + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE) + public void sort(List ids, String orgId) { + List oldStageConfigList = extContractStageConfigMapper.getStageConfigList(orgId); + List oldNames = oldStageConfigList.stream().map(StageConfigResponse::getName).toList(); + + for (int i = 0; i < ids.size(); i++) { + extContractStageConfigMapper.updatePos(ids.get(i), (long) (i + 1)); + } + + List newStageConfigList = extContractStageConfigMapper.getStageConfigList(orgId); + List newNames = newStageConfigList.stream().map(StageConfigResponse::getName).toList(); + + Map> originalVal = new HashMap<>(1); + originalVal.put("stageSort", oldNames); + Map> modifiedVal = new HashMap<>(1); + modifiedVal.put("stageSort", newNames); + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(orgId) + .resourceName(Translator.get("contract_stage_setting")) + .originalValue(originalVal) + .modifiedValue(modifiedVal) + .build() + ); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/constants/CustomerCollaborationType.java b/backend/crm/src/main/java/cn/cordys/crm/customer/constants/CustomerCollaborationType.java new file mode 100644 index 0000000..6e0494f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/constants/CustomerCollaborationType.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.customer.constants; + +/** + * @Author: jianxing + * @CreateTime: 2025-03-05 16:57 + */ +public enum CustomerCollaborationType { + /** + * 只读 + */ + READ_ONLY, + /** + * 协作 + */ + COLLABORATION +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/constants/CustomerRelationType.java b/backend/crm/src/main/java/cn/cordys/crm/customer/constants/CustomerRelationType.java new file mode 100644 index 0000000..720d1c9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/constants/CustomerRelationType.java @@ -0,0 +1,10 @@ +package cn.cordys.crm.customer.constants; + +/** + * @Author: jianxing + * @CreateTime: 2025-03-13 11:51 + */ +public enum CustomerRelationType { + GROUP, + SUBSIDIARY +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/constants/CustomerResultCode.java b/backend/crm/src/main/java/cn/cordys/crm/customer/constants/CustomerResultCode.java new file mode 100644 index 0000000..4a37b15 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/constants/CustomerResultCode.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.customer.constants; + +import cn.cordys.common.exception.IResultCode; + +/** + * @author jianxing + */ +public enum CustomerResultCode implements IResultCode { + + CUSTOMER_EXIST(102001, "customer.exist"), + CUSTOMER_CONTACT_EXIST(102002, "customer_contact.exist"), + CUSTOMER_RESOURCE_REF(102003, "customer.ref_resource.exist"); + + private final int code; + private final String message; + + CustomerResultCode(int code, String message) { + this.code = code; + this.message = message; + } + + @Override + public int getCode() { + return code; + } + + @Override + public String getMessage() { + return getTranslationMessage(this.message); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerCapacityController.java b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerCapacityController.java new file mode 100644 index 0000000..ea344b4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerCapacityController.java @@ -0,0 +1,54 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.customer.dto.CustomerCapacityDTO; +import cn.cordys.crm.customer.service.CustomerCapacityService; +import cn.cordys.crm.system.dto.request.CapacityAddRequest; +import cn.cordys.crm.system.dto.request.CapacityUpdateRequest; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/account-capacity") +@Tag(name = "客户库容容量设置") +public class CustomerCapacityController { + + @Resource + private CustomerCapacityService customerCapacityService; + + @GetMapping("/get") + @Operation(summary = "获取客户库容设置") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public List list() { + return customerCapacityService.list(OrganizationContext.getOrganizationId()); + } + + @PostMapping("/add") + @Operation(summary = "添加客户库容设置") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void add(@Validated @RequestBody CapacityAddRequest request) { + customerCapacityService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @Operation(summary = "修改客户库容设置") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void update(@Validated @RequestBody CapacityUpdateRequest request) { + customerCapacityService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/delete/{id}") + @Operation(summary = "删除客户库容设置") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void delete(@PathVariable("id") @Validated String id) { + customerCapacityService.delete(id); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerCollaborationController.java b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerCollaborationController.java new file mode 100644 index 0000000..11565dc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerCollaborationController.java @@ -0,0 +1,67 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.permission.CsBatchPermission; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.customer.domain.CustomerCollaboration; +import cn.cordys.crm.customer.dto.request.CustomerCollaborationAddRequest; +import cn.cordys.crm.customer.dto.request.CustomerCollaborationUpdateRequest; +import cn.cordys.crm.customer.dto.response.CustomerCollaborationListResponse; +import cn.cordys.crm.customer.service.CustomerCollaborationService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * @author jianxing + * @date 2025-02-08 17:42:41 + */ +@Tag(name = "客户协作人") +@RestController +@RequestMapping("/account/collaboration") +public class CustomerCollaborationController { + @Resource + private CustomerCollaborationService customerCollaborationService; + + @GetMapping("/list/{customerId}") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_READ, resourceId = "{#customerId}", formType = FormKeyConstants.CUSTOMER) + @Operation(summary = "客户协作人列表") + public List list(@PathVariable String customerId) { + return customerCollaborationService.list(customerId, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/add") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE) + @Operation(summary = "添加客户协作人") + public CustomerCollaboration add(@Validated @RequestBody CustomerCollaborationAddRequest request) { + return customerCollaborationService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.CUSTOMER) + @Operation(summary = "更新客户协作人") + public CustomerCollaboration update(@Validated @RequestBody CustomerCollaborationUpdateRequest request) { + return customerCollaborationService.update(request, SessionUtils.getUserId()); + } + + @GetMapping("/delete/{id}") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, resourceId = "{#id}", formType = FormKeyConstants.CUSTOMER) + @Operation(summary = "删除客户协作人") + public void delete(@PathVariable String id) { + customerCollaborationService.delete(id); + } + + @PostMapping("/batch/delete") + @Operation(summary = "批量删除客户协作人") + @CsBatchPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, resourceId = "{#ids}", formType = FormKeyConstants.CUSTOMER) + public void batchDelete(@RequestBody List ids) { + customerCollaborationService.batchDelete(ids); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerContactController.java b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerContactController.java new file mode 100644 index 0000000..c6c0e20 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerContactController.java @@ -0,0 +1,205 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.ChartAnalysisRequest; +import cn.cordys.common.permission.CsBatchPermission; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.customer.domain.CustomerContact; +import cn.cordys.crm.customer.dto.request.*; +import cn.cordys.crm.customer.dto.response.CustomerContactGetResponse; +import cn.cordys.crm.customer.dto.response.CustomerContactListAllResponse; +import cn.cordys.crm.customer.dto.response.CustomerContactListResponse; +import cn.cordys.crm.customer.service.CustomerContactExportService; +import cn.cordys.crm.customer.service.CustomerContactService; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * @author jianxing + * @date 2025-02-24 11:06:10 + */ +@Tag(name = "客户联系人") +@RestController +@RequestMapping("/account/contact") +public class CustomerContactController { + @Resource + private CustomerContactService customerContactService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private DataScopeService dataScopeService; + @Resource + private CustomerContactExportService customerContactExportService; + + @GetMapping("/module/form") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_READ, + PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ, PermissionConstants.OPPORTUNITY_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "获取表单配置") + public ModuleFormConfigDTO getModuleFormConfig() { + return moduleFormCacheService.getBusinessFormConfig(FormKey.CONTACT.getKey(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/page") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ) + @Operation(summary = "联系人列表") + public PagerWithOption> list(@Validated @RequestBody CustomerContactPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTACT.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ); + return customerContactService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @PostMapping("/chart") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ) + @Operation(summary = "联系人图表生成") + public List chart(@Validated @RequestBody ChartAnalysisRequest request) { + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ); + return customerContactService.chart(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @GetMapping("/list/{customerId}") + @Operation(summary = "客户下的联系人列表") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_READ, resourceId = "{#customerId}", formType = FormKeyConstants.CUSTOMER) + public CustomerContactListAllResponse list(@Validated @PathVariable String customerId) { + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), PermissionConstants.CUSTOMER_MANAGEMENT_READ); + return customerContactService.listByCustomerId(customerId, SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @GetMapping("/get/{id}") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_READ, + PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ}, logical = Logical.OR) + @Operation(summary = "客户联系人详情") + public CustomerContactGetResponse get(@PathVariable String id) { + return customerContactService.get(id); + } + + @PostMapping("/add") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_ADD, + PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_ADD}, logical = Logical.OR) + @Operation(summary = "添加客户联系人") + public CustomerContact add(@Validated @RequestBody CustomerContactAddRequest request) { + return customerContactService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, + PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_UPDATE}, logical = Logical.OR) + @Operation(summary = "更新客户联系人") + public CustomerContact update(@Validated @RequestBody CustomerContactUpdateRequest request) { + return customerContactService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/enable/{id}") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, + PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_UPDATE}, logical = Logical.OR) + @Operation(summary = "启用联系人") + public void enable(@PathVariable String id) { + customerContactService.enable(id); + } + + @PostMapping("/disable/{id}") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, + PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_UPDATE}, logical = Logical.OR) + @Operation(summary = "禁用联系人") + public void disable(@PathVariable String id, @RequestBody CustomerContactDisableRequest request) { + customerContactService.disable(id, request); + } + + @GetMapping("/delete/{id}") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_DELETE, + PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_DELETE}, logical = Logical.OR) + @Operation(summary = "删除客户联系人") + public void delete(@PathVariable String id) { + customerContactService.delete(id); + } + + @GetMapping("/opportunity/check/{id}") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_DELETE, + PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_DELETE}, logical = Logical.OR) + @Operation(summary = "检查客户联系人是否有关联商机") + public boolean checkOpportunity(@PathVariable String id) { + return customerContactService.checkOpportunity(id); + } + + @GetMapping("/tab") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ) + @Operation(summary = "所有所有和部门联系人tab是否显示") + public ResourceTabEnableDTO getTabEnableConfig() { + return customerContactService.getTabEnableConfig(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/export-all") + @Operation(summary = "联系人导出全部") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_EXPORT) + public String customerContactExportAll(@Validated @RequestBody CustomerContactExportRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTACT.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ); + return customerContactExportService.export(SessionUtils.getUserId(), request, OrganizationContext.getOrganizationId(), deptDataPermission, LocaleContextHolder.getLocale()); + } + + + @PostMapping("/export-select") + @Operation(summary = "导出选中联系人") + @CsBatchPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_EXPORT, resourceId = "{#request.ids}", formType = FormKeyConstants.CUSTOMER) + public String customerContactExportSelect(@Validated @RequestBody ExportSelectRequest request) { + return customerContactExportService.exportSelect(SessionUtils.getUserId(), request, OrganizationContext.getOrganizationId(), LocaleContextHolder.getLocale()); + } + + @GetMapping("/template/download") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_IMPORT) + @Operation(summary = "下载导入模板") + public void downloadImportTpl(HttpServletResponse response) { + customerContactService.downloadImportTpl(response, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/import/pre-check") + @Operation(summary = "导入检查") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_IMPORT) + public ImportResponse preCheck(@RequestPart(value = "file") MultipartFile file) { + return customerContactService.importPreCheck(file, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/import") + @Operation(summary = "导入") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_IMPORT) + public ImportResponse realImport(@RequestPart(value = "file") MultipartFile file) { + return customerContactService.realImport(file, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/batch/update") + @CsBatchPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_UPDATE, resourceId = "{#request.ids}", formType = FormKeyConstants.CUSTOMER) + @Operation(summary = "批量更新客户联系人") + public void batchUpdate(@Validated @RequestBody ResourceBatchEditRequest request) { + customerContactService.batchUpdate(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerContactUserViewController.java b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerContactUserViewController.java new file mode 100644 index 0000000..68d3696 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerContactUserViewController.java @@ -0,0 +1,95 @@ +package cn.cordys.crm.customer.controller; + + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.UserViewResourceType; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import cn.cordys.crm.system.dto.response.UserViewListResponse; +import cn.cordys.crm.system.dto.response.UserViewResponse; +import cn.cordys.crm.system.service.UserViewService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "联系人视图") +@RestController +@RequestMapping("/account/contact/view") +public class CustomerContactUserViewController { + + @Resource + private UserViewService userViewService; + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ) + @Operation(summary = "添加联系人视图") + public UserView add(@Validated @RequestBody UserViewAddRequest request) { + return userViewService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), UserViewResourceType.CUSTOMER_CONTACT.name()); + } + + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ) + @Operation(summary = "编辑联系人视图") + public UserView update(@Validated @RequestBody UserViewUpdateRequest request) { + return userViewService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ) + @Operation(summary = "删除联系人视图") + public void delete(@PathVariable String id) { + userViewService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/detail/{id}") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ) + @Operation(summary = "联系人视图详情") + public UserViewResponse viewDetail(@PathVariable String id) { + return userViewService.getViewDetail(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), FormKey.CONTACT.getKey()); + } + + + @GetMapping("/list") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ) + @Operation(summary = "联系人视图列表") + public List queryList() { + return userViewService.list(UserViewResourceType.CUSTOMER_CONTACT.name(), SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/fixed/{id}") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ) + @Operation(summary = "联系人视图固定/取消固定") + public void fixed(@PathVariable String id) { + userViewService.fixed(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/edit/pos") + @Operation(summary = "联系人视图-拖拽排序") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ) + public void editPos(@Validated @RequestBody PosRequest request) { + userViewService.editPos(request, SessionUtils.getUserId(), UserViewResourceType.CUSTOMER_CONTACT.name()); + } + + + @GetMapping("/enable/{id}") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ) + @Operation(summary = "联系人视图-启用/禁用") + public void enable(@PathVariable String id) { + userViewService.enable(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerController.java b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerController.java new file mode 100644 index 0000000..b8de4d4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerController.java @@ -0,0 +1,355 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.InternalUserView; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.*; +import cn.cordys.common.permission.CsBatchPermission; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.contract.dto.request.ContractPaymentRecordPageRequest; +import cn.cordys.crm.contract.dto.response.*; +import cn.cordys.crm.contract.service.ContractInvoiceService; +import cn.cordys.crm.contract.service.ContractPaymentPlanService; +import cn.cordys.crm.contract.service.ContractPaymentRecordService; +import cn.cordys.crm.contract.service.ContractService; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.dto.request.*; +import cn.cordys.crm.customer.dto.response.CustomerGetResponse; +import cn.cordys.crm.customer.dto.response.CustomerListResponse; +import cn.cordys.crm.customer.service.CustomerExportService; +import cn.cordys.crm.customer.service.CustomerService; +import cn.cordys.crm.opportunity.dto.response.OpportunityListResponse; +import cn.cordys.crm.opportunity.service.OpportunityService; +import cn.cordys.crm.order.dto.response.OrderListResponse; +import cn.cordys.crm.order.service.OrderService; +import cn.cordys.crm.system.dto.request.BatchPoolReasonRequest; +import cn.cordys.crm.system.dto.request.PoolReasonRequest; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.BatchAffectResponse; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.security.SessionUtils; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; +import java.util.List; + +/** + * @author jianxing + * @date 2025-02-08 17:42:41 + */ +@Tag(name = "客户") +@RestController +@RequestMapping("/account") +public class CustomerController { + + @Resource + private CustomerService customerService; + @Resource + private OpportunityService opportunityService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private DataScopeService dataScopeService; + @Resource + private CustomerExportService customerExportService; + @Resource + private ContractService contractService; + @Resource + private ContractPaymentPlanService contractPaymentPlanService; + @Resource + private ContractPaymentRecordService contractPaymentRecordService; + @Resource + private ContractInvoiceService contractInvoiceService; + @Resource + private OrderService orderService; + + @GetMapping("/module/form") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ}, logical = Logical.OR) + @Operation(summary = "获取表单配置") + public ModuleFormConfigDTO getModuleFormConfig() { + return moduleFormCacheService.getBusinessFormConfig(FormKey.CUSTOMER.getKey(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/page") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_READ) + @Operation(summary = "客户列表") + public PagerWithOption> list(@Validated @RequestBody CustomerPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CUSTOMER.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CUSTOMER_MANAGEMENT_READ); + return customerService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @GetMapping("/get/{id}") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_READ) // 不校验数据权限,service 还需校验协作人权限 + @Operation(summary = "客户详情") + public CustomerGetResponse get(@PathVariable String id) { + return customerService.getWithDataPermissionCheck(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/add") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_ADD) + @Operation(summary = "添加客户") + public Customer add(@Validated @RequestBody CustomerAddRequest request) { + return customerService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.CUSTOMER) + @Operation(summary = "更新客户") + public Customer update(@Validated @RequestBody CustomerUpdateRequest request) { + return customerService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/delete/{id}") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_DELETE, resourceId = "{#id}", formType = FormKeyConstants.CUSTOMER) + @Operation(summary = "删除客户") + public void delete(@PathVariable String id) { + customerService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch/transfer") + @CsBatchPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_TRANSFER, resourceId = "{#request.ids}", formType = FormKeyConstants.CUSTOMER) + @Operation(summary = "批量转移客户") + public void batchTransfer(@RequestBody CustomerBatchTransferRequest request) { + customerService.batchTransfer(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch/update") + @CsBatchPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, resourceId = "{#request.ids}", formType = FormKeyConstants.CUSTOMER) + @Operation(summary = "批量更新客户") + public void batchUpdate(@Validated @RequestBody ResourceBatchEditRequest request) { + customerService.batchUpdate(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch/delete") + @CsBatchPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_DELETE, resourceId = "{#ids}", formType = FormKeyConstants.CUSTOMER) + @Operation(summary = "批量删除客户") + public void batchDelete(@RequestBody @NotNull List ids) { + customerService.batchDelete(ids, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch/to-pool") + @CsBatchPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_RECYCLE, resourceId = "{#request.ids}", formType = FormKeyConstants.CUSTOMER) + @Operation(summary = "批量移入公海") + public BatchAffectResponse batchToPool(@RequestBody BatchPoolReasonRequest request) { + return customerService.batchToPool(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/to-pool") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_RECYCLE, resourceId = "{#request.id}", formType = FormKeyConstants.CUSTOMER) + @Operation(summary = "移入公海") + public BatchAffectResponse toPool(@Validated @RequestBody PoolReasonRequest request) { + return customerService.toPool(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/option") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_READ) + @Operation(summary = "客户选项") + public Pager> getCustomerOptions(@Validated @RequestBody BasePageRequest request) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, customerService.getCustomerOptions(request.getKeyword(), OrganizationContext.getOrganizationId())); + } + + @GetMapping("/tab") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_READ) + @Operation(summary = "所有客户和部门客户tab是否显示") + public ResourceTabEnableDTO getTabEnableConfig() { + return customerService.getTabEnableConfig(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/opportunity/page") + @RequiresPermissions({PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.OPPORTUNITY_MANAGEMENT_READ}) + @Operation(summary = "客户详情-商机列表") + public PagerWithOption> list(@Validated @RequestBody CustomerOpportunityPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.OPPORTUNITY.getKey()); + request.setViewId("ALL"); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.OPPORTUNITY_MANAGEMENT_READ); + return opportunityService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission, false); + } + + @PostMapping("/export-all") + @Operation(summary = "客户导出全部") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_EXPORT) + public String opportunityExportAll(@Validated @RequestBody CustomerExportRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CUSTOMER.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CUSTOMER_MANAGEMENT_READ); + return customerExportService.export(SessionUtils.getUserId(), request, OrganizationContext.getOrganizationId(), deptDataPermission, LocaleContextHolder.getLocale()); + } + + @PostMapping("/export-select") + @Operation(summary = "导出选中客户") + @CsBatchPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_EXPORT, resourceId = "{#request.ids}", formType = FormKeyConstants.CUSTOMER) + public String opportunityExportSelect(@Validated @RequestBody ExportSelectRequest request) { + return customerExportService.exportSelect(SessionUtils.getUserId(), request, OrganizationContext.getOrganizationId(), LocaleContextHolder.getLocale()); + } + + @GetMapping("/template/download") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_IMPORT) + @Operation(summary = "下载导入模板") + public void downloadImportTpl(HttpServletResponse response) { + customerService.downloadImportTpl(response, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/import/pre-check") + @Operation(summary = "导入检查") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_IMPORT) + public ImportResponse preCheck(@RequestPart(value = "file") MultipartFile file) { + return customerService.importPreCheck(file, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/import") + @Operation(summary = "导入") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_IMPORT) + public ImportResponse realImport(@RequestPart(value = "file") MultipartFile file) { + return customerService.realImport(file, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/merge/page") + @Operation(summary = "分页获取合并客户列表") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_READ) + public Pager> sourceCustomerPage(@Valid @RequestBody CustomerPageRequest request) { + request.setCombineSearch(request.getCombineSearch().convert()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), + "ALL", PermissionConstants.CUSTOMER_MANAGEMENT_READ); + return customerService.sourceList(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @PostMapping("/merge") + @Operation(summary = "合并客户") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_MERGE) + public void merge(@Valid @RequestBody CustomerMergeRequest request) { + customerService.merge(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/chart") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_READ) + @Operation(summary = "客户图表生成") + public List chart(@Validated @RequestBody ChartAnalysisRequest request) { + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CUSTOMER_MANAGEMENT_READ); + return customerService.chart(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + + @PostMapping("/contract/page") + @RequiresPermissions({PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.CONTRACT_READ}) + @Operation(summary = "客户详情-合同列表") + public PagerWithOption> contractList(@Validated @RequestBody CustomerContractPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTRACT.getKey()); + request.setViewId(InternalUserView.ALL.name()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_READ); + return contractService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission, false); + } + + @GetMapping("/contract/statistic/{accountId}") + @RequiresPermissions({PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.CONTRACT_READ}) + @Operation(summary = "客户详情-合同列表统计") + public CustomerContractStatisticResponse calculateCustomerContractStatistic(@PathVariable String accountId) { + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), PermissionConstants.CONTRACT_READ); + return contractService.calculateContractStatisticByCustomerId(accountId, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @PostMapping("/contract/payment-plan/page") + @RequiresPermissions({PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.CONTRACT_PAYMENT_PLAN_READ}) + @Operation(summary = "客户详情-合同回款计划列表") + public PagerWithOption> contractList(@Validated @RequestBody CustomerContractPaymentPlanPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTRACT_PAYMENT_PLAN.getKey()); + request.setViewId(InternalUserView.ALL.name()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_PAYMENT_PLAN_READ); + return contractPaymentPlanService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @GetMapping("/contract/payment-plan/statistic/{accountId}") + @RequiresPermissions({PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.CONTRACT_PAYMENT_PLAN_READ}) + @Operation(summary = "客户详情-合同回款计划列表统计") + public CustomerPaymentPlanStatisticResponse calculateCustomerPaymentPlanStatistic(@PathVariable String accountId) { + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), PermissionConstants.CONTRACT_PAYMENT_PLAN_READ); + return contractPaymentPlanService.calculateCustomerPaymentPlanStatistic(accountId, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @PostMapping("/contract/payment-record/page") + @RequiresPermissions({PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.CONTRACT_PAYMENT_RECORD_READ}) + @Operation(summary = "客户详情-合同回款记录列表") + public PagerWithOption> recordList(@Validated @RequestBody ContractPaymentRecordPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTRACT_PAYMENT_RECORD.getKey()); + request.setViewId(InternalUserView.ALL.name()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_PAYMENT_RECORD_READ); + return contractPaymentRecordService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @GetMapping("/contract/payment-record/statistic/{accountId}") + @RequiresPermissions({PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.CONTRACT_PAYMENT_RECORD_READ}) + @Operation(summary = "客户详情-合同回款记录列表统计") + public CustomerPaymentRecordStatisticResponse calculateCustomerPaymentRecordStatistic(@PathVariable String accountId) { + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), PermissionConstants.CONTRACT_PAYMENT_RECORD_READ); + return contractPaymentRecordService.sumCustomerPaymentAmount(accountId, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @PostMapping("/invoice/page") + @RequiresPermissions({PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.CONTRACT_INVOICE_READ}) + @Operation(summary = "客户详情-发票列表") + public PagerWithOption> invoiceList(@Validated @RequestBody CustomerContractInvoicePageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.INVOICE.getKey()); + request.setViewId(InternalUserView.ALL.name()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_INVOICE_READ); + return contractInvoiceService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @PostMapping("/order/page") + @RequiresPermissions({PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.ORDER_READ}) + @Operation(summary = "客户详情-订单列表") + public PagerWithOption> invoiceList(@Validated @RequestBody CustomerOrderPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.ORDER.getKey()); + request.setViewId(InternalUserView.ALL.name()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.ORDER_READ); + return orderService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission, false); + } + + @GetMapping("/invoice/statistic/{accountId}") + @RequiresPermissions({PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.CONTRACT_INVOICE_READ}) + @Operation(summary = "客户详情-发票列表统计") + public CustomerInvoiceStatisticResponse calculateCustomerInvoiceStatistic(@PathVariable String accountId) { + BigDecimal invoiceAmount = contractInvoiceService.calculateCustomerInvoiceAmount(accountId, SessionUtils.getUserId(), + OrganizationContext.getOrganizationId()); + + CustomerInvoiceStatisticResponse response = new CustomerInvoiceStatisticResponse(); + response.setContractAmount(calculateCustomerContractStatistic(accountId).getTotalAmount()); + response.setInvoicedAmount(invoiceAmount); + response.setUninvoicedAmount(response.getContractAmount().subtract(invoiceAmount)); + return response; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerFollowPlanController.java b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerFollowPlanController.java new file mode 100644 index 0000000..8b4c63a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerFollowPlanController.java @@ -0,0 +1,88 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.follow.domain.FollowUpPlan; +import cn.cordys.crm.follow.dto.CustomerDataDTO; +import cn.cordys.crm.follow.dto.request.FollowUpPlanAddRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanPageRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanStatusRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanUpdateRequest; +import cn.cordys.crm.follow.dto.response.FollowUpPlanDetailResponse; +import cn.cordys.crm.follow.dto.response.FollowUpPlanListResponse; +import cn.cordys.crm.follow.service.FollowUpPlanService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "客户跟进计划") +@RestController +@RequestMapping("/account/follow/plan") +public class CustomerFollowPlanController { + + @Resource + private FollowUpPlanService followUpPlanService; + + @PostMapping("/add") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE) + @Operation(summary = "添加客户跟进计划") + public FollowUpPlan add(@Validated @RequestBody FollowUpPlanAddRequest request) { + return followUpPlanService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/update") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.FOLLOW_PLAN) + @Operation(summary = "更新客户跟进计划") + public FollowUpPlan update(@Validated @RequestBody FollowUpPlanUpdateRequest request) { + return followUpPlanService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/page") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_READ) + @Operation(summary = "客户跟进计划列表") + public PagerWithOption> list(@Validated @RequestBody FollowUpPlanPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.FOLLOW_PLAN.getKey()); + return followUpPlanService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), "CUSTOMER", "CUSTOMER"); + } + + @GetMapping("/get/{id}") + @Operation(summary = "客户跟进计划详情") + public FollowUpPlanDetailResponse get(@PathVariable String id) { + return followUpPlanService.get(id, OrganizationContext.getOrganizationId()); + } + + @GetMapping("/cancel/{id}") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, resourceId = "{#id}", formType = FormKeyConstants.FOLLOW_PLAN) + @Operation(summary = "取消客户跟进计划") + public void cancelPlan(@PathVariable String id) { + followUpPlanService.cancelPlan(id, SessionUtils.getUserId()); + } + + + @GetMapping("/delete/{id}") + @Operation(summary = "客户删除跟进计划") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, resourceId = "{#id}", formType = FormKeyConstants.FOLLOW_PLAN) + public void deletePlan(@PathVariable String id) { + followUpPlanService.delete(id); + } + + + @PostMapping("/status/update") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.FOLLOW_PLAN) + @Operation(summary = "客户更新跟进计划状态") + public void updateStatus(@Validated @RequestBody FollowUpPlanStatusRequest request) { + followUpPlanService.updateStatus(request, SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerFollowRecordController.java b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerFollowRecordController.java new file mode 100644 index 0000000..0dbe63b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerFollowRecordController.java @@ -0,0 +1,82 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.follow.domain.FollowUpRecord; +import cn.cordys.crm.follow.dto.request.FollowUpRecordAddRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordPageRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordUpdateRequest; +import cn.cordys.crm.follow.dto.response.FollowUpRecordDetailResponse; +import cn.cordys.crm.follow.dto.response.FollowUpRecordListResponse; +import cn.cordys.crm.follow.service.FollowUpRecordService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "客户跟进记录") +@RestController +@RequestMapping("/account/follow/record") +public class CustomerFollowRecordController { + + @Resource + private FollowUpRecordService followUpRecordService; + + @PostMapping("/add") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE) + @Operation(summary = "添加客户跟进记录") + public FollowUpRecord add(@Validated @RequestBody FollowUpRecordAddRequest request) { + return followUpRecordService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/update") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.FOLLOW_RECORD) + @Operation(summary = "更新客户跟进记录") + public FollowUpRecord update(@Validated @RequestBody FollowUpRecordUpdateRequest request) { + return followUpRecordService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/pool/page") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ) + @Operation(summary = "客户公海池跟进记录列表") + public PagerWithOption> poolList(@Validated @RequestBody FollowUpRecordPageRequest request) { + return followUpRecordService.poolList(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), "CUSTOMER", "CUSTOMER"); + } + + @PostMapping("/page") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_READ) + @Operation(summary = "客户跟进记录列表") + public PagerWithOption> list(@Validated @RequestBody FollowUpRecordPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.FOLLOW_RECORD.getKey()); + return followUpRecordService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), "CUSTOMER", "CUSTOMER"); + } + + + @GetMapping("/get/{id}") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ}, logical = Logical.OR) + @Operation(summary = "客户跟进记录详情") + public FollowUpRecordDetailResponse get(@PathVariable String id) { + return followUpRecordService.get(id, OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @Operation(summary = "客户删除跟进记录") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, resourceId = "{#id}", formType = FormKeyConstants.FOLLOW_RECORD) + public void deleteRecord(@PathVariable String id) { + followUpRecordService.delete(id); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerOwnerHistoryController.java b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerOwnerHistoryController.java new file mode 100644 index 0000000..90d7f08 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerOwnerHistoryController.java @@ -0,0 +1,36 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.customer.dto.response.CustomerOwnerListResponse; +import cn.cordys.crm.customer.service.CustomerOwnerHistoryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * @author jianxing + * @date 2025-02-08 17:42:41 + */ +@Tag(name = "客户责任人历史") +@RestController +@RequestMapping("/account/owner/history") +public class CustomerOwnerHistoryController { + @Resource + private CustomerOwnerHistoryService customerOwnerHistoryService; + + @GetMapping("/list/{customerId}") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ}, logical = Logical.OR) + @Operation(summary = "客户责任人历史列表") + public List list(@PathVariable String customerId) { + return customerOwnerHistoryService.list(customerId, OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerPoolController.java b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerPoolController.java new file mode 100644 index 0000000..b48bbdf --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerPoolController.java @@ -0,0 +1,80 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.customer.dto.CustomerPoolDTO; +import cn.cordys.crm.customer.dto.request.CustomerPoolAddRequest; +import cn.cordys.crm.customer.dto.request.CustomerPoolUpdateRequest; +import cn.cordys.crm.customer.service.CustomerPoolService; +import cn.cordys.security.SessionUtils; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/account-pool") +@Tag(name = "公海池") +public class CustomerPoolController { + + @Resource + private CustomerPoolService customerPoolService; + + @PostMapping("/page") + @Operation(summary = "分页获取公海池") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public Pager> page(@RequestBody BasePageRequest request) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, customerPoolService.page(request, OrganizationContext.getOrganizationId())); + } + + @PostMapping("/add") + @Operation(summary = "保存公海池") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void add(@Validated @RequestBody CustomerPoolAddRequest request) { + customerPoolService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @Operation(summary = "编辑公海池") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void update(@Validated @RequestBody CustomerPoolUpdateRequest request) { + customerPoolService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/quick-update") + @Operation(summary = "快捷保存公海池") + public void quickUpdate(@Validated @RequestBody CustomerPoolUpdateRequest request) { + customerPoolService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/no-pick/{id}") + @Operation(summary = "公海池是否存在未领取线索") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public boolean checkNoPick(@PathVariable String id) { + return customerPoolService.checkNoPick(id); + } + + @GetMapping("/delete/{id}") + @Operation(summary = "删除公海池") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void delete(@PathVariable String id) { + customerPoolService.delete(id); + } + + @GetMapping("/switch/{id}") + @Operation(summary = "启用/禁用公海池") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void switchStatus(@PathVariable String id) { + customerPoolService.switchStatus(id, SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerRelationController.java b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerRelationController.java new file mode 100644 index 0000000..ed81e38 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerRelationController.java @@ -0,0 +1,66 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.crm.customer.domain.CustomerRelation; +import cn.cordys.crm.customer.dto.request.CustomerRelationSaveRequest; +import cn.cordys.crm.customer.dto.request.CustomerRelationUpdateRequest; +import cn.cordys.crm.customer.dto.response.CustomerRelationListResponse; +import cn.cordys.crm.customer.service.CustomerRelationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * @author jianxing + * @date 2025-02-08 17:42:41 + */ +@Tag(name = "客户关系") +@RestController +@RequestMapping("/account/relation") +public class CustomerRelationController { + @Resource + private CustomerRelationService customerRelationService; + + @GetMapping("/list/{customerId}") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_READ, resourceId = "{#customerId}", formType = FormKeyConstants.CUSTOMER) + @Operation(summary = "客户关系列表") + public List list(@PathVariable String customerId) { + return customerRelationService.list(customerId); + } + + @PostMapping("/add/{customerId}") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE) + @Operation(summary = "添加客户关系") + public CustomerRelation add(@PathVariable String customerId, @Validated @RequestBody CustomerRelationSaveRequest request) { + return customerRelationService.add(request, customerId); + } + + @PostMapping("/update/{customerId}") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, resourceId = "{#customerId}", formType = FormKeyConstants.CUSTOMER) + @Operation(summary = "更新客户关系") + public CustomerRelation update(@PathVariable String customerId, @Validated @RequestBody CustomerRelationUpdateRequest request) { + return customerRelationService.update(request, customerId); + } + + @GetMapping("/delete/{id}") + @CsPermission(value = PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, resourceId = "{#id}", formType = FormKeyConstants.CUSTOMER) + @Operation(summary = "删除客户关系") + public void delete(@PathVariable String id) { + customerRelationService.delete(id); + } + + @PostMapping("/save/{customerId}") + @CsPermission(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE) + @Operation(summary = "整体客户关系") + public void save(@PathVariable String customerId, @Validated @RequestBody @Valid @NotNull List requests) { + customerRelationService.save(customerId, requests); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerUserViewController.java b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerUserViewController.java new file mode 100644 index 0000000..e0d3f30 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/CustomerUserViewController.java @@ -0,0 +1,95 @@ +package cn.cordys.crm.customer.controller; + + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.UserViewResourceType; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import cn.cordys.crm.system.dto.response.UserViewListResponse; +import cn.cordys.crm.system.dto.response.UserViewResponse; +import cn.cordys.crm.system.service.UserViewService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "客户视图") +@RestController +@RequestMapping("/account/view") +public class CustomerUserViewController { + + @Resource + private UserViewService userViewService; + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_READ) + @Operation(summary = "添加客户视图") + public UserView add(@Validated @RequestBody UserViewAddRequest request) { + return userViewService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), UserViewResourceType.CUSTOMER.name()); + } + + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_READ) + @Operation(summary = "编辑客户视图") + public UserView update(@Validated @RequestBody UserViewUpdateRequest request) { + return userViewService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_READ) + @Operation(summary = "删除客户视图") + public void delete(@PathVariable String id) { + userViewService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/detail/{id}") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_READ) + @Operation(summary = "客户视图详情") + public UserViewResponse viewDetail(@PathVariable String id) { + return userViewService.getViewDetail(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), FormKey.CUSTOMER.getKey()); + } + + + @GetMapping("/list") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_READ) + @Operation(summary = "客户视图列表") + public List queryList() { + return userViewService.list(UserViewResourceType.CUSTOMER.name(), SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/fixed/{id}") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_READ) + @Operation(summary = "客户视图固定/取消固定") + public void fixed(@PathVariable String id) { + userViewService.fixed(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/edit/pos") + @Operation(summary = "客户视图-拖拽排序") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_READ) + public void editPos(@Validated @RequestBody PosRequest request) { + userViewService.editPos(request, SessionUtils.getUserId(), UserViewResourceType.CUSTOMER.name()); + } + + + @GetMapping("/enable/{id}") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_READ) + @Operation(summary = "客户视图-启用/禁用") + public void enable(@PathVariable String id) { + userViewService.enable(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/controller/PoolCustomerController.java b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/PoolCustomerController.java new file mode 100644 index 0000000..ab2d167 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/PoolCustomerController.java @@ -0,0 +1,136 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.customer.dto.CustomerPoolDTO; +import cn.cordys.crm.customer.dto.request.*; +import cn.cordys.crm.customer.dto.response.CustomerGetResponse; +import cn.cordys.crm.customer.dto.response.CustomerListResponse; +import cn.cordys.crm.customer.service.CustomerPoolExportService; +import cn.cordys.crm.customer.service.CustomerService; +import cn.cordys.crm.customer.service.PoolCustomerService; +import cn.cordys.crm.system.dto.request.PoolBatchAssignRequest; +import cn.cordys.crm.system.dto.request.PoolBatchPickRequest; +import cn.cordys.crm.system.dto.request.PoolBatchRequest; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "公海客户") +@RestController +@RequestMapping("/pool/account") +public class PoolCustomerController { + + @Resource + private PoolCustomerService poolCustomerService; + @Resource + private CustomerService customerService; + @Resource + private CustomerPoolExportService customerPoolExportService; + + @GetMapping("/options") + @Operation(summary = "获取当前用户公海选项") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ}) + public List getPoolOptions() { + return poolCustomerService.getPoolOptions(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/page") + @Operation(summary = "客户列表") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ}) + public PagerWithOption> list(@Validated @RequestBody CustomerPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CUSTOMER.getKey()); + return customerService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), null); + } + + @PostMapping("/pick") + @Operation(summary = "领取客户") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_POOL_PICK}) + public void pick(@Validated @RequestBody PoolCustomerPickRequest request) { + poolCustomerService.pick(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/assign") + @Operation(summary = "分配客户") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_POOL_ASSIGN}) + public void assign(@Validated @RequestBody PoolCustomerAssignRequest request) { + poolCustomerService.assign(request.getCustomerId(), request.getAssignUserId(), OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @GetMapping("/delete/{id}") + @Operation(summary = "删除客户") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_POOL_DELETE}) + public void delete(@PathVariable String id) { + poolCustomerService.delete(id); + } + + @GetMapping("/get/{id}") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ) + @Operation(summary = "客户详情") + public CustomerGetResponse get(@PathVariable String id) { + return customerService.get(id); + } + + @PostMapping("/batch-pick") + @Operation(summary = "批量领取客户") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_POOL_PICK}) + public void batchPick(@Validated @RequestBody PoolBatchPickRequest request) { + poolCustomerService.batchPick(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch-assign") + @Operation(summary = "批量分配客户") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_POOL_ASSIGN}) + public void batchAssign(@Validated @RequestBody PoolBatchAssignRequest request) { + poolCustomerService.batchAssign(request, request.getAssignUserId(), OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/batch-delete") + @Operation(summary = "批量删除客户") + @RequiresPermissions(value = {PermissionConstants.CUSTOMER_MANAGEMENT_POOL_DELETE}) + public void batchDelete(@Validated @RequestBody PoolBatchRequest request) { + poolCustomerService.batchDelete(request.getBatchIds(), SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch-update") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_UPDATE) + @Operation(summary = "批量更新客户") + public void batchUpdate(@Validated @RequestBody ResourceBatchEditRequest request) { + poolCustomerService.batchUpdate(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/export-all") + @Operation(summary = "客户导出全部") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_EXPORT) + public String customerPoolExportAll(@Validated @RequestBody CustomerExportRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CUSTOMER.getKey()); + return customerPoolExportService.exportCrossPage(SessionUtils.getUserId(), request, OrganizationContext.getOrganizationId(), null, LocaleContextHolder.getLocale()); + } + + @PostMapping("/export-select") + @Operation(summary = "导出选中客户") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_EXPORT) + public String customerPoolExportSelect(@Validated @RequestBody ExportSelectRequest request) { + return customerPoolExportService.exportCrossSelect(SessionUtils.getUserId(), request, OrganizationContext.getOrganizationId(), LocaleContextHolder.getLocale()); + } + + @PostMapping("/chart") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ) + @Operation(summary = "客户图表生成") + public List chart(@Validated @RequestBody PoolCustomerChartAnalysisRequest request) { + return poolCustomerService.chart(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), null); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/controller/PoolCustomerUserViewController.java b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/PoolCustomerUserViewController.java new file mode 100644 index 0000000..de0a333 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/controller/PoolCustomerUserViewController.java @@ -0,0 +1,95 @@ +package cn.cordys.crm.customer.controller; + + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.UserViewResourceType; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import cn.cordys.crm.system.dto.response.UserViewListResponse; +import cn.cordys.crm.system.dto.response.UserViewResponse; +import cn.cordys.crm.system.service.UserViewService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "公海视图") +@RestController +@RequestMapping("/pool/account/view") +public class PoolCustomerUserViewController { + + @Resource + private UserViewService userViewService; + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ) + @Operation(summary = "添加公海视图") + public UserView add(@Validated @RequestBody UserViewAddRequest request) { + return userViewService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), UserViewResourceType.CUSTOMER_POOL.name()); + } + + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ) + @Operation(summary = "编辑公海视图") + public UserView update(@Validated @RequestBody UserViewUpdateRequest request) { + return userViewService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ) + @Operation(summary = "删除公海视图") + public void delete(@PathVariable String id) { + userViewService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/detail/{id}") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ) + @Operation(summary = "公海视图详情") + public UserViewResponse viewDetail(@PathVariable String id) { + return userViewService.getViewDetail(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), FormKey.CUSTOMER.getKey()); + } + + + @GetMapping("/list") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ) + @Operation(summary = "公海视图列表") + public List queryList() { + return userViewService.list(UserViewResourceType.CUSTOMER_POOL.name(), SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/fixed/{id}") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ) + @Operation(summary = "公海视图固定/取消固定") + public void fixed(@PathVariable String id) { + userViewService.fixed(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/edit/pos") + @Operation(summary = "公海视图-拖拽排序") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ) + public void editPos(@Validated @RequestBody PosRequest request) { + userViewService.editPos(request, SessionUtils.getUserId(), UserViewResourceType.CUSTOMER_POOL.name()); + } + + + @GetMapping("/enable/{id}") + @RequiresPermissions(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ) + @Operation(summary = "公海视图-启用/禁用") + public void enable(@PathVariable String id) { + userViewService.enable(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/domain/Customer.java b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/Customer.java new file mode 100644 index 0000000..d2f5423 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/Customer.java @@ -0,0 +1,45 @@ +package cn.cordys.crm.customer.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 客户 + * + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +@Table(name = "customer") +public class Customer extends BaseModel { + + @Schema(description = "客户名称") + private String name; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "领取时间") + private Long collectionTime; + + @Schema(description = "公海ID") + private String poolId; + + @Schema(description = "是否在公海池") + private Boolean inSharedPool; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "最新跟进人") + private String follower; + + @Schema(description = "最新跟进时间") + private Long followTime; + + @Schema(description = "公海原因ID") + private String reasonId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerCapacity.java b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerCapacity.java new file mode 100644 index 0000000..9939f83 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerCapacity.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.customer.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "customer_capacity") +public class CustomerCapacity extends BaseModel { + + @Schema(description = "组织架构ID") + private String organizationId; + + @Schema(description = "范围ID") + private String scopeId; + + @Schema(description = "库容") + private Integer capacity; + + @Schema(description = "过滤条件") + private String filter; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerCollaboration.java b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerCollaboration.java new file mode 100644 index 0000000..c11b084 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerCollaboration.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.customer.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 客户协作人 + * + * @author jianxing + * @date 2025-03-04 11:06:12 + */ +@Data +@Table(name = "customer_collaboration") +public class CustomerCollaboration extends BaseModel { + + @Schema(description = "协作人id") + private String userId; + + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "协作类型(只读/协作)") + private String collaborationType; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerContact.java b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerContact.java new file mode 100644 index 0000000..b2bdefb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerContact.java @@ -0,0 +1,39 @@ +package cn.cordys.crm.customer.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 客户联系人 + * + * @author jianxing + * @date 2025-02-24 11:06:10 + */ +@Data +@Table(name = "customer_contact") +public class CustomerContact extends BaseModel { + + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "责任人") + private String owner; + + @Schema(description = "联系人姓名") + private String name; + + @Schema(description = "联系人电话") + private String phone; + + @Schema(description = "是否停用") + private Boolean enable; + + @Schema(description = "停用原因") + private String disableReason; + + @Schema(description = "组织id") + private String organizationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerContactBlob.java b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerContactBlob.java new file mode 100644 index 0000000..95084e7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerContactBlob.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.customer.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 客户联系人自定义属性大文本 + * + * @author jianxing + * @date 2025-02-24 16:23:32 + */ +@Data +@Table(name = "customer_contact_blob") +public class CustomerContactBlob extends BaseModel { + + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "自定义属性id") + private String fieldId; + + @Schema(description = "自定义属性值") + private String fieldValue; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerContactField.java b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerContactField.java new file mode 100644 index 0000000..211820e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerContactField.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.customer.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 客户联系人自定义属性 + * + * @author jianxing + * @date 2025-02-24 16:23:32 + */ +@Data +@Table(name = "customer_contact_field") +public class CustomerContactField extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerContactFieldBlob.java b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerContactFieldBlob.java new file mode 100644 index 0000000..24a1be4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerContactFieldBlob.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.customer.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 客户联系人自定义属性大文本 + * + * @author jianxing + * @date 2025-02-27 14:43:46 + */ +@Data +@Table(name = "customer_contact_field_blob") +public class CustomerContactFieldBlob extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerField.java b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerField.java new file mode 100644 index 0000000..4e2267b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerField.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.customer.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 客户自定义属性 + * + * @author jianxing + * @date 2025-02-10 18:12:46 + */ +@Data +@Table(name = "customer_field") +public class CustomerField extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerFieldBlob.java b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerFieldBlob.java new file mode 100644 index 0000000..6738408 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerFieldBlob.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.customer.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 客户自定义属性大文本 + * + * @author jianxing + * @date 2025-02-27 14:43:46 + */ +@Data +@Table(name = "customer_field_blob") +public class CustomerFieldBlob extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerOwner.java b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerOwner.java new file mode 100644 index 0000000..ca8172c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerOwner.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.customer.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 客户历史责任人 + * + * @author jianxing + * @date 2025-03-12 10:20:41 + */ +@Data +@Table(name = "customer_owner") +public class CustomerOwner { + + @Schema(description = "id") + private String id; + + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "责任人") + private String owner; + + @Schema(description = "领取时间") + private Long collectionTime; + + @Schema(description = "结束时间") + private Long endTime; + + @Schema(description = "操作人") + private String operator; + + @Schema(description = "公海原因ID") + private String reasonId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerPool.java b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerPool.java new file mode 100644 index 0000000..bc466ae --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerPool.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.customer.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "customer_pool") +public class CustomerPool extends BaseModel { + + @Schema(description = "组织ID") + private String organizationId; + + @Schema(description = "公海池名称") + private String name; + + @Schema(description = "范围ID") + private String scopeId; + + @Schema(description = "管理员ID") + private String ownerId; + + @Schema(description = "启用/禁用") + private Boolean enable; + + @Schema(description = "是否自动回收") + private Boolean auto; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerPoolHiddenField.java b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerPoolHiddenField.java new file mode 100644 index 0000000..2f1572d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerPoolHiddenField.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.customer.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "customer_pool_hidden_field") +public class CustomerPoolHiddenField { + + @Schema(description = "公海池ID") + private String poolId; + + @Schema(description = "字段ID") + private String fieldId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerPoolPickRule.java b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerPoolPickRule.java new file mode 100644 index 0000000..5e31247 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerPoolPickRule.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.customer.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "customer_pool_pick_rule") +public class CustomerPoolPickRule extends BaseModel { + + @Schema(description = "公海池ID") + private String poolId; + + @Schema(description = "是否限制每日领取数量") + private Boolean limitOnNumber; + + @Schema(description = "领取数量") + private Integer pickNumber; + + @Schema(description = "是否限制前归属人领取") + private Boolean limitPreOwner; + + @Schema(description = "领取间隔天数") + private Integer pickIntervalDays; + + @Schema(description = "是否限制新数据") + private Boolean limitNew; + + @Schema(description = "新数据领取间隔") + private Integer newPickInterval; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerPoolRecycleRule.java b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerPoolRecycleRule.java new file mode 100644 index 0000000..33ffaef --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerPoolRecycleRule.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.customer.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "customer_pool_recycle_rule") +public class CustomerPoolRecycleRule extends BaseModel { + + @Schema(description = "公海池ID") + private String poolId; + + @Schema(description = "操作符") + private String operator; + + @Schema(description = "回收条件") + private String condition; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerRelation.java b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerRelation.java new file mode 100644 index 0000000..39c2fe0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/domain/CustomerRelation.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.customer.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 客户关系 + * + * @author jianxing + * @date 2025-03-12 17:19:16 + */ +@Data +@Table(name = "customer_relation") +public class CustomerRelation { + + @Schema(description = "id") + private String id; + + @Schema(description = "客户ID(集团)") + private String sourceCustomerId; + + @Schema(description = "客户ID(子公司)") + private String targetCustomerId; + + @Schema(description = "创建时间") + private Long createTime; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerCapacityDTO.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerCapacityDTO.java new file mode 100644 index 0000000..6f20af2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerCapacityDTO.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.customer.dto; + +import cn.cordys.crm.customer.domain.CustomerCapacity; +import cn.cordys.crm.system.dto.FilterConditionDTO; +import cn.cordys.crm.system.dto.ScopeNameDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class CustomerCapacityDTO extends CustomerCapacity { + + @Schema(description = "成员集合") + private List members; + @Schema(description = "过滤条件集合") + private List filters; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerPoolDTO.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerPoolDTO.java new file mode 100644 index 0000000..a92cfa8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerPoolDTO.java @@ -0,0 +1,30 @@ +package cn.cordys.crm.customer.dto; + +import cn.cordys.crm.customer.domain.CustomerPool; +import cn.cordys.crm.system.dto.ScopeNameDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class CustomerPoolDTO extends CustomerPool { + + @Schema(description = "成员集合") + private List members; + @Schema(description = "管理员集合") + private List owners; + @Schema(description = "创建人名称") + private String createUserName; + @Schema(description = "更新人名称") + private String updateUserName; + @Schema(description = "领取规则") + private CustomerPoolPickRuleDTO pickRule; + @Schema(description = "回收规则") + private CustomerPoolRecycleRuleDTO recycleRule; + @Schema(description = "字段配置") + private List fieldConfigs; + + @Schema(description = "是否可编辑") + private Boolean editable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerPoolFieldConfigDTO.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerPoolFieldConfigDTO.java new file mode 100644 index 0000000..cdc72da --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerPoolFieldConfigDTO.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.customer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class CustomerPoolFieldConfigDTO { + @Schema(description = "字段ID") + private String fieldId; + @Schema(description = "字段名称") + private String fieldName; + @Schema(description = "是否启用") + private Boolean enable; + @Schema(description = "是否可编辑") + private Boolean editable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerPoolPickRuleDTO.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerPoolPickRuleDTO.java new file mode 100644 index 0000000..0590ce6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerPoolPickRuleDTO.java @@ -0,0 +1,36 @@ +package cn.cordys.crm.customer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CustomerPoolPickRuleDTO { + + @NotNull + @Schema(description = "是否限制每日领取数量") + private Boolean limitOnNumber; + + @Schema(description = "领取数量") + private Integer pickNumber; + + @NotNull + @Schema(description = "是否限制前归属人领取") + private Boolean limitPreOwner; + + @Schema(description = "领取间隔天数") + private Integer pickIntervalDays; + + @NotNull + @Schema(description = "是否限制新数据") + private Boolean limitNew; + + @Schema(description = "新数据领取间隔") + private Integer newPickInterval; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerPoolRecycleRuleDTO.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerPoolRecycleRuleDTO.java new file mode 100644 index 0000000..0f375d9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerPoolRecycleRuleDTO.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.customer.dto; + +import cn.cordys.crm.system.dto.RuleConditionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CustomerPoolRecycleRuleDTO { + + @Schema(description = "操作符") + private String operator; + + @Schema(description = "规则条件集合") + private List conditions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerPoolRuleDTO.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerPoolRuleDTO.java new file mode 100644 index 0000000..772ecc2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/CustomerPoolRuleDTO.java @@ -0,0 +1,34 @@ +package cn.cordys.crm.customer.dto; + +import cn.cordys.crm.system.dto.RuleConditionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CustomerPoolRuleDTO { + + @Schema(description = "是否限制领取数量") + private Boolean limitOnNumber; + @Schema(description = "领取数量") + private Integer pickNumber; + @Schema(description = "是否限制前归属人领取") + private Boolean limitPreOwner; + @Schema(description = "领取间隔天数") + private Integer pickIntervalDays; + @Schema(description = "到期提醒") + private Boolean expireNotice; + @Schema(description = "提前提醒天数") + private Integer noticeDays; + @Schema(description = "操作符") + private String operator; + @Schema(description = "规则条件集合") + private List conditions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/BatchReTransitionCustomerRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/BatchReTransitionCustomerRequest.java new file mode 100644 index 0000000..30e337f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/BatchReTransitionCustomerRequest.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.customer.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class BatchReTransitionCustomerRequest { + + @NotEmpty + @Schema(description = "线索ID集合", requiredMode = Schema.RequiredMode.REQUIRED) + private List clueIds; + @NotBlank + @Schema(description = "客户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String customerId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/ClueChartAnalysisDbRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/ClueChartAnalysisDbRequest.java new file mode 100644 index 0000000..d9f7f82 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/ClueChartAnalysisDbRequest.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.common.dto.ChartAnalysisDbRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-10-23 13:42 + */ +@Data +public class ClueChartAnalysisDbRequest extends ChartAnalysisDbRequest { + @Schema(description = "线索池ID{线索池线索列表时传参}") + private String poolId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/ClueTransformRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/ClueTransformRequest.java new file mode 100644 index 0000000..0a778bf --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/ClueTransformRequest.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.customer.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ClueTransformRequest { + + @NotBlank + @Schema(description = "线索ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String clueId; + @Schema(description = "是否创建商机") + private Boolean oppCreated; + @Schema(description = "商机名称") + private String oppName; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/ContactUniqueRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/ContactUniqueRequest.java new file mode 100644 index 0000000..3115c4e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/ContactUniqueRequest.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.customer.dto.request; + +import lombok.Builder; +import lombok.Data; + +/** + * @author song-cc-rock + */ +@Data +@Builder +public class ContactUniqueRequest { + + /** + * 联系人名称 + */ + private String name; + /** + * 联系人电话 + */ + private String phone; + /** + * 是否名称唯一 + */ + private Boolean nameUnique; + /** + * 是否电话唯一 + */ + private Boolean phoneUnique; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/ContractDetailInvoicePageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/ContractDetailInvoicePageRequest.java new file mode 100644 index 0000000..a57cc36 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/ContractDetailInvoicePageRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.crm.contract.dto.request.ContractInvoicePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-11-26 11:35 + */ +@Data +public class ContractDetailInvoicePageRequest extends ContractInvoicePageRequest { + @NotBlank + @Schema(description = "合同ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String contractId; + + @Override + public String getContractId() { + return contractId; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/ContractOrderPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/ContractOrderPageRequest.java new file mode 100644 index 0000000..9a205f7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/ContractOrderPageRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.crm.order.dto.request.OrderPageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-11-26 11:35 + */ +@Data +public class ContractOrderPageRequest extends OrderPageRequest { + @NotBlank + @Schema(description = "合同ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String contractId; + + @Override + public String getContractId() { + return contractId; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerAddRequest.java new file mode 100644 index 0000000..2d30b82 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerAddRequest.java @@ -0,0 +1,36 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class CustomerAddRequest { + + @NotBlank + @Size(max = 255) + @Schema(description = "客户名称") + private String name; + + @Size(max = 32) + @Schema(description = "负责人") + private String owner; + + @Schema(description = "模块字段值") + private List moduleFields; + + @Schema(description = "最新跟进人(转客户时需录入)") + private String follower; + + @Schema(description = "最新跟进时间(转客户时需录入)") + private Long followTime; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerBatchTransferRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerBatchTransferRequest.java new file mode 100644 index 0000000..a0ad6e1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerBatchTransferRequest.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.customer.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class CustomerBatchTransferRequest { + + @NotEmpty + @Schema(description = "ids", requiredMode = Schema.RequiredMode.REQUIRED) + private List ids; + + @NotBlank + @Size(max = 32) + @Schema(description = "修改负责人", requiredMode = Schema.RequiredMode.REQUIRED) + private String owner; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerChartAnalysisDbRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerChartAnalysisDbRequest.java new file mode 100644 index 0000000..6fde95c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerChartAnalysisDbRequest.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.common.dto.ChartAnalysisDbRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-10-23 13:42 + */ +@Data +public class CustomerChartAnalysisDbRequest extends ChartAnalysisDbRequest { + @Schema(description = "公海ID{公海客户列表时传参}") + private String poolId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerCollaborationAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerCollaborationAddRequest.java new file mode 100644 index 0000000..cc0386d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerCollaborationAddRequest.java @@ -0,0 +1,33 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.crm.customer.constants.CustomerCollaborationType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class CustomerCollaborationAddRequest { + + @Size(max = 32) + @NotBlank + @Schema(description = "客户id", requiredMode = Schema.RequiredMode.REQUIRED) + private String customerId; + + @NotBlank + @Size(max = 32) + @Schema(description = "人员", requiredMode = Schema.RequiredMode.REQUIRED) + private String userId; + + @NotNull + @Schema(description = "协作类型(只读 READ_ONLY / 协作 COLLABORATION)", requiredMode = Schema.RequiredMode.REQUIRED) + @EnumValue(enumClass = CustomerCollaborationType.class) + private String collaborationType; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerCollaborationUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerCollaborationUpdateRequest.java new file mode 100644 index 0000000..1673dd0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerCollaborationUpdateRequest.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.crm.customer.constants.CustomerCollaborationType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class CustomerCollaborationUpdateRequest { + @Size(max = 32) + @NotBlank + @Schema(description = "客户协作人id", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @NotNull + @Schema(description = "协作类型(只读 READ_ONLY / 协作 COLLABORATION)", requiredMode = Schema.RequiredMode.REQUIRED) + @EnumValue(enumClass = CustomerCollaborationType.class) + private String collaborationType; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContactAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContactAddRequest.java new file mode 100644 index 0000000..40300ff --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContactAddRequest.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + * @date 2025-02-24 11:06:10 + */ +@Data +public class CustomerContactAddRequest { + + @Size(max = 32) + @Schema(description = "客户id", requiredMode = Schema.RequiredMode.REQUIRED) + private String customerId; + + @Size(max = 32) + @Schema(description = "负责人") + private String owner; + + @Size(max = 255) + @NotBlank + @Schema(description = "联系人姓名", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Size(max = 30) + @Schema(description = "联系人电话") + private String phone; + + @Schema(description = "模块字段值") + private List moduleFields; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContactDisableRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContactDisableRequest.java new file mode 100644 index 0000000..fe67867 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContactDisableRequest.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.customer.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; + + +/** + * @author jianxing + * @date 2025-02-24 11:06:10 + */ +@Data +public class CustomerContactDisableRequest { + @Size(max = 255) + @Schema(description = "停用原因", requiredMode = Schema.RequiredMode.REQUIRED) + private String reason; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContactExportRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContactExportRequest.java new file mode 100644 index 0000000..50b8e56 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContactExportRequest.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.common.dto.ExportHeadDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class CustomerContactExportRequest extends CustomerContactPageRequest { + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "表头信息") + @NotEmpty(message = "{export_head_list_is_empty}") + private List headList; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContactPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContactPageRequest.java new file mode 100644 index 0000000..0351c95 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContactPageRequest.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import lombok.Data; + + +/** + * @author jianxing + * @date 2025-02-24 11:06:10 + */ +@Data +public class CustomerContactPageRequest extends BasePageRequest { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContactUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContactUpdateRequest.java new file mode 100644 index 0000000..268addb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContactUpdateRequest.java @@ -0,0 +1,44 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + * @date 2025-02-24 11:06:10 + */ +@Data +public class CustomerContactUpdateRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 32) + private String id; + + @Size(max = 32) + @Schema(description = "客户id") + private String customerId; + + @Size(max = 255) + @Schema(description = "联系人姓名") + private String name; + + @Size(max = 30) + @Schema(description = "联系人电话") + private String phone; + + @Size(max = 32) + @Schema(description = "负责人") + private String owner; + + @Schema(description = "模块字段值") + private List moduleFields; + + private Boolean agentInvoke = false; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContractInvoicePageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContractInvoicePageRequest.java new file mode 100644 index 0000000..970c354 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContractInvoicePageRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.crm.contract.dto.request.ContractInvoicePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-11-26 11:35 + */ +@Data +public class CustomerContractInvoicePageRequest extends ContractInvoicePageRequest { + @NotBlank + @Schema(description = "客户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String customerId; + + @Override + public String getCustomerId() { + return customerId; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContractPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContractPageRequest.java new file mode 100644 index 0000000..ffe50fb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContractPageRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.crm.contract.dto.request.ContractPageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-11-26 11:35 + */ +@Data +public class CustomerContractPageRequest extends ContractPageRequest { + @NotBlank + @Schema(description = "客户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String customerId; + + @Override + public String getCustomerId() { + return customerId; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContractPaymentPlanPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContractPaymentPlanPageRequest.java new file mode 100644 index 0000000..80aae1b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerContractPaymentPlanPageRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.crm.contract.dto.request.ContractPaymentPlanPageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-11-26 11:35 + */ +@Data +public class CustomerContractPaymentPlanPageRequest extends ContractPaymentPlanPageRequest { + @NotBlank + @Schema(description = "客户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String customerId; + + @Override + public String getCustomerId() { + return customerId; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerExportRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerExportRequest.java new file mode 100644 index 0000000..143a975 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerExportRequest.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.common.dto.ExportHeadDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class CustomerExportRequest extends CustomerPageRequest { + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "表头信息") + @NotEmpty(message = "{export_head_list_is_empty}") + private List headList; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerMergeRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerMergeRequest.java new file mode 100644 index 0000000..2a1f6ab --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerMergeRequest.java @@ -0,0 +1,24 @@ +package cn.cordys.crm.customer.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Data +public class CustomerMergeRequest { + + @NotNull + @Schema(description = "被合并的客户") + private List mergeIds; + + @NotEmpty + @Schema(description = "合并的客户", requiredMode = Schema.RequiredMode.REQUIRED) + private String toMergeId; + + @NotEmpty + @Schema(description = "合并的负责人", requiredMode = Schema.RequiredMode.REQUIRED) + private String ownerId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerOpportunityPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerOpportunityPageRequest.java new file mode 100644 index 0000000..7b034a4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerOpportunityPageRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.crm.opportunity.dto.request.OpportunityPageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * @author song-cc-rock + */ +@Data +public class CustomerOpportunityPageRequest extends OpportunityPageRequest { + + @NotBlank + @Schema(description = "客户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String customerId; + + @Override + public String getCustomerId() { + return customerId; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerOrderPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerOrderPageRequest.java new file mode 100644 index 0000000..c3bd116 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerOrderPageRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.crm.order.dto.request.OrderPageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-11-26 11:35 + */ +@Data +public class CustomerOrderPageRequest extends OrderPageRequest { + @NotBlank + @Schema(description = "客户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String customerId; + + @Override + public String getCustomerId() { + return customerId; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerPageRequest.java new file mode 100644 index 0000000..92fceeb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerPageRequest.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class CustomerPageRequest extends BasePageRequest { + + @Schema(description = "公海ID{公海客户列表时传参}") + private String poolId; + + @Schema(description = "是否查询关联客户列表") + private Boolean transition; + + @Schema(description = "查询需要关联的公海客户传参") + private List transitionPoolIds; + + @Schema(description = "关联客户数据权限") + private DeptDataPermissionDTO transitionDataPermission; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerPoolAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerPoolAddRequest.java new file mode 100644 index 0000000..8398e6e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerPoolAddRequest.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.crm.customer.dto.CustomerPoolPickRuleDTO; +import cn.cordys.crm.customer.dto.CustomerPoolRecycleRuleDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; +import java.util.Set; + +@Data +public class CustomerPoolAddRequest { + + @NotBlank + @Size(max = 255) + @Schema(description = "公海池名称") + private String name; + + @NotNull + @Schema(description = "范围ID集合") + private List scopeIds; + + @NotNull + @Schema(description = "管理员ID集合") + private List ownerIds; + + @NotNull + @Schema(description = "启用/禁用") + private Boolean enable; + + @NotNull + @Schema(description = "是否自动回收") + private Boolean auto; + + @Schema(description = "领取规则") + private CustomerPoolPickRuleDTO pickRule; + + @Schema(description = "回收规则") + private CustomerPoolRecycleRuleDTO recycleRule; + + @Schema(description = "隐藏字段ID集合") + private Set<@NotBlank String> hiddenFieldIds; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerPoolPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerPoolPageRequest.java new file mode 100644 index 0000000..ea60386 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerPoolPageRequest.java @@ -0,0 +1,8 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import lombok.Data; + +@Data +public class CustomerPoolPageRequest extends BasePageRequest { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerPoolUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerPoolUpdateRequest.java new file mode 100644 index 0000000..c73d8b2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerPoolUpdateRequest.java @@ -0,0 +1,51 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.crm.customer.dto.CustomerPoolPickRuleDTO; +import cn.cordys.crm.customer.dto.CustomerPoolRecycleRuleDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; +import java.util.Set; + +@Data +public class CustomerPoolUpdateRequest { + + @NotBlank + @Size(max = 32) + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @NotBlank + @Size(max = 255) + @Schema(description = "公海池名称") + private String name; + + @NotNull + @Schema(description = "范围ID集合") + private List scopeIds; + + @NotNull + @Schema(description = "管理员ID集合") + private List ownerIds; + + @NotNull + @Schema(description = "启用/禁用") + private Boolean enable; + + @NotNull + @Schema(description = "是否自动回收") + private Boolean auto; + + @Schema(description = "领取规则") + private CustomerPoolPickRuleDTO pickRule; + + @Schema(description = "回收规则") + private CustomerPoolRecycleRuleDTO recycleRule; + + @Schema(description = "隐藏字段ID集合") + private Set<@NotBlank String> hiddenFieldIds; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerRelationSaveRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerRelationSaveRequest.java new file mode 100644 index 0000000..8542dc2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerRelationSaveRequest.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.crm.customer.constants.CustomerRelationType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CustomerRelationSaveRequest { + @Size(max = 32) + @Schema(description = "客户ID") + private String customerId; + + @Schema(description = "关系类型") + @EnumValue(enumClass = CustomerRelationType.class) + private String relationType; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerRelationUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerRelationUpdateRequest.java new file mode 100644 index 0000000..7fd8974 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerRelationUpdateRequest.java @@ -0,0 +1,24 @@ +package cn.cordys.crm.customer.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CustomerRelationUpdateRequest extends CustomerRelationSaveRequest { + + @NotBlank + @Size(max = 32) + @Schema(description = "ID") + private String id; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerUpdateRequest.java new file mode 100644 index 0000000..8fb29e1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/CustomerUpdateRequest.java @@ -0,0 +1,37 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class CustomerUpdateRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 32) + private String id; + + @Size(max = 255) + @Schema(description = "客户名称") + private String name; + + @Size(max = 32) + @Schema(description = "负责人") + private String owner; + + @Schema(description = "自定义字段") + private List moduleFields; + + + private Boolean agentInvoke = false; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/PoolClueChartAnalysisRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/PoolClueChartAnalysisRequest.java new file mode 100644 index 0000000..7b24e96 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/PoolClueChartAnalysisRequest.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.common.dto.ChartAnalysisRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-10-23 13:40 + */ +@Data +public class PoolClueChartAnalysisRequest extends ChartAnalysisRequest { + @Schema(description = "线索池ID") + @NotBlank + private String poolId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/PoolCustomerAssignRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/PoolCustomerAssignRequest.java new file mode 100644 index 0000000..ed7dc84 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/PoolCustomerAssignRequest.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.customer.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class PoolCustomerAssignRequest { + + @NotBlank + @Schema(description = "客户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String customerId; + + @NotBlank + @Schema(description = "分配用户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String assignUserId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/PoolCustomerChartAnalysisRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/PoolCustomerChartAnalysisRequest.java new file mode 100644 index 0000000..b6552af --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/PoolCustomerChartAnalysisRequest.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.customer.dto.request; + +import cn.cordys.common.dto.ChartAnalysisRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-10-23 13:40 + */ +@Data +public class PoolCustomerChartAnalysisRequest extends ChartAnalysisRequest { + @Schema(description = "公海ID") + @NotBlank + private String poolId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/PoolCustomerPickRequest.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/PoolCustomerPickRequest.java new file mode 100644 index 0000000..57f10ea --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/request/PoolCustomerPickRequest.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.customer.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class PoolCustomerPickRequest { + + @NotBlank + @Schema(description = "客户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String customerId; + + @NotBlank + @Schema(description = "公海ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String poolId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/ClueTransformResponse.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/ClueTransformResponse.java new file mode 100644 index 0000000..68f3394 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/ClueTransformResponse.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.customer.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class ClueTransformResponse { + + @Schema(description = "目标ID") + private String transformId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerCollaborationListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerCollaborationListResponse.java new file mode 100644 index 0000000..32bfdf9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerCollaborationListResponse.java @@ -0,0 +1,28 @@ +package cn.cordys.crm.customer.dto.response; + +import cn.cordys.crm.customer.domain.CustomerCollaboration; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class CustomerCollaborationListResponse extends CustomerCollaboration { + @Schema(description = "用户名称") + private String userName; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "归属部门") + private String departmentId; + + @Schema(description = "归属部门名称") + private String departmentName; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerContactGetResponse.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerContactGetResponse.java new file mode 100644 index 0000000..c14484f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerContactGetResponse.java @@ -0,0 +1,48 @@ +package cn.cordys.crm.customer.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.customer.domain.CustomerContact; +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * @author jianxing + * @date 2025-02-24 11:06:10 + */ +@Data +public class CustomerContactGetResponse extends CustomerContact { + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "归属部门") + private String departmentId; + + @Schema(description = "归属部门名称") + private String departmentName; + + @Schema(description = "客户名称") + private String customerName; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "选项集合") + private Map> optionMap; + + /** + * 附件集合 + */ + @Schema(description = "附件集合") + private Map> attachmentMap; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerContactListAllResponse.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerContactListAllResponse.java new file mode 100644 index 0000000..3026776 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerContactListAllResponse.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.customer.dto.response; + +import cn.cordys.common.dto.OptionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + + +/** + * @author jianxing + * @date 2025-02-24 11:06:10 + */ +@Data +public class CustomerContactListAllResponse { + @Schema(description = "列表数据") + private List list; + + /** + * 选项集合 + */ + @Schema(description = "选项集合") + private Map> optionMap; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerContactListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerContactListResponse.java new file mode 100644 index 0000000..bade383 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerContactListResponse.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.customer.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.customer.domain.CustomerContact; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + * @date 2025-02-24 11:06:10 + */ +@Data +public class CustomerContactListResponse extends CustomerContact { + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "归属部门") + private String departmentId; + + @Schema(description = "归属部门名称") + private String departmentName; + + @Schema(description = "客户名称") + private String customerName; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "自定义字段集合") + private List moduleFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerGetResponse.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerGetResponse.java new file mode 100644 index 0000000..9882455 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerGetResponse.java @@ -0,0 +1,94 @@ +package cn.cordys.crm.customer.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class CustomerGetResponse { + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "客户名称") + private String name; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "归属部门") + private String departmentId; + + @Schema(description = "归属部门名称") + private String departmentName; + + @Schema(description = "创建时间") + private Long collectionTime; + + @Schema(description = "剩余归属天数") + private Integer reservedDays; + + @Schema(description = "最新跟进人") + private String follower; + + @Schema(description = "最新跟进人名称") + private String followerName; + + @Schema(description = "最新跟进日期") + private Long followTime; + + @Schema(description = "默认回收公海名称") + private String recyclePoolName; + + @Schema(description = "失败原因ID") + private String reasonId; + + @Schema(description = "失败原因ID") + private String reasonName; + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "协作类型(只读/协作),为空则不是协作人员") + private String collaborationType; + + @Schema(description = "选项集合") + private Map> optionMap; + + /** + * 附件集合 + */ + @Schema(description = "附件集合") + private Map> attachmentMap; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerListResponse.java new file mode 100644 index 0000000..82a2f1e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerListResponse.java @@ -0,0 +1,90 @@ +package cn.cordys.crm.customer.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class CustomerListResponse { + @Schema(description = "ID") + private String id; + + @Schema(description = "客户名称") + private String name; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "是否在公海池") + private Boolean inSharedPool; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "归属部门") + private String departmentId; + + @Schema(description = "归属部门名称") + private String departmentName; + + @Schema(description = "最近跟进时间") + private Long latestFollowUpTime; + + @Schema(description = "创建时间") + private Long collectionTime; + + @Schema(description = "剩余归属天数") + private Integer reservedDays; + + @Schema(description = "最新跟进人") + private String follower; + + @Schema(description = "最新跟进人名称") + private String followerName; + + @Schema(description = "最新跟进日期") + private Long followTime; + + @Schema(description = "公海ID") + private String poolId; + + @Schema(description = "默认回收公海名称") + private String recyclePoolName; + + @Schema(description = "失败原因ID") + private String reasonId; + + @Schema(description = "失败原因ID") + private String reasonName; + + @Schema(description = "自定义字段集合") + private List moduleFields; + + @Schema(description = "协作类型(只读/协作),为空则不是协作人员") + private String collaborationType; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerOwnerListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerOwnerListResponse.java new file mode 100644 index 0000000..d2aa1a7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerOwnerListResponse.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.customer.dto.response; + +import cn.cordys.crm.customer.domain.CustomerOwner; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class CustomerOwnerListResponse extends CustomerOwner { + @Schema(description = "操作人名称") + private String operatorName; + + @Schema(description = "责任人名称") + private String ownerName; + + @Schema(description = "归属部门") + private String departmentId; + + @Schema(description = "归属部门名称") + private String departmentName; + + @Schema(description = "回收原因名称") + private String reasonName; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerRelationListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerRelationListResponse.java new file mode 100644 index 0000000..3d0faf7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/CustomerRelationListResponse.java @@ -0,0 +1,24 @@ +package cn.cordys.crm.customer.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class CustomerRelationListResponse { + @Schema(description = "ID") + private String id; + + @Schema(description = "客户ID") + private String customerId; + + @Schema(description = "客户名称") + private String customerName; + + @Schema(description = "关系类型") + private String relationType; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/PoolCustomerResponse.java b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/PoolCustomerResponse.java new file mode 100644 index 0000000..b6687ad --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/dto/response/PoolCustomerResponse.java @@ -0,0 +1,9 @@ +package cn.cordys.crm.customer.dto.response; + +import lombok.Data; + +@Data +public class PoolCustomerResponse { + + private String customerName; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerCapacityMapper.java b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerCapacityMapper.java new file mode 100644 index 0000000..c7e4055 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerCapacityMapper.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.customer.mapper; + +import cn.cordys.crm.customer.domain.CustomerCapacity; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtCustomerCapacityMapper { + + /** + * 获取容量 + * + * @param scopeIds 范围ID集合 + * @param organizationId 组织ID + * + * @return 容量 + */ + CustomerCapacity getCapacityByScopeIds(@Param("ids") List scopeIds, @Param("orgId") String organizationId); + + /** + * 更新容量 + * + * @param capacity 容量 + */ + void updateCapacity(@Param("capacity") CustomerCapacity capacity); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerCapacityMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerCapacityMapper.xml new file mode 100644 index 0000000..f923b4a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerCapacityMapper.xml @@ -0,0 +1,34 @@ + + + + + + + + update customer_capacity + + + scope_id = #{capacity.scopeId}, + + + filter = #{capacity.filter}, + + + update_user = #{capacity.updateUser}, + + + update_time = #{capacity.updateTime}, + + capacity = #{capacity.capacity}, + + + id = #{capacity.id} + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerContactMapper.java b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerContactMapper.java new file mode 100644 index 0000000..e5ef477 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerContactMapper.java @@ -0,0 +1,95 @@ +package cn.cordys.crm.customer.mapper; + +import cn.cordys.common.dto.*; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.crm.customer.domain.CustomerContact; +import cn.cordys.crm.customer.dto.request.ContactUniqueRequest; +import cn.cordys.crm.customer.dto.request.CustomerContactPageRequest; +import cn.cordys.crm.customer.dto.request.CustomerMergeRequest; +import cn.cordys.crm.customer.dto.response.CustomerContactListResponse; +import cn.cordys.crm.home.dto.request.HomeStatisticSearchWrapperRequest; +import cn.cordys.crm.search.response.advanced.AdvancedCustomerContactResponse; +import cn.cordys.crm.search.response.global.GlobalCustomerContactResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * @author jianxing + * @date 2025-02-24 11:06:10 + */ +public interface ExtCustomerContactMapper { + + List list(@Param("request") CustomerContactPageRequest request, @Param("userId") String userId, @Param("orgId") String orgId, + @Param("dataPermission") DeptDataPermissionDTO dataPermission); + + List selectContactOptionByIds(List contactIds); + + List listByCustomerId(@Param("customerId") String customerId); + + List getById(@Param("id") String id); + + List selectContactPhoneOptionByIds(@Param("contactIds") List contactIds); + + List getSimilarContactList(@Param("request") CustomerContactPageRequest request, @Param("userId") String userId, @Param("orgId") String organizationId); + + /** + * 获取联系人数量(唯一性校验) + * + * @param uniqueRequest 请求参数 + * @param customerId 客户ID + * @param orgId 组织ID + * + * @return 联系人数量 + */ + long getUniqueContactCount(@Param("request") ContactUniqueRequest uniqueRequest, @Param("customerId") String customerId, @Param("orgId") String orgId); + + Long getNewContactCount(@Param("request") HomeStatisticSearchWrapperRequest request); + + void updateContactOwner(@Param("customerId") String customerId, @Param("newOwner") String newOwner, @Param("oldOwner") String oldOwner, @Param("orgId") String orgId); + + /** + * 更新公海联系人负责人 + * + * @param customerId 客户ID + * @param newOwner 新负责人 + * @param oldOwner 旧负责人 + * @param orgId 组织ID + */ + void updatePoolContactOwner(@Param("customerId") String customerId, @Param("newOwner") String newOwner, @Param("oldOwner") String oldOwner, @Param("orgId") String orgId); + + void updateContactById(@Param("id") String id, @Param("owner") String owner); + + List getListByIds(@Param("ids") List ids); + + List getContactOptions(@Param("keyword") String keyword, @Param("orgId") String orgId); + + List globalSearchList(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + long globalSearchListCount(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + void batchUpdate(@Param("request") BatchUpdateDbParam request); + + /** + * 批量合并客户联系人 + * + * @param request 请求参数 + * @param userId 用户ID + * @param orgId 组织ID + */ + void batchMerge(@Param("request") CustomerMergeRequest request, @Param("userId") String userId, @Param("orgId") String orgId, + @Param("names") List names, @Param("phones") List phones); + + /** + * 获取待合并的客户联系人列表 + * + * @param request 请求参数 + * @param orgId 组织ID + * + * @return 客户联系人列表 + */ + List getMergeContactList(@Param("request") CustomerMergeRequest request, @Param("orgId") String orgId); + + List chart(@Param("request") ChartAnalysisDbRequest request, @Param("userId") String userId, @Param("orgId") String orgId, + @Param("dataPermission") DeptDataPermissionDTO dataPermission); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerContactMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerContactMapper.xml new file mode 100644 index 0000000..aca4ef3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerContactMapper.xml @@ -0,0 +1,569 @@ + + + + + + + + + + + + + + + order by + + + sort_table.department_id ${sortType}, cc.id ASC + + + sort_table.name ${sortType}, cc.id ASC + + + cc.${sortName} ${sortType}, cc.id ASC + + + CASE + WHEN module_table.type = 'INPUT_NUMBER' OR module_table.type = 'FORMULA' + THEN sort_table.field_value+0 + END ${sortType}, + sort_table.field_value ${sortType}, + cc.id ${sortType} + + + + + order by cc.create_time desc, cc.id asc + + + + + + + + + + + + + + + + + + + + left join customer_contact_field_blob ${tablePrefix}_${i} + on cc.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + left join customer_contact_field ${tablePrefix}_${i} + on cc.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + + left join sys_organization_user ${tablePrefix}_${i} + on ${tablePrefix}_${i}.user_id = cc.owner + + + + + + + + + + + left join customer_contact_field sort_table + on cc.id = sort_table.resource_id and sort_table.field_id = #{sortName} + left join sys_module_field module_table on sort_table.field_id = module_table.id + + + left join sys_organization_user sort_table + on sort_table.user_id = cc.owner + + + left join customer sort_table + on sort_table.id = cc.customer_id + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + update customer_contact + set owner = #{newOwner} + where owner = #{oldOwner} + and customer_id = #{customerId} + and organization_id = #{orgId} + + + + update customer_contact cc + left join sys_user su on cc.owner = su.id + set cc.owner = #{newOwner} + where (cc.owner = #{oldOwner} or cc.owner = '' or su.id is null) + and customer_id = #{customerId} + and organization_id = #{orgId} + + + + update customer_contact + set owner = #{owner} + where id = #{id} + + + + + + + + + + + + + UPDATE customer_contact + + + owner = #{request.fieldValue}, + + + name = #{request.fieldValue}, + + + phone = #{request.fieldValue}, + + + customer_id = #{request.fieldValue}, + + update_time = #{request.updateTime}, + update_user = #{request.updateUser} + + WHERE id IN + + #{id} + + + + + update customer_contact set customer_id = #{request.toMergeId}, update_time = unix_timestamp() * 1000, update_user = #{userId} + where customer_id in + + #{id} + + + and name not in + + #{name} + + + + and phone not in + + #{phone} + + + and organization_id = #{orgId} + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerMapper.java b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerMapper.java new file mode 100644 index 0000000..19f79ad --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerMapper.java @@ -0,0 +1,98 @@ +package cn.cordys.crm.customer.mapper; + +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.BatchUpdateDbParam; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.dto.request.CustomerBatchTransferRequest; +import cn.cordys.crm.customer.dto.request.CustomerChartAnalysisDbRequest; +import cn.cordys.crm.customer.dto.request.CustomerPageRequest; +import cn.cordys.crm.customer.dto.response.CustomerListResponse; +import cn.cordys.crm.home.dto.request.HomeStatisticSearchWrapperRequest; +import cn.cordys.crm.search.response.advanced.AdvancedCustomerPoolResponse; +import cn.cordys.crm.search.response.advanced.AdvancedCustomerResponse; +import cn.cordys.crm.search.response.global.GlobalCustomerPoolResponse; +import cn.cordys.crm.search.response.global.GlobalCustomerResponse; +import cn.cordys.crm.system.dto.FilterConditionDTO; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * @author jianxing + * @date 2025-02-08 17:42:41 + */ +public interface ExtCustomerMapper { + + List list(@Param("request") CustomerPageRequest request, @Param("orgId") String orgId, + @Param("userId") String userId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission); + + List sourceList(@Param("request") CustomerPageRequest request, @Param("orgId") String orgId, + @Param("userId") String userId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission); + + List selectOptionByIds(@Param("ids") List ids); + + void batchTransfer(@Param("request") CustomerBatchTransferRequest request, @Param("userId") String userId); + + List checkRepeatCustomer(@Param("request") CustomerPageRequest request, @Param("orgId") String orgId, + @Param("userId") String userId); + + int countByOwner(@Param("owner") String owner); + + /** + * 移入公海 + * + * @param customer 客户 + */ + void moveToPool(@Param("customer") Customer customer); + + List getCustomerOptions(@Param("keyword") String keyword, @Param("orgId") String orgId); + + List getCustomerOptionsByIds(@Param("ids") List ids); + + boolean hasRefOpportunity(@Param("ids") List ids); + + boolean hasRefContact(@Param("ids") List ids); + + /** + * 查询负责人过滤条件下的客户数量 + * + * @param ownerId 负责人ID + * @param filters 过滤条件集合 + * + * @return 客户数量 + */ + long filterOwnerCount(@Param("ownerId") String ownerId, @Param("filters") List filters); + + List getListByIds(@Param("ids") List ids); + + Long selectCustomerCount(@Param("request") HomeStatisticSearchWrapperRequest request, @Param("unfollowed") boolean unfollowed); + + List customerPoolList(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + List globalPoolSearchList(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + long globalPoolSearchListCount(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + + List globalSearchList(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + long globalSearchListCount(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + /** + * 根据ID全量更新客户信息 + * + * @param customer 客户 + */ + void updateIncludeNullById(@Param("customer") Customer customer); + + void batchUpdate(@Param("request") BatchUpdateDbParam request); + + List getCustomerPoolId(@Param("ids") List ids); + + List chart(@Param("request") CustomerChartAnalysisDbRequest request, @Param("userId") String userId, @Param("orgId") String orgId, + @Param("dataPermission") DeptDataPermissionDTO dataPermission); + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerMapper.xml new file mode 100644 index 0000000..83267db --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerMapper.xml @@ -0,0 +1,771 @@ + + + + + + update customer + set owner = #{request.owner}, + customer.collection_time = ${@java.lang.System@currentTimeMillis()}, + customer.update_time = ${@java.lang.System@currentTimeMillis()}, customer.update_user = #{userId} + where owner != #{request.owner} and id in + + #{id} + + + + + + + + + + + + + + + order by + + + c.${sortName} ${sortType}, c.id ASC + + + sou_sort.${sortName} ${sortType}, c.id ASC + + + CASE + WHEN module_table.type = 'INPUT_NUMBER' OR module_table.type = 'FORMULA' + THEN sort_table.field_value+0 + END ${sortType}, + sort_table.field_value ${sortType}, + c.id ${sortType} + + + + + order by c.create_time desc, c.id asc + + + + + + + + + + + + + + + + + + + + left join customer_field_blob ${tablePrefix}_${i} + on c.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + left join customer_field ${tablePrefix}_${i} + on c.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + + left join sys_organization_user ${tablePrefix}_${i} + on ${tablePrefix}_${i}.user_id = c.owner + + + + + + + + + + + + + left join sys_organization_user sou_sort on c.owner = sou_sort.user_id + + + left join customer_field sort_table + on c.id = sort_table.resource_id and sort_table.field_id = #{sortName} + left join sys_module_field module_table on sort_table.field_id = module_table.id + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + update customer set pool_id = #{customer.poolId}, in_shared_pool = #{customer.inSharedPool}, owner = #{customer.owner}, reason_id = #{customer.reasonId}, + collection_time = #{customer.collectionTime}, update_time = #{customer.updateTime}, update_user = #{customer.updateUser} + where id = #{customer.id} + + + + + + + + + + + + + + + + + + + + + + update customer set pool_id = #{customer.poolId}, in_shared_pool = #{customer.inSharedPool}, owner = #{customer.owner}, + collection_time = #{customer.collectionTime}, update_time = #{customer.updateTime} + where id = #{customer.id} + + + + UPDATE customer + + + owner = #{request.fieldValue}, + + + name = #{request.fieldValue}, + + update_time = #{request.updateTime}, + update_user = #{request.updateUser} + + WHERE id IN + + #{id} + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerOwnerMapper.java b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerOwnerMapper.java new file mode 100644 index 0000000..bf16a04 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerOwnerMapper.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.customer.mapper; + +import cn.cordys.crm.customer.dto.request.CustomerBatchTransferRequest; +import org.apache.ibatis.annotations.Param; + +/** + * @author jianxing + * @date 2025-02-24 11:06:10 + */ +public interface ExtCustomerOwnerMapper { + + void batchAdd(@Param("request") CustomerBatchTransferRequest transferRequest, @Param("userId") String userId); + + /** + * 获取最近的客户负责人 + * + * @param customerId 客户ID + * + * @return 负责人ID + */ + String getRecentOwner(@Param("customerId") String customerId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerOwnerMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerOwnerMapper.xml new file mode 100644 index 0000000..281cb27 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerOwnerMapper.xml @@ -0,0 +1,22 @@ + + + + + + + insert into customer_owner (id, owner, customer_id, operator, collection_time, end_time) + select UUID_SHORT() as id, customer.owner, customer.id as customer_id, '${userId}' as operator, customer.collection_time, UNIX_TIMESTAMP() * 1000 as end_time + from customer + where customer.owner != #{request.owner} and id in + + #{id} + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerPoolMapper.java b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerPoolMapper.java new file mode 100644 index 0000000..ff71f59 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerPoolMapper.java @@ -0,0 +1,56 @@ +package cn.cordys.crm.customer.mapper; + +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.crm.customer.domain.CustomerPool; +import cn.cordys.crm.customer.domain.CustomerPoolPickRule; +import cn.cordys.crm.customer.domain.CustomerPoolRecycleRule; +import cn.cordys.crm.customer.dto.CustomerPoolDTO; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtCustomerPoolMapper { + + /** + * 分页获取公海池 + * + * @param request 请求参数 + * @param orgId 组织ID + * + * @return 公海池列表 + */ + List list(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + /** + * 更新公海池领取规则 + * + * @param rule 领取规则 + */ + void updatePickRule(@Param("rule") CustomerPoolPickRule rule); + + /** + * 更新公海池回收规则 + * + * @param rule 回收规则 + */ + void updateRecycleRule(@Param("rule") CustomerPoolRecycleRule rule); + + /** + * 获取公海池集合 + * + * @param orgId 组织ID + * + * @return 公海池集合 + */ + List getAllPool(@Param("orgId") String orgId); + + /** + * 获取指定范围下的公海池 + * + * @param scopeIds 范围ID集合 + * @param orgId 组织ID + * + * @return 公海池集合 + */ + List getPoolByScopeIds(@Param("ids") List scopeIds, @Param("orgId") String orgId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerPoolMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerPoolMapper.xml new file mode 100644 index 0000000..3d1d902 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/mapper/ExtCustomerPoolMapper.xml @@ -0,0 +1,61 @@ + + + + + + + update customer_pool_pick_rule set limit_on_number = #{rule.limitOnNumber}, pick_number = #{rule.pickNumber}, limit_pre_owner = #{rule.limitPreOwner}, + pick_interval_days = #{rule.pickIntervalDays}, update_user = #{rule.updateUser}, update_time = #{rule.updateTime}, + limit_new = #{rule.limitNew}, new_pick_interval = #{rule.newPickInterval} + where pool_id = #{rule.poolId} + + + + update customer_pool_recycle_rule set operator = #{rule.operator}, `condition` = #{rule.condition}, update_user = #{rule.updateUser}, update_time = #{rule.updateTime} + where pool_id = #{rule.poolId} + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/ContractInvoiceLogService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/ContractInvoiceLogService.java new file mode 100644 index 0000000..575fc8e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/ContractInvoiceLogService.java @@ -0,0 +1,36 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.crm.contract.constants.ContractApprovalStatus; +import cn.cordys.crm.system.service.BaseModuleLogService; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.List; + +@Service +@Transactional(rollbackFor = Exception.class) +public class ContractInvoiceLogService extends BaseModuleLogService { + + @Override + public List handleLogField(List differences, String orgId) { + differences = super.handleModuleLogField(differences, orgId, FormKey.INVOICE.getKey()); + + for (JsonDifferenceDTO differ : differences) { + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.INVOICE_OWNER.getBusinessKey())) { + setUserFieldName(differ); + } else if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.INVOICE_CONTRACT_ID.getBusinessKey())) { + setContractFieldName(differ); + } else if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.INVOICE_BUSINESS_TITLE_ID.getBusinessKey())) { + setBusinessTitleName(differ); + } else if (Strings.CI.equals(differ.getColumn(), "approvalStatus")) { + setApprovalName(differ); + } + } + return differences; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/ContractPaymentPlanLogService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/ContractPaymentPlanLogService.java new file mode 100644 index 0000000..3a3c3ac --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/ContractPaymentPlanLogService.java @@ -0,0 +1,53 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.contract.service.ContractService; +import cn.cordys.crm.system.service.BaseModuleLogService; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(rollbackFor = Exception.class) +public class ContractPaymentPlanLogService extends BaseModuleLogService { + + @Override + public List handleLogField(List differences, String orgId) { + differences = super.handleModuleLogField(differences, orgId, FormKey.CONTRACT_PAYMENT_PLAN.getKey()); + + for (JsonDifferenceDTO differ : differences) { + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.CONTRACT_PAYMENT_PLAN_OWNER.getBusinessKey())) { + setUserFieldName(differ); + } else if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.CONTRACT_PAYMENT_PLAN_CONTRACT.getBusinessKey())) { + setContractFieldName(differ); + } else if (Strings.CS.equals(differ.getColumn(), "planStatus")) { + setPlanStatusFieldName(differ); + } + } + return differences; + } + + /** + * 回款计划状态 + * + * @param differ + */ + protected void setPlanStatusFieldName(JsonDifferenceDTO differ) { + ContractService contractService = CommonBeanFactory.getBean(ContractService.class); + assert contractService != null; + if (differ.getOldValue() != null) { + String oldName = Translator.get("contract.payment_plan.status." + differ.getOldValue().toString().toLowerCase()); + differ.setOldValueName(oldName); + } + if (differ.getNewValue() != null) { + String newName = Translator.get("contract.payment_plan.status." + differ.getNewValue().toString().toLowerCase()); + differ.setNewValueName(newName); + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerCapacityService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerCapacityService.java new file mode 100644 index 0000000..d44744e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerCapacityService.java @@ -0,0 +1,133 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.customer.domain.CustomerCapacity; +import cn.cordys.crm.customer.dto.CustomerCapacityDTO; +import cn.cordys.crm.customer.mapper.ExtCustomerCapacityMapper; +import cn.cordys.crm.system.dto.FilterConditionDTO; +import cn.cordys.crm.system.dto.request.CapacityAddRequest; +import cn.cordys.crm.system.dto.request.CapacityUpdateRequest; +import cn.cordys.crm.system.service.UserExtendService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Service +@Transactional(rollbackFor = Exception.class) +public class CustomerCapacityService { + + @Resource + private UserExtendService userExtendService; + @Resource + private BaseMapper customerCapacityMapper; + @Resource + private ExtCustomerCapacityMapper extCustomerCapacityMapper; + + /** + * 获取客户库容设置 + * + * @return 客户库容设置集合 + */ + public List list(String currentOrgId) { + var capacityList = new ArrayList(); + var wrapper = new LambdaQueryWrapper(); + wrapper.eq(CustomerCapacity::getOrganizationId, currentOrgId).orderByDesc(CustomerCapacity::getCreateTime); + var capacities = customerCapacityMapper.selectListByLambda(wrapper); + if (CollectionUtils.isEmpty(capacities)) { + return new ArrayList<>(); + } + capacities.forEach(capacity -> { + CustomerCapacityDTO capacityDTO = new CustomerCapacityDTO(); + capacityDTO.setId(capacity.getId()); + capacityDTO.setCapacity(capacity.getCapacity()); + capacityDTO.setMembers(userExtendService.getScope(JSON.parseArray(capacity.getScopeId(), String.class))); + capacityDTO.setFilters(StringUtils.isEmpty(capacity.getFilter()) ? new ArrayList<>() : JSON.parseArray(capacity.getFilter(), FilterConditionDTO.class)); + capacityList.add(capacityDTO); + }); + return capacityList; + } + + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.ADD) + public void add(CapacityAddRequest request, String currentUserId, String currentOrgId) { + List oldCapacities = customerCapacityMapper.selectAll(null); + List targetScopeIds = oldCapacities.stream().flatMap(capacity -> JSON.parseArray(capacity.getScopeId(), String.class).stream()) + .toList(); + boolean duplicate = userExtendService.hasDuplicateScopeObj(request.getScopeIds(), targetScopeIds, currentOrgId); + if (duplicate) { + throw new GenericException(Translator.get("capacity.scope.duplicate")); + } + + var capacity = new CustomerCapacity(); + capacity.setId(IDGenerator.nextStr()); + capacity.setOrganizationId(currentOrgId); + capacity.setCapacity(request.getCapacity()); + capacity.setScopeId(JSON.toJSONString(request.getScopeIds())); + capacity.setFilter(CollectionUtils.isNotEmpty(request.getFilters()) ? JSON.toJSONString(request.getFilters()) : null); + capacity.setCreateTime(System.currentTimeMillis()); + capacity.setCreateUser(currentUserId); + capacity.setUpdateTime(System.currentTimeMillis()); + capacity.setUpdateUser(currentUserId); + customerCapacityMapper.insert(capacity); + + // 添加日志上下文 + OperationLogContext.setContext(LogContextInfo.builder() + .modifiedValue(capacity) + .resourceId(capacity.getId()) + .resourceName(Translator.get("module.customer.capacity.setting")) + .build()); + } + + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE) + public void update(CapacityUpdateRequest request, String currentUserId, String currentOrgId) { + CustomerCapacity oldCapacity = customerCapacityMapper.selectByPrimaryKey(request.getId()); + if (oldCapacity == null) { + throw new GenericException(Translator.get("capacity.not.exist")); + } + var wrapper = new LambdaQueryWrapper(); + wrapper.eq(CustomerCapacity::getOrganizationId, currentOrgId).nq(CustomerCapacity::getId, request.getId()); + var oldCapacities = customerCapacityMapper.selectListByLambda(wrapper); + List targetScopeIds = oldCapacities.stream().flatMap(capacity -> JSON.parseArray(capacity.getScopeId(), String.class).stream()) + .toList(); + boolean duplicate = userExtendService.hasDuplicateScopeObj(request.getScopeIds(), targetScopeIds, currentOrgId); + if (duplicate) { + throw new GenericException(Translator.get("capacity.scope.duplicate")); + } + oldCapacity.setScopeId(JSON.toJSONString(request.getScopeIds())); + oldCapacity.setCapacity(request.getCapacity()); + oldCapacity.setFilter(CollectionUtils.isNotEmpty(request.getFilters()) ? JSON.toJSONString(request.getFilters()) : null); + oldCapacity.setUpdateTime(System.currentTimeMillis()); + oldCapacity.setUpdateUser(currentUserId); + extCustomerCapacityMapper.updateCapacity(oldCapacity); + + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(request.getId()) + .resourceName(Translator.get("module.customer.capacity.setting")) + .originalValue(oldCapacity) + .modifiedValue(customerCapacityMapper.selectByPrimaryKey(request.getId())) + .build() + ); + } + + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + customerCapacityMapper.deleteByPrimaryKey(id); + // 设置操作对象 + OperationLogContext.setResourceName(Translator.get("module.customer.capacity.setting")); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerCollaborationService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerCollaborationService.java new file mode 100644 index 0000000..cb55d45 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerCollaborationService.java @@ -0,0 +1,158 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.common.dto.UserDeptDTO; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.domain.CustomerCollaboration; +import cn.cordys.crm.customer.dto.request.CustomerCollaborationAddRequest; +import cn.cordys.crm.customer.dto.request.CustomerCollaborationUpdateRequest; +import cn.cordys.crm.customer.dto.response.CustomerCollaborationListResponse; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.notice.CommonNoticeSendService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class CustomerCollaborationService { + @Resource + private BaseMapper customerCollaborationMapper; + @Resource + private BaseService baseService; + @Resource + private CommonNoticeSendService commonNoticeSendService; + @Resource + private BaseMapper customerMapper; + + public List list(String customerId, String orgId) { + CustomerCollaboration example = new CustomerCollaboration(); + example.setCustomerId(customerId); + List collaborations = customerCollaborationMapper.select(example); + return buildListData(orgId, collaborations); + } + + public List selectByCustomerIdAndUserId(String customerId, String userId) { + CustomerCollaboration example = new CustomerCollaboration(); + example.setCustomerId(customerId); + example.setUserId(userId); + return customerCollaborationMapper.select(example); + } + + public List selectByCustomerId(String customerId) { + CustomerCollaboration example = new CustomerCollaboration(); + example.setCustomerId(customerId); + return customerCollaborationMapper.select(example); + } + + private List buildListData(String orgId, List collaborations) { + if (CollectionUtils.isEmpty(collaborations)) { + return List.of(); + } + Set userIds = new HashSet<>(); + Set ownerIds = new HashSet<>(); + + for (CustomerCollaboration collaboration : collaborations) { + userIds.add(collaboration.getCreateUser()); + userIds.add(collaboration.getUpdateUser()); + userIds.add(collaboration.getUserId()); + ownerIds.add(collaboration.getUserId()); + } + + Map userDeptMap = baseService.getUserDeptMapByUserIds(new ArrayList<>(ownerIds), orgId); + + Map userNameMap = baseService.getUserNameMap(userIds); + + return collaborations + .stream() + .map(item -> { + CustomerCollaborationListResponse customerCollaboration = + BeanUtils.copyBean(new CustomerCollaborationListResponse(), item); + UserDeptDTO userDeptDTO = userDeptMap.get(customerCollaboration.getUserId()); + if (userDeptDTO != null) { + customerCollaboration.setDepartmentId(userDeptDTO.getDeptId()); + customerCollaboration.setDepartmentName(userDeptDTO.getDeptName()); + } + customerCollaboration.setCreateUserName(userNameMap.get(customerCollaboration.getCreateUser())); + customerCollaboration.setUpdateUserName(userNameMap.get(customerCollaboration.getUpdateUser())); + customerCollaboration.setUserName(userNameMap.get(customerCollaboration.getUserId())); + return customerCollaboration; + }).toList(); + } + + public CustomerCollaboration add(CustomerCollaborationAddRequest request, String userId, String orgId) { + CustomerCollaboration customerCollaboration = BeanUtils.copyBean(new CustomerCollaboration(), request); + customerCollaboration.setCreateTime(System.currentTimeMillis()); + customerCollaboration.setUpdateTime(System.currentTimeMillis()); + customerCollaboration.setUpdateUser(userId); + customerCollaboration.setCreateUser(userId); + customerCollaboration.setId(IDGenerator.nextStr()); + customerCollaborationMapper.insert(customerCollaboration); + + // 添加协作人通知 + Customer customer = customerMapper.selectByPrimaryKey(request.getCustomerId()); + if (StringUtils.isNotBlank(customer.getOwner()) && !customer.getInSharedPool()) { + // 公海客户添加协作人无需通知 + Map userNameMap = baseService.getUserNameMap(List.of(userId, request.getUserId())); + var paramMap = new HashMap(4); + paramMap.put("useTemplate", "true"); + paramMap.put("template", Translator.get("message.customer.collaboration.add.text")); + paramMap.put("operator", userNameMap.getOrDefault(userId, userId)); + paramMap.put("uName", userNameMap.getOrDefault(request.getUserId(), request.getUserId())); + paramMap.put("name", customer.getName()); + commonNoticeSendService.sendNotice(NotificationConstants.Module.CUSTOMER, NotificationConstants.Event.CUSTOMER_COLLABORATION_ADD, + paramMap, userId, orgId, List.of(customer.getOwner()), true); + } + return customerCollaborationMapper.selectByPrimaryKey(customerCollaboration.getId()); + } + + public CustomerCollaboration update(CustomerCollaborationUpdateRequest request, String userId) { + CustomerCollaboration customerCollaboration = BeanUtils.copyBean(new CustomerCollaboration(), request); + customerCollaboration.setUpdateTime(System.currentTimeMillis()); + customerCollaboration.setUpdateUser(userId); + customerCollaborationMapper.update(customerCollaboration); + return customerCollaborationMapper.selectByPrimaryKey(customerCollaboration.getId()); + } + + public void delete(String id) { + customerCollaborationMapper.deleteByPrimaryKey(id); + } + + public void batchDelete(List ids) { + customerCollaborationMapper.deleteByIds(ids); + } + + public void deleteByCustomerIds(List customerIds) { + var wrapper = new LambdaQueryWrapper(); + wrapper.in(CustomerCollaboration::getCustomerId, customerIds); + customerCollaborationMapper.deleteByLambda(wrapper); + } + + /** + * 是否有客户协作关系 + * + * @param userId 协作人 + * @param customerId 客户ID + * + * @return bool + */ + public boolean hasCollaboration(String userId, String customerId) { + CustomerCollaboration example = new CustomerCollaboration(); + example.setUserId(userId); + example.setCustomerId(customerId); + return customerCollaborationMapper.countByExample(example) > 0; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerContactExportService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerContactExportService.java new file mode 100644 index 0000000..9e719b5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerContactExportService.java @@ -0,0 +1,209 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ExportHeadDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.service.BaseExportService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.SubListUtils; +import cn.cordys.crm.customer.dto.request.CustomerContactExportRequest; +import cn.cordys.crm.customer.dto.response.CustomerContactListResponse; +import cn.cordys.crm.customer.mapper.ExtCustomerContactMapper; +import cn.cordys.crm.customer.utils.CustomerContactFieldUtils; +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.domain.ExportTask; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.service.ExportTaskService; +import cn.cordys.registry.ExportThreadRegistry; +import cn.idev.excel.EasyExcel; +import cn.idev.excel.ExcelWriter; +import cn.idev.excel.support.ExcelTypeEnum; +import cn.idev.excel.write.metadata.WriteSheet; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.File; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class CustomerContactExportService extends BaseExportService { + @Resource + private ExportTaskService exportTaskService; + @Resource + private ExtCustomerContactMapper extCustomerContactMapper; + @Resource + private CustomerContactService customerContactService; + + /** + * 导出全部客户联系人数据 + * + * @param userId + * @param request + * @param orgId + * @param deptDataPermission + * @param locale + * + * @return + */ + public String export(String userId, CustomerContactExportRequest request, String orgId, + DeptDataPermissionDTO deptDataPermission, Locale locale) { + checkFileName(request.getFileName()); + exportTaskService.checkUserTaskLimit(userId, ExportConstants.ExportType.CUSTOMER_CONTACT.name()); + + final var fileId = IDGenerator.nextStr(); + final var exportTask = exportTaskService.saveTask( + orgId, fileId, userId, + ExportConstants.ExportType.CUSTOMER_CONTACT.toString(), + request.getFileName() + ); + + runExport(orgId, userId, LogModule.CUSTOMER_CONTACT, locale, exportTask, request.getFileName(), + () -> exportData(fileId, exportTask, userId, request, orgId, deptDataPermission)); + + return exportTask.getId(); + } + + private void exportData(String fileId, ExportTask exportTask, String userId, CustomerContactExportRequest request, String orgId, DeptDataPermissionDTO deptDataPermission) throws InterruptedException { + // 表头信息 + final var headList = request.getHeadList().stream() + .map(head -> List.of(head.getTitle())) + .toList(); + + batchHandleData( + fileId, + headList, + exportTask, + request.getFileName(), + request, + t -> getExportData(request.getHeadList(), request, userId, orgId, deptDataPermission, exportTask.getId()) + ); + + + } + + + /** + * 构建导出的数据 + * + * @param headList + * @param request + * @param userId + * @param orgId + * @param deptDataPermission + * + * @return + */ + private List> getExportData(List headList, CustomerContactExportRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission, String taskId) throws InterruptedException { + PageHelper.startPage(request.getCurrent(), request.getPageSize()); + //获取数据 + List allList = extCustomerContactMapper.list(request, userId, orgId, deptDataPermission); + allList = customerContactService.buildListData(allList, orgId); + Map fieldConfigMap = getFieldConfigMap(FormKey.CONTACT.getKey(), orgId); + //构建导出数据 + var data = new ArrayList>(); + for (CustomerContactListResponse response : allList) { + if (ExportThreadRegistry.isInterrupted(taskId)) { + throw new InterruptedException("线程已被中断,主动退出"); + } + List value = buildData(headList, response, fieldConfigMap); + data.add(value); + } + + return data; + } + + private List buildData(List headList, CustomerContactListResponse data, Map fieldConfigMap) { + var dataList = new ArrayList<>(); + //固定字段map + LinkedHashMap systemFieldMap = CustomerContactFieldUtils.getSystemFieldMap(data); + //自定义字段map + AtomicReference> moduleFieldMap = new AtomicReference<>(new LinkedHashMap<>()); + Optional.ofNullable(data.getModuleFields()).ifPresent(moduleFields -> + moduleFieldMap.set(moduleFields.stream().collect(Collectors.toMap(BaseModuleFieldValue::getFieldId, BaseModuleFieldValue::getFieldValue)))); + //处理数据转换 + transModuleFieldValue(headList, systemFieldMap, moduleFieldMap.get(), dataList, fieldConfigMap); + return dataList; + } + + + /** + * 导出选中客户联系人数据 + * + * @param userId + * @param request + * @param orgId + * @param locale + * + * @return + */ + public String exportSelect(String userId, ExportSelectRequest request, String orgId, Locale locale) { + checkFileName(request.getFileName()); + // 用户导出数量限制 + exportTaskService.checkUserTaskLimit(userId, ExportConstants.ExportType.CUSTOMER_CONTACT.name()); + + var fileId = IDGenerator.nextStr(); + var exportTask = exportTaskService.saveTask(orgId, fileId, userId, ExportConstants.ExportType.CUSTOMER_CONTACT.toString(), request.getFileName()); + + runExport(orgId, userId, LogModule.CUSTOMER_CONTACT, locale, exportTask, request.getFileName(), + () -> exportData(fileId, exportTask, userId, request, orgId)); + + return exportTask.getId(); + } + + private void exportData(String fileId, ExportTask exportTask, String userId, ExportSelectRequest request, String orgId) { + //表头信息 + var headList = request.getHeadList().stream() + .map(head -> List.of(head.getTitle())) + .toList(); + + // 准备导出文件 + var file = prepareExportFile(fileId, request.getFileName(), exportTask.getOrganizationId()); + try (ExcelWriter writer = EasyExcel.write(file) + .head(headList) + .excelType(ExcelTypeEnum.XLSX) + .build()) { + var sheet = EasyExcel.writerSheet("导出数据").build(); + + SubListUtils.dealForSubList(request.getIds(), SubListUtils.DEFAULT_EXPORT_BATCH_SIZE, (subIds) -> { + List> data = null; + try { + data = getExportDataBySelect(request.getHeadList(), subIds, orgId, exportTask.getId()); + } catch (InterruptedException e) { + log.error("任务停止中断", e); + exportTaskService.update(exportTask.getId(), ExportConstants.ExportStatus.STOP.toString(), userId); + } + writer.write(data, sheet); + }); + } + + } + + private List> getExportDataBySelect(List headList, List ids, String orgId, String taskId) throws InterruptedException { + var allList = Optional.ofNullable(extCustomerContactMapper.getListByIds(ids)).orElseGet(List::of); + allList = customerContactService.buildListData(allList, orgId); + var fieldConfigMap = getFieldConfigMap(FormKey.CONTACT.getKey(), orgId); + + // 预估容量,避免频繁扩容 + var data = new ArrayList>(allList.size()); + for (var response : allList) { + if (ExportThreadRegistry.isInterrupted(taskId)) { + throw new InterruptedException("线程已被中断,主动退出"); + } + data.add(buildData(headList, response, fieldConfigMap)); + } + + return data; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerContactFieldService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerContactFieldService.java new file mode 100644 index 0000000..2f35c34 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerContactFieldService.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.crm.customer.domain.CustomerContactField; +import cn.cordys.crm.customer.domain.CustomerContactFieldBlob; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class CustomerContactFieldService extends BaseResourceFieldService { + @Resource + private BaseMapper customerContactFieldMapper; + @Resource + private BaseMapper customerContactFieldBlobMapper; + + @Override + protected String getFormKey() { + return FormKey.CONTACT.getKey(); + } + + @Override + protected BaseMapper getResourceFieldMapper() { + return customerContactFieldMapper; + } + + @Override + protected BaseMapper getResourceFieldBlobMapper() { + return customerContactFieldBlobMapper; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerContactLogService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerContactLogService.java new file mode 100644 index 0000000..bac51db --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerContactLogService.java @@ -0,0 +1,59 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.system.service.BaseModuleLogService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(rollbackFor = Exception.class) +public class CustomerContactLogService extends BaseModuleLogService { + + @Resource + private BaseMapper customerMapper; + + @Override + public List handleLogField(List differences, String orgId) { + List result = super.handleModuleLogField( + differences, orgId, FormKey.CONTACT.getKey() + ); + + for (JsonDifferenceDTO differ : result) { + var column = differ.getColumn(); + + // 负责人字段处理 + if (Strings.CS.equals(column, BusinessModuleField.CUSTOMER_CONTACT_OWNER.getBusinessKey())) { + setUserFieldName(differ); + continue; + } + + // 客户字段处理 + if (Strings.CS.equals(column, BusinessModuleField.CUSTOMER_CONTACT_CUSTOMER.getBusinessKey())) { + differ.setOldValueName(getCustomerName(differ.getOldValue())); + differ.setNewValueName(getCustomerName(differ.getNewValue())); + } + } + + return result; + } + + /** + * 根据客户ID获取客户名称 + */ + private String getCustomerName(Object customerId) { + if (customerId == null) { + return null; + } + + var customer = customerMapper.selectByPrimaryKey(customerId.toString()); + return customer != null ? customer.getName() : null; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerContactResourceAccessContextProvider.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerContactResourceAccessContextProvider.java new file mode 100644 index 0000000..2621b6b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerContactResourceAccessContextProvider.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.permission.ResourceAccessContext; +import cn.cordys.common.permission.ResourceAccessContextProvider; +import cn.cordys.crm.customer.domain.CustomerContact; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class CustomerContactResourceAccessContextProvider implements ResourceAccessContextProvider { + + @Resource + private BaseMapper contactMapper; + + @Override + public String getFormType() { + return FormKey.CUSTOMER.getKey(); + } + + @Override + public ResourceAccessContext getAccessContext(String resourceId, String orgId) { + var customerContact = contactMapper.selectByPrimaryKey(resourceId); + if (customerContact == null) { + return null; + } + var context = new ResourceAccessContext(); + context.setOwnerId(customerContact.getOwner()); + return context; + } + + @Override + public Map batchGetOwnerIds(List resourceIds, String orgId) { + if (resourceIds == null || resourceIds.isEmpty()) { + return Map.of(); + } + return contactMapper.selectByIds(resourceIds).stream() + .filter(customer -> customer.getOwner() != null) + .collect(Collectors.toMap(CustomerContact::getId, CustomerContact::getOwner, (a, b) -> a)); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerContactService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerContactService.java new file mode 100644 index 0000000..fc21f9d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerContactService.java @@ -0,0 +1,716 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.*; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.domain.BaseResourceSubField; +import cn.cordys.common.dto.*; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.permission.PermissionUtils; +import cn.cordys.common.service.BaseChartService; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.customer.constants.CustomerCollaborationType; +import cn.cordys.crm.customer.domain.*; +import cn.cordys.crm.customer.dto.request.*; +import cn.cordys.crm.customer.dto.response.CustomerContactGetResponse; +import cn.cordys.crm.customer.dto.response.CustomerContactListAllResponse; +import cn.cordys.crm.customer.dto.response.CustomerContactListResponse; +import cn.cordys.crm.customer.mapper.ExtCustomerContactMapper; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.constants.SheetKey; +import cn.cordys.crm.system.domain.ModuleField; +import cn.cordys.crm.system.domain.ModuleFieldBlob; +import cn.cordys.crm.system.domain.ModuleForm; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.excel.CustomImportAfterDoConsumer; +import cn.cordys.crm.system.excel.handler.CustomHeadColWidthStyleStrategy; +import cn.cordys.crm.system.excel.handler.CustomTemplateWriteHandler; +import cn.cordys.crm.system.excel.listener.CustomFieldCheckEventListener; +import cn.cordys.crm.system.excel.listener.CustomFieldImportEventListener; +import cn.cordys.crm.system.notice.CommonNoticeSendService; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.excel.utils.EasyExcelExporter; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import cn.idev.excel.FastExcelFactory; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * @author jianxing + * @date 2025-02-24 11:06:10 + */ +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class CustomerContactService { + @Resource + private BaseMapper customerContactMapper; + @Resource + private ExtCustomerMapper extCustomerMapper; + @Resource + private ExtCustomerContactMapper extCustomerContactMapper; + @Resource + private BaseMapper customerMapper; + @Resource + private BaseService baseService; + @Resource + private CustomerContactFieldService customerContactFieldService; + @Resource + private BaseChartService baseChartService; + @Resource + private CustomerCollaborationService customerCollaborationService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private BaseMapper opportunityMapper; + @Resource + private PermissionCache permissionCache; + @Resource + private BaseMapper moduleFormMapper; + @Resource + private BaseMapper moduleFieldMapper; + @Resource + private BaseMapper moduleFieldBlobMapper; + @Resource + private CommonNoticeSendService commonNoticeSendService; + @Resource + private BaseMapper customerContactFieldMapper; + @Resource + private BaseMapper customerContactFieldBlobMapper; + @Resource + private LogService logService; + + public PagerWithOption> list(CustomerContactPageRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extCustomerContactMapper.list(request, userId, orgId, deptDataPermission); + list = buildListData(list, orgId); + + Map> optionMap = getListOptionMap(orgId, list); + + return PageUtils.setPageInfoWithOption(page, list, optionMap); + } + + public Map> getListOptionMap(String orgId, List list) { + ModuleFormConfigDTO customerFormConfig = getFormConfig(orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, CustomerContactListResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, moduleFieldValues); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(list, + CustomerContactListResponse::getOwner, CustomerContactListResponse::getOwnerName); + optionMap.put(BusinessModuleField.CUSTOMER_CONTACT_OWNER.getBusinessKey(), ownerFieldOption); + + // 补充客户选项 + List customerFieldOption = moduleFormService.getBusinessFieldOption(list, + CustomerContactListResponse::getCustomerId, CustomerContactListResponse::getCustomerName); + optionMap.put(BusinessModuleField.CUSTOMER_CONTACT_CUSTOMER.getBusinessKey(), customerFieldOption); + return optionMap; + } + + private ModuleFormConfigDTO getFormConfig(String orgId) { + return moduleFormCacheService.getBusinessFormConfig(FormKey.CONTACT.getKey(), orgId); + } + + public List buildListData(List list, String orgId) { + if (CollectionUtils.isEmpty(list)) { + return List.of(); + } + + List customerContactIds = list.stream().map(CustomerContactListResponse::getId) + .distinct() + .toList(); + + List customerIds = list.stream().map(CustomerContactListResponse::getCustomerId) + .distinct() + .toList(); + + Map> caseCustomFiledMap = customerContactFieldService.getResourceFieldMap(customerContactIds, true); + Map> fieldValueMap = customerContactFieldService.setBusinessRefFieldValue(list, moduleFormService.getFlattenFormFields(FormKey.CONTACT.getKey(), orgId), caseCustomFiledMap); + + Map customNameMap = extCustomerMapper.selectOptionByIds(customerIds) + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + + List ownerIds = list.stream() + .map(CustomerContactListResponse::getOwner) + .distinct() + .toList(); + + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + + list.forEach(customerListResponse -> { + // 获取自定义字段 + List customerFields = fieldValueMap.get(customerListResponse.getId()); + customerListResponse.setModuleFields(customerFields); + + UserDeptDTO userDeptDTO = userDeptMap.get(customerListResponse.getOwner()); + if (userDeptDTO != null) { + customerListResponse.setDepartmentId(userDeptDTO.getDeptId()); + customerListResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + + customerListResponse.setCustomerName(customNameMap.get(customerListResponse.getCustomerId())); + }); + + return baseService.setCreateUpdateOwnerUserName(list); + } + + /** + * @param id 联系人ID + * @return 联系人详情 + */ + public CustomerContactGetResponse get(String id) { + CustomerContact customerContact = customerContactMapper.selectByPrimaryKey(id); + if (customerContact == null) { + return null; + } + CustomerContactGetResponse customerContactGetResponse = BeanUtils.copyBean(new CustomerContactGetResponse(), customerContact); + + Customer customer = customerMapper.selectByPrimaryKey(customerContact.getCustomerId()); + if (customer != null) { + customerContactGetResponse.setCustomerName(customer.getName()); + } + + UserDeptDTO userDeptDTO = baseService.getUserDeptMapByUserId(customerContact.getOwner(), customerContact.getOrganizationId()); + if (userDeptDTO != null) { + customerContactGetResponse.setDepartmentId(userDeptDTO.getDeptId()); + customerContactGetResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + + customerContactGetResponse = baseService.setCreateUpdateOwnerUserName(customerContactGetResponse); + + // 获取模块字段 + List customerContactFields = customerContactFieldService.getModuleFieldValuesByResourceId(id); + customerContactFields = customerContactFieldService.setBusinessRefFieldValue(List.of(customerContactGetResponse), + moduleFormService.getFlattenFormFields(FormKey.CONTACT.getKey(), customerContact.getOrganizationId()), new HashMap<>(Map.of(id, customerContactFields))).get(id); + ModuleFormConfigDTO customerContactFormConfig = getFormConfig(customerContact.getOrganizationId()); + + Map> optionMap = moduleFormService.getOptionMap(customerContactFormConfig, customerContactFields); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(customerContactGetResponse, + CustomerContactGetResponse::getOwner, CustomerContactGetResponse::getOwnerName); + optionMap.put(BusinessModuleField.CUSTOMER_CONTACT_OWNER.getBusinessKey(), ownerFieldOption); + + // 补充客户选项 + List customerFieldOption = moduleFormService.getBusinessFieldOption(customerContactGetResponse, + CustomerContactGetResponse::getCustomerId, CustomerContactGetResponse::getCustomerName); + optionMap.put(BusinessModuleField.CUSTOMER_CONTACT_CUSTOMER.getBusinessKey(), customerFieldOption); + + customerContactGetResponse.setOptionMap(optionMap); + customerContactGetResponse.setModuleFields(customerContactFields); + + // 附件信息 + customerContactGetResponse.setAttachmentMap(moduleFormService.getAttachmentMap(customerContactFormConfig, customerContactFields)); + return customerContactGetResponse; + } + + /** + * 获取联系人详情(简化版)⚠️反射调用; 勿修改入参, 返回, 方法名! + * @param id 联系人ID + * @return 详情 + */ + public CustomerContactGetResponse getSimple(String id) { + CustomerContact customerContact = customerContactMapper.selectByPrimaryKey(id); + if (customerContact == null) { + return null; + } + CustomerContactGetResponse response = BeanUtils.copyBean(new CustomerContactGetResponse(), customerContact); + List fvs = customerContactFieldService.getModuleFieldValuesByResourceId(id); + response.setModuleFields(fvs); + return response; + } + + /** + * 批量获取联系人详情 (用于数据源批量查询优化) + * @param ids 联系人ID集合 + * @return 联系人详情列表 + */ + public List batchGetSimpleByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return List.of(); + } + List contacts = customerContactMapper.selectByIds(ids); + if (CollectionUtils.isEmpty(contacts)) { + return List.of(); + } + Map> fieldValueMap = customerContactFieldService.getResourceFieldMap(ids, true); + + return contacts.stream().map(contact -> { + CustomerContactGetResponse response = BeanUtils.copyBean(new CustomerContactGetResponse(), contact); + response.setModuleFields(fieldValueMap.get(contact.getId())); + return response; + }).toList(); + } + + @OperationLog(module = LogModule.CUSTOMER_CONTACT, type = LogType.ADD) + public CustomerContact add(CustomerContactAddRequest request, String userId, String orgId) { + CustomerContact customerContact = BeanUtils.copyBean(new CustomerContact(), request); + customerContact.setCreateTime(System.currentTimeMillis()); + customerContact.setUpdateTime(System.currentTimeMillis()); + customerContact.setUpdateUser(userId); + customerContact.setCreateUser(userId); + customerContact.setOrganizationId(orgId); + customerContact.setId(IDGenerator.nextStr()); + customerContact.setEnable(true); + if (StringUtils.isBlank(request.getOwner())) { + customerContact.setOwner(userId); + } + + //保存自定义字段 + customerContactFieldService.saveModuleField(customerContact, orgId, userId, request.getModuleFields(), false); + + customerContactMapper.insert(customerContact); + + baseService.handleAddLogWithResourceName(customerContact, request.getModuleFields()); + + // 添加联系人通知 + Customer customer = customerMapper.selectByPrimaryKey(request.getCustomerId()); + if (customer != null && StringUtils.isNotEmpty(customer.getOwner())) { + Map userNameMap = baseService.getUserNameMap(List.of(userId)); + var paramMap = new HashMap(4); + paramMap.put("useTemplate", "true"); + paramMap.put("template", Translator.get("message.customer.contact.add.text")); + paramMap.put("operator", userNameMap.getOrDefault(userId, userId)); + paramMap.put("cName", customerContact.getName()); + paramMap.put("name", customer.getName()); + commonNoticeSendService.sendNotice(NotificationConstants.Module.CUSTOMER, NotificationConstants.Event.CUSTOMER_CONCAT_ADD, + paramMap, userId, orgId, List.of(customer.getOwner()), true); + } + return customerContact; + } + + @OperationLog(module = LogModule.CUSTOMER_CONTACT, type = LogType.UPDATE, resourceId = "{#request.id}") + public CustomerContact update(CustomerContactUpdateRequest request, String userId, String orgId) { + CustomerContact customerContact = BeanUtils.copyBean(new CustomerContact(), request); + customerContact.setUpdateTime(System.currentTimeMillis()); + customerContact.setUpdateUser(userId); + + CustomerContact originCustomerContact = customerContactMapper.selectByPrimaryKey(customerContact.getId()); + // 获取模块字段 + List originCustomerFields = customerContactFieldService.getModuleFieldValuesByResourceId(request.getId()); + + if (BooleanUtils.isTrue(request.getAgentInvoke())) { + customerContactFieldService.updateModuleFieldByAgent(customerContact, originCustomerFields, request.getModuleFields(), orgId, userId); + } else { + // 更新模块字段 + updateModuleField(customerContact, request.getModuleFields(), orgId, userId); + } + + customerContactMapper.update(customerContact); + + customerContact = customerContactMapper.selectByPrimaryKey(customerContact.getId()); + baseService.handleUpdateLog(originCustomerContact, customerContact, originCustomerFields, request.getModuleFields(), originCustomerContact.getId(), originCustomerContact.getName()); + return customerContact; + } + + private void updateModuleField(CustomerContact customerContact, List moduleFields, String orgId, String userId) { + if (moduleFields == null) { + // 如果为 null,则不更新 + return; + } + // 先删除 + customerContactFieldService.deleteByResourceId(customerContact.getId()); + // 再保存 + customerContactFieldService.saveModuleField(customerContact, orgId, userId, moduleFields, true); + } + + @OperationLog(module = LogModule.CUSTOMER_CONTACT, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + CustomerContact originCustomerContact = customerContactMapper.selectByPrimaryKey(id); + if (originCustomerContact == null) { + return; + } + customerContactMapper.deleteByPrimaryKey(id); + customerContactFieldService.deleteByResourceId(id); + + // 设置操作对象 + OperationLogContext.setResourceName(originCustomerContact.getName()); + } + + @OperationLog(module = LogModule.CUSTOMER_CONTACT, type = LogType.UPDATE, resourceId = "{#id}") + public void enable(String id) { + changeEnable(id, true, StringUtils.EMPTY); + } + + private void changeEnable(String id, boolean enable, String reason) { + CustomerContact originCustomerContact = customerContactMapper.selectByPrimaryKey(id); + + CustomerContact customerContact = new CustomerContact(); + customerContact.setEnable(enable); + customerContact.setId(id); + customerContact.setDisableReason(reason); + customerContactMapper.updateById(customerContact); + + if (!originCustomerContact.getEnable().equals(customerContact.getEnable())) { + CustomerContact originResourceLog = new CustomerContact(); + originResourceLog.setEnable(!enable); + CustomerContact modifiedResourceLog = new CustomerContact(); + modifiedResourceLog.setEnable(customerContact.getEnable()); + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(id) + .resourceName(originCustomerContact.getName()) + .originalValue(originResourceLog) + .modifiedValue(modifiedResourceLog) + .build() + ); + } + } + + @OperationLog(module = LogModule.CUSTOMER_CONTACT, type = LogType.UPDATE, resourceId = "{#id}") + public void disable(String id, CustomerContactDisableRequest request) { + changeEnable(id, false, request.getReason()); + } + + public CustomerContactListAllResponse listByCustomerId(String customerId, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { + Customer customer = customerMapper.selectByPrimaryKey(customerId); + CustomerContactListAllResponse response = new CustomerContactListAllResponse(); + if (deptDataPermission.getAll() || Strings.CS.equals(userId, InternalUser.ADMIN.getValue())) { + // 全部数据权限,直接返回 + List list = extCustomerContactMapper.listByCustomerId(customerId); + list = buildListData(list, orgId); + Map> optionMap = getListOptionMap(orgId, list); + response.setList(list); + response.setOptionMap(optionMap); + return response; + } + + List list = List.of(); + + // 查询协作人信息 + List collaborations = customerCollaborationService.selectByCustomerId(customerId); + + // 获取协作类型的协作的联系人 + Set collaborationUserIds = collaborations.stream() + .filter(collaboration -> Strings.CS.equals(collaboration.getCollaborationType(), CustomerCollaborationType.COLLABORATION.name())) + .map(CustomerCollaboration::getUserId) + .collect(Collectors.toSet()); + + boolean isCustomerOwner = Strings.CS.equals(customer.getOwner(), userId); + boolean isCollaborationUser = collaborationUserIds.contains(userId); + + // 部门数据权限 + if (CollectionUtils.isNotEmpty(deptDataPermission.getDeptIds())) { + UserDeptDTO customerOwnerDept = baseService.getUserDeptMapByUserId(customer.getOwner(), orgId); + // 部门权限是否有该客户的权限 + boolean hasDeptCustomerPermission = customerOwnerDept != null && deptDataPermission.getDeptIds().contains(customerOwnerDept.getDeptId()); + + list = extCustomerContactMapper.listByCustomerId(customerId); + if (hasDeptCustomerPermission) { + Map userDeptMapByUserIds = baseService.getUserDeptMapByUserIds(collaborationUserIds, orgId); + list = list.stream() + .filter(item -> { + if (!collaborationUserIds.contains(item.getOwner())) { + // 不是协作人的联系人,则不过滤 + return true; + } + UserDeptDTO userDeptDTO = userDeptMapByUserIds.get(item.getOwner()); + // 部门数据权限,则过滤掉非本部门的协作人的联系人 + return userDeptDTO != null && deptDataPermission.getDeptIds().contains(userDeptDTO.getDeptId()); + }) + .toList(); + } else if (isCollaborationUser) { + // 没有权限,只是协作人,则只能看自己的 + list = list.stream() + .filter(item -> Strings.CS.equals(item.getOwner(), userId)) + .toList(); + } + } + + if (deptDataPermission.getSelf()) { + list = extCustomerContactMapper.listByCustomerId(customerId); + if (isCustomerOwner) { + // 本人数据权限,则过滤协作人的联系人 + list = list.stream() + .filter(item -> !collaborationUserIds.contains(item.getOwner()) || Strings.CS.equals(item.getOwner(), userId)) + .toList(); + } else if (isCollaborationUser) { + // 没有权限,只是协作人,则只能看自己的 + list = list.stream() + .filter(item -> Strings.CS.equals(item.getOwner(), userId)) + .toList(); + } + } + + list = buildListData(list, orgId); + Map> optionMap = getListOptionMap(orgId, list); + response.setList(list); + response.setOptionMap(optionMap); + return response; + } + + public boolean checkOpportunity(String id) { + var example = new Opportunity(); + example.setContactId(id); + return opportunityMapper.countByExample(example) > 0; + } + + public String getContactName(String id) { + CustomerContact customerContact = customerContactMapper.selectByPrimaryKey(id); + return Optional.ofNullable(customerContact).map(CustomerContact::getName).orElse(null); + } + + public CustomerContactListAllResponse getOpportunityContactList(String contactId, String orgId) { + CustomerContactListAllResponse response = new CustomerContactListAllResponse(); + List list = extCustomerContactMapper.getById(contactId); + list = buildListData(list, orgId); + Map> optionMap = getListOptionMap(orgId, list); + response.setList(list); + response.setOptionMap(optionMap); + return response; + } + + public ResourceTabEnableDTO getTabEnableConfig(String userId, String orgId) { + List rolePermissions = permissionCache.getRolePermissions(userId, orgId); + return PermissionUtils.getTabEnableConfig(userId, PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ, rolePermissions); + } + + /** + * 检查联系人和电话是否唯一 + * + * @param contact 联系人姓名 + * @param phone 联系人电话 + * @param customerId 客户ID + * @param orgId 组织ID + * + * @return 是否唯一 + */ + public boolean checkCustomerContactUnique(String contact, String phone, String customerId, String orgId) { + var formQueryWrapper = new LambdaQueryWrapper(); + formQueryWrapper.eq(ModuleForm::getOrganizationId, orgId).eq(ModuleForm::getFormKey, FormKey.CONTACT.getKey()); + List forms = moduleFormMapper.selectListByLambda(formQueryWrapper); + List formIds = forms.stream().map(ModuleForm::getId).toList(); + + var queryWrapper = new LambdaQueryWrapper(); + queryWrapper.in(ModuleField::getInternalKey, List.of(BusinessModuleField.CUSTOMER_CONTACT_NAME.getKey(), + BusinessModuleField.CUSTOMER_CONTACT_PHONE.getKey())).in(ModuleField::getFormId, formIds); + List fieldIds = moduleFieldMapper.selectListByLambda(queryWrapper).stream().map(ModuleField::getId).toList(); + List blobs = moduleFieldBlobMapper.selectByIds(fieldIds); + + boolean nameUnique = false, phoneUnique = false; + for (ModuleFieldBlob blob : blobs) { + BaseField baseField = JSON.parseObject(blob.getProp(), BaseField.class); + String internalKey = baseField.getInternalKey(); + boolean hasUnique = baseField.getRules().stream().anyMatch(rule -> RuleValidatorConstants.UNIQUE.equals(rule.getKey())); + if (Strings.CI.equals(internalKey, BusinessModuleField.CUSTOMER_CONTACT_NAME.getKey())) { + nameUnique = hasUnique; + } + if (Strings.CI.equals(internalKey, BusinessModuleField.CUSTOMER_CONTACT_PHONE.getKey())) { + phoneUnique = hasUnique; + } + } + if (!nameUnique && !phoneUnique) { + return true; + } + ContactUniqueRequest request = ContactUniqueRequest.builder().name(contact).phone(phone).nameUnique(nameUnique).phoneUnique(phoneUnique).build(); + return extCustomerContactMapper.getUniqueContactCount(request, customerId, orgId) == 0; + } + + + /** + * 更新责任人 + * + * @param customerId 客户ID + * @param newOwner 新的负责人 + * @param oldOwner 旧的负责人 + * @param orgId 组织ID + */ + public void updateContactOwner(String customerId, String newOwner, String oldOwner, String orgId) { + extCustomerContactMapper.updateContactOwner(customerId, newOwner, oldOwner, orgId); + } + + /** + * 更新公海客户联系人 + * + * @param customerId 客户ID + * @param newOwner 新的负责人 + * @param oldOwner 旧的负责人 + * @param orgId 组织ID + */ + public void updatePoolContactOwner(String customerId, String newOwner, String oldOwner, String orgId) { + extCustomerContactMapper.updatePoolContactOwner(customerId, newOwner, oldOwner, orgId); + } + + public List getContactListByNames(List names) { + var lambdaQueryWrapper = new LambdaQueryWrapper(); + lambdaQueryWrapper.in(CustomerContact::getName, names); + return customerContactMapper.selectListByLambda(lambdaQueryWrapper); + } + + /** + * 下载导入的模板 + * + * @param response 响应 + */ + public void downloadImportTpl(HttpServletResponse response, String currentOrg) { + new EasyExcelExporter() + .exportMultiSheetTplWithSharedHandler(response, moduleFormService.getCustomImportHeadsNoRef(FormKey.CONTACT.getKey(), currentOrg), + Translator.get("contact.import_tpl.name"), Translator.get(SheetKey.DATA), Translator.get(SheetKey.COMMENT), + new CustomTemplateWriteHandler(moduleFormService.getAllCustomImportFields(FormKey.CONTACT.getKey(), currentOrg)), new CustomHeadColWidthStyleStrategy()); + } + + /** + * 导入检查 + * + * @param file 导入文件 + * @param currentOrg 当前组织 + * + * @return 导入检查信息 + */ + public ImportResponse importPreCheck(MultipartFile file, String currentOrg) { + if (file == null) { + throw new GenericException(Translator.get("file_cannot_be_null")); + } + return checkImportExcel(file, currentOrg); + } + + /** + * 联系人导入 + * + * @param file 导入文件 + * @param currentOrg 当前组织 + * @param currentUser 当前用户 + * + * @return 导入返回信息 + */ + public ImportResponse realImport(MultipartFile file, String currentOrg, String currentUser) { + try { + List fields = moduleFormService.getAllFields(FormKey.CONTACT.getKey(), currentOrg); + CustomImportAfterDoConsumer afterDo = (contacts, contactFields, contactFieldBlobs) -> { + var logs = new ArrayList(); + contacts.forEach(contact -> { + contact.setEnable(true); + logs.add(new LogDTO(currentOrg, contact.getId(), currentUser, LogType.ADD, LogModule.CUSTOMER_CONTACT, contact.getName())); + }); + customerContactMapper.batchInsert(contacts); + customerContactFieldMapper.batchInsert(contactFields.stream().map(field -> BeanUtils.copyBean(new CustomerContactField(), field)).toList()); + customerContactFieldBlobMapper.batchInsert(contactFieldBlobs.stream().map(field -> BeanUtils.copyBean(new CustomerContactFieldBlob(), field)).toList()); + // record logs + logService.batchAdd(logs); + }; + CustomFieldImportEventListener eventListener = new CustomFieldImportEventListener<>(fields, CustomerContact.class, currentOrg, currentUser, + "customer_contact_field", afterDo, 2000, null, null); + FastExcelFactory.read(file.getInputStream(), eventListener).headRowNumber(1).ignoreEmptyRow(true).sheet().doRead(); + return ImportResponse.builder().errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccessCount()).failCount(eventListener.getErrList().size()).build(); + } catch (Exception e) { + log.error("contact import error: {}", e.getMessage()); + throw new GenericException(e.getMessage()); + } + } + + /** + * 检查导入的文件 + * + * @param file 文件 + * @param currentOrg 当前组织 + * + * @return 检查信息 + */ + private ImportResponse checkImportExcel(MultipartFile file, String currentOrg) { + try { + List fields = moduleFormService.getAllCustomImportFields(FormKey.CONTACT.getKey(), currentOrg); + CustomFieldCheckEventListener eventListener = new CustomFieldCheckEventListener(fields, "customer_contact", "customer_contact_field", currentOrg); + FastExcelFactory.read(file.getInputStream(), eventListener).headRowNumber(1).ignoreEmptyRow(true).sheet().doRead(); + return ImportResponse.builder().errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccess()).failCount(eventListener.getErrList().size()).build(); + } catch (Exception e) { + log.error("contact import pre-check error: {}", e.getMessage()); + throw new GenericException(e.getMessage()); + } + } + + public String getContactNameByIds(List ids) { + List contacts = customerContactMapper.selectByIds(ids); + if (CollectionUtils.isNotEmpty(contacts)) { + List names = contacts.stream().map(CustomerContact::getName).toList(); + return String.join(",", names); + } + return StringUtils.EMPTY; + } + + public void batchUpdate(ResourceBatchEditRequest request, String userId, String organizationId) { + BaseField field = customerContactFieldService.getAndCheckField(request.getFieldId(), organizationId); + + List originCustomerContacts = customerContactMapper.selectByIds(request.getIds()); + + customerContactFieldService.batchUpdate(request, field, originCustomerContacts, CustomerContact.class, LogModule.CUSTOMER_CONTACT, extCustomerContactMapper::batchUpdate, userId, organizationId); + } + + public List chart(ChartAnalysisRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { + ModuleFormConfigDTO formConfig = getFormConfig(orgId); + formConfig.getFields().addAll(BaseChartService.getChartBaseFields()); + ChartAnalysisDbRequest chartAnalysisDbRequest = ConditionFilterUtils.parseChartAnalysisRequest(request, formConfig); + List chartResults = extCustomerContactMapper.chart(chartAnalysisDbRequest, userId, orgId, deptDataPermission); + return baseChartService.translateAxisName(formConfig, chartAnalysisDbRequest, chartResults); + } + + /** + * 联系人是否有唯一字段 + * + * @param orgId 组织ID + * + * @return 是否唯一 + */ + public Map getUniqueMap(String orgId) { + var uniqueMap = new HashMap(2); + var formQueryWrapper = new LambdaQueryWrapper(); + formQueryWrapper.eq(ModuleForm::getOrganizationId, orgId).eq(ModuleForm::getFormKey, FormKey.CONTACT.getKey()); + List forms = moduleFormMapper.selectListByLambda(formQueryWrapper); + List formIds = forms.stream().map(ModuleForm::getId).toList(); + + var queryWrapper = new LambdaQueryWrapper(); + queryWrapper.in(ModuleField::getInternalKey, List.of(BusinessModuleField.CUSTOMER_CONTACT_NAME.getKey(), + BusinessModuleField.CUSTOMER_CONTACT_PHONE.getKey())).in(ModuleField::getFormId, formIds); + List fieldIds = moduleFieldMapper.selectListByLambda(queryWrapper).stream().map(ModuleField::getId).toList(); + List blobs = moduleFieldBlobMapper.selectByIds(fieldIds); + + for (ModuleFieldBlob blob : blobs) { + BaseField baseField = JSON.parseObject(blob.getProp(), BaseField.class); + String internalKey = baseField.getInternalKey(); + boolean hasUnique = baseField.getRules().stream().anyMatch(rule -> RuleValidatorConstants.UNIQUE.equals(rule.getKey())); + uniqueMap.put(internalKey, hasUnique); + } + return uniqueMap; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerExportService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerExportService.java new file mode 100644 index 0000000..2380106 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerExportService.java @@ -0,0 +1,193 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ExportHeadDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.service.BaseExportService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.SubListUtils; +import cn.cordys.crm.customer.dto.request.CustomerExportRequest; +import cn.cordys.crm.customer.dto.response.CustomerListResponse; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.crm.customer.utils.PoolCustomerFieldUtils; +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.domain.ExportTask; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.service.ExportTaskService; +import cn.cordys.registry.ExportThreadRegistry; +import cn.idev.excel.EasyExcel; +import cn.idev.excel.ExcelWriter; +import cn.idev.excel.support.ExcelTypeEnum; +import cn.idev.excel.write.metadata.WriteSheet; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.File; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class CustomerExportService extends BaseExportService { + + @Resource + private CustomerService customerService; + @Resource + private ExportTaskService exportTaskService; + @Resource + private ExtCustomerMapper extCustomerMapper; + + + public String export(String userId, CustomerExportRequest request, String orgId, DeptDataPermissionDTO deptDataPermission, Locale locale) { + checkFileName(request.getFileName()); + //用户导出数量 限制 + exportTaskService.checkUserTaskLimit(userId, ExportConstants.ExportType.CUSTOMER.name()); + + var exportTask = exportTaskService.saveTask(orgId, IDGenerator.nextStr(), userId, ExportConstants.ExportType.CUSTOMER.toString(), request.getFileName()); + + // 启动虚拟线程执行导出任务 + runExport(orgId, userId, LogModule.CUSTOMER_INDEX, locale, exportTask, request.getFileName(), + () -> exportCustomerData(exportTask, userId, request, orgId, deptDataPermission)); + + return exportTask.getId(); + } + + public void exportCustomerData(ExportTask exportTask, String userId, CustomerExportRequest request, String orgId, DeptDataPermissionDTO deptDataPermission) throws Exception { + //表头信息 + var headList = request.getHeadList().stream() + .map(head -> List.of(head.getTitle())) + .toList(); + //分批查询数据并写入文件 + batchHandleData(exportTask.getFileId(), + headList, + exportTask, + request.getFileName(), + request, + t -> getExportData(request.getHeadList(), request, userId, orgId, deptDataPermission, exportTask.getId())); + } + + private List buildData(List headList, CustomerListResponse data, Map fieldConfigMap) { + var dataList = new ArrayList<>(); + //固定字段map + LinkedHashMap systemFieldMap = PoolCustomerFieldUtils.getSystemFieldMap(data); + //自定义字段map + AtomicReference> moduleFieldMap = new AtomicReference<>(new LinkedHashMap<>()); + + Optional.ofNullable(data.getModuleFields()).ifPresent(moduleFields -> + moduleFieldMap.set(moduleFields.stream().collect(Collectors.toMap(BaseModuleFieldValue::getFieldId, BaseModuleFieldValue::getFieldValue)))); + //处理数据转换 + transModuleFieldValue(headList, systemFieldMap, moduleFieldMap.get(), dataList, fieldConfigMap); + return dataList; + } + + + /** + * 导出选择数据 + * + * @param userId 用户ID + * @param request 导出选择请求 + * @param orgId 组织ID + * + * @return 导出任务ID + */ + public String exportSelect(String userId, ExportSelectRequest request, String orgId, Locale locale) { + checkFileName(request.getFileName()); + // 用户导出数量限制 + exportTaskService.checkUserTaskLimit(userId, ExportConstants.ExportType.CUSTOMER.name()); + + String fileId = IDGenerator.nextStr(); + var exportTask = exportTaskService.saveTask(orgId, fileId, userId, ExportConstants.ExportType.CUSTOMER.toString(), request.getFileName()); + + runExport(orgId, userId, LogModule.CUSTOMER_INDEX, locale, exportTask, request.getFileName(), + () -> exportSelectData(exportTask, userId, request, orgId, locale)); + + return exportTask.getId(); + } + + public void exportSelectData(ExportTask exportTask, String userId, ExportSelectRequest request, String orgId, Locale locale) { + LocaleContextHolder.setLocale(locale); + ExportThreadRegistry.register(exportTask.getId(), Thread.currentThread()); + //表头信息 + var headList = request.getHeadList().stream() + .map(head -> List.of(head.getTitle())) + .toList(); + // 准备导出文件 + var file = prepareExportFile(exportTask.getFileId(), request.getFileName(), exportTask.getOrganizationId()); + try (ExcelWriter writer = EasyExcel.write(file) + .head(headList) + .excelType(ExcelTypeEnum.XLSX) + .build()) { + var sheet = EasyExcel.writerSheet("导出数据").build(); + + SubListUtils.dealForSubList(request.getIds(), SubListUtils.DEFAULT_EXPORT_BATCH_SIZE, (subIds) -> { + List> data = null; + try { + data = getExportDataBySelect(request.getHeadList(), subIds, orgId, exportTask.getId()); + } catch (InterruptedException e) { + log.error("任务停止中断", e); + exportTaskService.update(exportTask.getId(), ExportConstants.ExportStatus.STOP.toString(), userId); + } + writer.write(data, sheet); + }); + } + + //更新导出任务状态 + exportTaskService.update(exportTask.getId(), ExportConstants.ExportStatus.SUCCESS.toString(), userId); + } + + + /** + * 选中客户数据 + */ + private List> getExportDataBySelect( + List headList, List ids, String orgId, String taskId + ) throws InterruptedException { + + List rawList = extCustomerMapper.getListByIds(ids); + return buildCustomerExportData(headList, rawList, orgId, taskId); + } + + /** + * 条件分页导出客户数据 + */ + private List> getExportData( + List headList, CustomerExportRequest request, + String userId, String orgId, DeptDataPermissionDTO deptDataPermission, String taskId + ) throws InterruptedException { + + PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List rawList = extCustomerMapper.list(request, orgId, userId, deptDataPermission); + return buildCustomerExportData(headList, rawList, orgId, taskId); + } + + /** + * 构建客户导出数据(公共逻辑) + */ + private List> buildCustomerExportData( + List headList, List rawList, + String orgId, String taskId + ) throws InterruptedException { + + List dataList = customerService.buildListData(rawList, orgId); + Map fieldConfigMap = getFieldConfigMap(FormKey.CUSTOMER.getKey(), orgId); + + var result = new ArrayList>(dataList.size()); + for (CustomerListResponse response : dataList) { + if (ExportThreadRegistry.isInterrupted(taskId)) { + throw new InterruptedException("线程已被中断,主动退出"); + } + result.add(buildData(headList, response, fieldConfigMap)); + } + return result; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerFieldService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerFieldService.java new file mode 100644 index 0000000..ac5bb8f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerFieldService.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.crm.customer.domain.CustomerField; +import cn.cordys.crm.customer.domain.CustomerFieldBlob; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class CustomerFieldService extends BaseResourceFieldService { + @Resource + private BaseMapper customerFieldMapper; + @Resource + private BaseMapper customerFieldBlobMapper; + + @Override + protected String getFormKey() { + return FormKey.CUSTOMER.getKey(); + } + + @Override + protected BaseMapper getResourceFieldMapper() { + return customerFieldMapper; + } + + @Override + protected BaseMapper getResourceFieldBlobMapper() { + return customerFieldBlobMapper; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerLogService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerLogService.java new file mode 100644 index 0000000..3ce4e2d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerLogService.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.crm.system.service.BaseModuleLogService; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.text.SimpleDateFormat; +import java.util.List; + +@Service +@Transactional(rollbackFor = Exception.class) +public class CustomerLogService extends BaseModuleLogService { + + @Override + public List handleLogField(List differences, String orgId) { + differences = super.handleModuleLogField(differences, orgId, FormKey.CUSTOMER.getKey()); + + for (JsonDifferenceDTO differ : differences) { + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.CUSTOMER_OWNER.getBusinessKey())) { + setUserFieldName(differ); + } else if (Strings.CS.equals(differ.getColumn(), "collectionTime")) { + setFormatDataTimeFieldValueName(differ, new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); + } + } + return differences; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerOwnerHistoryService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerOwnerHistoryService.java new file mode 100644 index 0000000..0fc2532 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerOwnerHistoryService.java @@ -0,0 +1,127 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.common.dto.UserDeptDTO; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.domain.CustomerOwner; +import cn.cordys.crm.customer.dto.request.CustomerBatchTransferRequest; +import cn.cordys.crm.customer.dto.response.CustomerOwnerListResponse; +import cn.cordys.crm.customer.mapper.ExtCustomerOwnerMapper; +import cn.cordys.crm.system.constants.DictModule; +import cn.cordys.crm.system.domain.Dict; +import cn.cordys.crm.system.domain.DictConfig; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class CustomerOwnerHistoryService { + @Resource + private BaseMapper customerOwnerMapper; + @Resource + private BaseService baseService; + @Resource + private ExtCustomerOwnerMapper extCustomerOwnerMapper; + @Resource + private BaseMapper dictConfigMapper; + @Resource + private BaseMapper dictMapper; + + + public List list(String customerId, String orgId) { + var wrapper = new LambdaQueryWrapper(); + wrapper.eq(CustomerOwner::getCustomerId, customerId); + wrapper.orderByDesc(CustomerOwner::getEndTime); + List owners = customerOwnerMapper.selectListByLambda(wrapper); + return buildListData(orgId, owners); + } + + private List buildListData(String orgId, List owners) { + if (CollectionUtils.isEmpty(owners)) { + return List.of(); + } + Set userIds = new HashSet<>(); + Set ownerIds = new HashSet<>(); + Set reasonIds = new HashSet<>(); + + for (CustomerOwner owner : owners) { + userIds.add(owner.getOwner()); + userIds.add(owner.getOperator()); + ownerIds.add(owner.getOwner()); + reasonIds.add(owner.getReasonId()); + } + + Map userDeptMap = baseService.getUserDeptMapByUserIds(new ArrayList<>(ownerIds), orgId); + + Map userNameMap = baseService.getUserNameMap(userIds); + + List dictList = dictMapper.selectByIds(new ArrayList<>(reasonIds)); + var dictNameMap = dictList.stream().collect(Collectors.toMap(Dict::getId, Dict::getName)); + + var configLambdaQueryWrapper = new LambdaQueryWrapper(); + configLambdaQueryWrapper.eq(DictConfig::getModule, DictModule.CUSTOMER_POOL_RS.name()).eq(DictConfig::getOrganizationId, orgId); + List configs = dictConfigMapper.selectListByLambda(configLambdaQueryWrapper); + + var showReason = CollectionUtils.isNotEmpty(configs) && configs.getFirst().getEnabled(); + return owners + .stream() + .map(item -> { + CustomerOwnerListResponse customerOwner = + BeanUtils.copyBean(new CustomerOwnerListResponse(), item); + UserDeptDTO userDeptDTO = userDeptMap.get(customerOwner.getOwner()); + if (userDeptDTO != null) { + customerOwner.setDepartmentId(userDeptDTO.getDeptId()); + customerOwner.setDepartmentName(userDeptDTO.getDeptName()); + } + if (!showReason) { + customerOwner.setReasonId(null); + customerOwner.setReasonName(null); + } else { + customerOwner.setReasonName(dictNameMap.get(customerOwner.getReasonId())); + } + customerOwner.setOwnerName(userNameMap.get(customerOwner.getOwner())); + customerOwner.setOperatorName(userNameMap.get(customerOwner.getOperator())); + return customerOwner; + }).toList(); + } + + public CustomerOwner add(Customer customer, String userId, Boolean addReason) { + CustomerOwner customerOwner = new CustomerOwner(); + customerOwner.setOwner(customer.getOwner()); + customerOwner.setOperator(userId); + customerOwner.setCustomerId(customer.getId()); + customerOwner.setCollectionTime(customer.getCollectionTime()); + customerOwner.setEndTime(System.currentTimeMillis()); + if (addReason && StringUtils.isNotBlank(customer.getReasonId()) && !Strings.CS.equals(customer.getReasonId(), "system")) { + customerOwner.setReasonId(customer.getReasonId()); + } + customerOwner.setId(IDGenerator.nextStr()); + customerOwnerMapper.insert(customerOwner); + return customerOwner; + } + + public void batchAdd(CustomerBatchTransferRequest transferRequest, String userId) { + extCustomerOwnerMapper.batchAdd(transferRequest, userId); + } + + public void deleteByCustomerIds(List customerIds) { + var wrapper = new LambdaQueryWrapper(); + wrapper.in(CustomerOwner::getCustomerId, customerIds); + customerOwnerMapper.deleteByLambda(wrapper); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerPoolExportService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerPoolExportService.java new file mode 100644 index 0000000..3430a5a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerPoolExportService.java @@ -0,0 +1,63 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.crm.customer.dto.request.CustomerExportRequest; +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.domain.ExportTask; +import cn.cordys.crm.system.service.ExportTaskService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Locale; + +@Service +@Transactional(rollbackFor = Exception.class) +public class CustomerPoolExportService extends CustomerExportService { + + @Resource + private CustomerExportService customerExportService; + @Resource + private ExportTaskService exportTaskService; + + + /** + * 跨页导出公海池数据 + * + * @param userId + * @param request + * @param orgId + * @param deptDataPermission + * @param locale + * + * @return + */ + public String exportCrossPage(String userId, CustomerExportRequest request, String orgId, DeptDataPermissionDTO deptDataPermission, Locale locale) { + checkFileName(request.getFileName()); + //用户导出数量 限制 + exportTaskService.checkUserTaskLimit(userId, ExportConstants.ExportType.CUSTOMER_POOL.name()); + String fileId = IDGenerator.nextStr(); + ExportTask exportTask = exportTaskService.saveTask(orgId, fileId, userId, ExportConstants.ExportType.CUSTOMER_POOL.toString(), request.getFileName()); + + runExport(orgId, userId, LogModule.CUSTOMER_POOL, locale, exportTask, request.getFileName(), + () -> customerExportService.exportCustomerData(exportTask, userId, request, orgId, deptDataPermission)); + + return exportTask.getId(); + } + + public String exportCrossSelect(String userId, ExportSelectRequest request, String orgId, Locale locale) { + checkFileName(request.getFileName()); + // 用户导出数量限制 + exportTaskService.checkUserTaskLimit(userId, ExportConstants.ExportType.CUSTOMER_POOL.name()); + String fileId = IDGenerator.nextStr(); + ExportTask exportTask = exportTaskService.saveTask(orgId, fileId, userId, ExportConstants.ExportType.CUSTOMER_POOL.toString(), request.getFileName()); + + runExport(orgId, userId, LogModule.CUSTOMER_POOL, locale, exportTask, request.getFileName(), + () -> exportSelectData(exportTask, userId, request, orgId, locale)); + + return exportTask.getId(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerPoolService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerPoolService.java new file mode 100644 index 0000000..c95b350 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerPoolService.java @@ -0,0 +1,497 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.condition.CombineSearch; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.common.utils.RecycleConditionUtils; +import cn.cordys.crm.customer.domain.*; +import cn.cordys.crm.customer.dto.CustomerPoolDTO; +import cn.cordys.crm.customer.dto.CustomerPoolFieldConfigDTO; +import cn.cordys.crm.customer.dto.CustomerPoolPickRuleDTO; +import cn.cordys.crm.customer.dto.CustomerPoolRecycleRuleDTO; +import cn.cordys.crm.customer.dto.request.CustomerPoolAddRequest; +import cn.cordys.crm.customer.dto.request.CustomerPoolUpdateRequest; +import cn.cordys.crm.customer.mapper.ExtCustomerPoolMapper; +import cn.cordys.crm.system.constants.RecycleConditionColumnKey; +import cn.cordys.crm.system.constants.RecycleConditionOperator; +import cn.cordys.crm.system.constants.RecycleConditionScopeKey; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.RuleConditionDTO; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.UserExtendService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@Transactional(rollbackFor = Exception.class) +public class CustomerPoolService { + + @Resource + private BaseMapper userMapper; + @Resource + private BaseMapper customerMapper; + @Resource + private BaseMapper customerPoolMapper; + @Resource + private BaseMapper customerPoolHiddenFieldMapper; + @Resource + private BaseMapper customerPoolPickRuleMapper; + @Resource + private BaseMapper customerPoolRecycleRuleMapper; + @Resource + private ExtCustomerPoolMapper extCustomerPoolMapper; + @Resource + private UserExtendService userExtendService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + + /** + * 分页获取公海池 + * + * @param request 分页参数 + * + * @return 公海池列表 + */ + public List page(BasePageRequest request, String organizationId) { + List pools = extCustomerPoolMapper.list(request, organizationId); + if (CollectionUtils.isEmpty(pools)) { + return new ArrayList<>(); + } + + List userIds = pools.stream().flatMap(pool -> Stream.of(pool.getCreateUser(), pool.getUpdateUser())).toList(); + List createOrUpdateUsers = userMapper.selectByIds(userIds.toArray(new String[0])); + Map userMap = createOrUpdateUsers.stream() + .collect(Collectors.toMap(User::getId, User::getName)); + + List poolIds = pools.stream() + .map(CustomerPoolDTO::getId) + .toList(); + + var pickRuleWrapper = new LambdaQueryWrapper(); + pickRuleWrapper.in(CustomerPoolPickRule::getPoolId, poolIds); + + List pickRules = customerPoolPickRuleMapper.selectListByLambda(pickRuleWrapper); + Map pickRuleMap = pickRules.stream() + .collect(Collectors.toMap(CustomerPoolPickRule::getPoolId, pickRule -> pickRule)); + + var recycleRuleWrapper = new LambdaQueryWrapper(); + recycleRuleWrapper.in(CustomerPoolRecycleRule::getPoolId, poolIds); + + List recycleRules = customerPoolRecycleRuleMapper.selectListByLambda(recycleRuleWrapper); + Map recycleRuleMap = recycleRules.stream() + .collect(Collectors.toMap(CustomerPoolRecycleRule::getPoolId, recycleRule -> recycleRule)); + + Map> hiddenFieldMap = getCustomerPoolHiddenFieldByPoolIds(poolIds) + .stream() + .collect(Collectors.groupingBy(CustomerPoolHiddenField::getPoolId)); + + List fields = moduleFormCacheService.getBusinessFormConfig(FormKey.CUSTOMER.getKey(), organizationId).getFields(); + + pools.forEach(pool -> { + pool.setMembers(userExtendService.getScope(JSON.parseArray(pool.getScopeId(), String.class))); + pool.setOwners(userExtendService.getScope(JSON.parseArray(pool.getOwnerId(), String.class))); + pool.setCreateUserName(userMap.get(pool.getCreateUser())); + pool.setUpdateUserName(userMap.get(pool.getUpdateUser())); + + var pickRule = new CustomerPoolPickRuleDTO(); + BeanUtils.copyBean(pickRule, pickRuleMap.get(pool.getId())); + var recycleRule = new CustomerPoolRecycleRuleDTO(); + CustomerPoolRecycleRule customerPoolRecycleRule = recycleRuleMap.get(pool.getId()); + BeanUtils.copyBean(recycleRule, customerPoolRecycleRule); + recycleRule.setConditions(JSON.parseArray(customerPoolRecycleRule.getCondition(), RuleConditionDTO.class)); + delOldTime(recycleRule); + pool.setPickRule(pickRule); + pool.setRecycleRule(recycleRule); + + Set hiddenFieldIds; + if (hiddenFieldMap.get(pool.getId()) != null) { + hiddenFieldIds = hiddenFieldMap.get(pool.getId()).stream() + .map(CustomerPoolHiddenField::getFieldId) + .collect(Collectors.toSet()); + } else { + hiddenFieldIds = Set.of(); + } + + pool.setFieldConfigs(getFieldConfigs(fields, hiddenFieldIds)); + }); + + return pools; + } + + private void delOldTime(CustomerPoolRecycleRuleDTO recycleRule) { + recycleRule.getConditions().forEach(condition -> { + if (Strings.CS.equals(condition.getOperator(), RecycleConditionOperator.DYNAMICS.name())) { + String[] split = condition.getValue().split(","); + if (StringUtils.isNotBlank(condition.getValue()) && split.length == 2) { + String dateValue = split[0]; + String dateUnit = split[1]; + dateUnit = switch (dateUnit) { + case "day" -> "BEFORE_DAY"; + case "month" -> "BEFORE_MONTH"; + case "week" -> "BEFORE_WEEK"; + default -> dateUnit; + }; + condition.setValue("CUSTOM," + dateValue + "," + dateUnit); + } + } + }); + } + + + public List getFieldConfigs(List fields, Set hiddenFieldIds) { + return fields.stream().map(field -> { + var hiddenFieldDTO = new CustomerPoolFieldConfigDTO(); + hiddenFieldDTO.setFieldId(field.getId()); + hiddenFieldDTO.setFieldName(field.getName()); + hiddenFieldDTO.setEnable(!hiddenFieldIds.contains(field.getId())); + hiddenFieldDTO.setEditable(!Strings.CS.equals(field.getInternalKey(), BusinessModuleField.CUSTOMER_NAME.getKey())); + return hiddenFieldDTO; + }).toList(); + } + + public List getCustomerPoolHiddenFieldByPoolIds(List poolIds) { + var hiddenFieldWrapper = new LambdaQueryWrapper(); + hiddenFieldWrapper.in(CustomerPoolHiddenField::getPoolId, poolIds); + return customerPoolHiddenFieldMapper.selectListByLambda(hiddenFieldWrapper); + } + + /** + * 新增公海池 + * + * @param request 请求参数 + * @param currentUserId 当前用户ID + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.ADD) + public void add(CustomerPoolAddRequest request, String currentUserId, String organizationId) { + var pool = new CustomerPool(); + BeanUtils.copyBean(pool, request); + pool.setId(IDGenerator.nextStr()); + pool.setOrganizationId(organizationId); + pool.setOwnerId(JSON.toJSONString(request.getOwnerIds())); + pool.setScopeId(JSON.toJSONString(request.getScopeIds())); + pool.setCreateTime(System.currentTimeMillis()); + pool.setCreateUser(currentUserId); + pool.setUpdateTime(System.currentTimeMillis()); + pool.setUpdateUser(currentUserId); + customerPoolMapper.insert(pool); + var pickRule = new CustomerPoolPickRule(); + BeanUtils.copyBean(pickRule, request.getPickRule()); + pickRule.setId(IDGenerator.nextStr()); + pickRule.setPoolId(pool.getId()); + pickRule.setCreateUser(currentUserId); + pickRule.setCreateTime(System.currentTimeMillis()); + pickRule.setUpdateUser(currentUserId); + pickRule.setUpdateTime(System.currentTimeMillis()); + customerPoolPickRuleMapper.insert(pickRule); + var recycleRule = new CustomerPoolRecycleRule(); + BeanUtils.copyBean(recycleRule, request.getRecycleRule()); + recycleRule.setId(IDGenerator.nextStr()); + try { + recycleRule.setCondition(JSON.toJSONString(request.getRecycleRule().getConditions())); + } catch (Exception e) { + throw new GenericException(Translator.get("customer_rule_condition_error")); + } + recycleRule.setPoolId(pool.getId()); + recycleRule.setCreateUser(currentUserId); + recycleRule.setCreateTime(System.currentTimeMillis()); + recycleRule.setUpdateUser(currentUserId); + recycleRule.setUpdateTime(System.currentTimeMillis()); + customerPoolRecycleRuleMapper.insert(recycleRule); + + batchInsertCustomerPoolHiddenFields(pool.getId(), request.getHiddenFieldIds()); + + // 添加日志上下文 + OperationLogContext.setContext(LogContextInfo.builder() + .modifiedValue(pool) + .resourceId(pool.getId()) + .resourceName(Translator.get("module.customer.pool.setting")) + .build()); + } + + /** + * 修改公海池 + * + * @param request 请求参数 + * @param currentUserId 当前用户ID + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE, resourceId = "{#request.id}") + public void update(CustomerPoolUpdateRequest request, String currentUserId, String organizationId) { + CustomerPool originCustomerPool = checkPoolExist(request.getId()); + var pool = new CustomerPool(); + BeanUtils.copyBean(pool, request); + pool.setOrganizationId(organizationId); + pool.setOwnerId(JSON.toJSONString(request.getOwnerIds())); + pool.setScopeId(JSON.toJSONString(request.getScopeIds())); + pool.setUpdateTime(System.currentTimeMillis()); + pool.setUpdateUser(currentUserId); + customerPoolMapper.update(pool); + var pickRule = new CustomerPoolPickRule(); + BeanUtils.copyBean(pickRule, request.getPickRule()); + pickRule.setPoolId(pool.getId()); + pickRule.setUpdateUser(currentUserId); + pickRule.setUpdateTime(System.currentTimeMillis()); + extCustomerPoolMapper.updatePickRule(pickRule); + var recycleRule = new CustomerPoolRecycleRule(); + BeanUtils.copyBean(recycleRule, request.getRecycleRule()); + recycleRule.setPoolId(pool.getId()); + try { + recycleRule.setCondition(JSON.toJSONString(request.getRecycleRule().getConditions())); + } catch (Exception e) { + throw new GenericException(Translator.get("customer_rule_condition_error")); + } + recycleRule.setUpdateUser(currentUserId); + recycleRule.setUpdateTime(System.currentTimeMillis()); + extCustomerPoolMapper.updateRecycleRule(recycleRule); + + if (request.getHiddenFieldIds() != null) { + deleteCustomerPoolHiddenFieldByPoolId(pool.getId()); + batchInsertCustomerPoolHiddenFields(pool.getId(), request.getHiddenFieldIds()); + } + + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceName(Translator.get("module.customer.pool.setting")) + .originalValue(originCustomerPool) + .modifiedValue(customerPoolMapper.selectByPrimaryKey(request.getId())) + .build() + ); + } + + private void batchInsertCustomerPoolHiddenFields(String poolId, Set fieldIds) { + if (CollectionUtils.isEmpty(fieldIds)) { + return; + } + List customerPoolHiddenFields = fieldIds.stream() + .map(fieldId -> { + var customerPoolHiddenField = new CustomerPoolHiddenField(); + customerPoolHiddenField.setFieldId(fieldId); + customerPoolHiddenField.setPoolId(poolId); + return customerPoolHiddenField; + }).toList(); + customerPoolHiddenFieldMapper.batchInsert(customerPoolHiddenFields); + } + + private void deleteCustomerPoolHiddenFieldByPoolId(String poolId) { + var customerPoolHiddenField = new CustomerPoolHiddenField(); + customerPoolHiddenField.setPoolId(poolId); + customerPoolHiddenFieldMapper.delete(customerPoolHiddenField); + } + + /** + * 公海池是否存在未领取线索 + * + * @param id 线索池ID + */ + public boolean checkNoPick(String id) { + var wrapper = new LambdaQueryWrapper(); + wrapper.eq(Customer::getPoolId, id) + .eq(Customer::getInSharedPool, true); + List relations = customerMapper.selectListByLambda(wrapper); + return CollectionUtils.isNotEmpty(relations); + } + + /** + * 删除公海池 + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + checkPoolExist(id); + customerPoolMapper.deleteByPrimaryKey(id); + var pickRule = new CustomerPoolPickRule(); + pickRule.setPoolId(id); + customerPoolPickRuleMapper.delete(pickRule); + var recycleRule = new CustomerPoolRecycleRule(); + recycleRule.setPoolId(id); + customerPoolRecycleRuleMapper.delete(recycleRule); + deleteCustomerPoolHiddenFieldByPoolId(id); + + // 设置操作对象 + OperationLogContext.setResourceName(Translator.get("module.customer.pool.setting")); + } + + /** + * 启用/禁用公海池 + * + * @param id 线索池ID + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE, resourceId = "{#id}") + public void switchStatus(String id, String currentUserId) { + CustomerPool pool = checkPoolExist(id); + pool.setEnable(!pool.getEnable()); + pool.setUpdateTime(System.currentTimeMillis()); + pool.setUpdateUser(currentUserId); + customerPoolMapper.updateById(pool); + + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceName(Translator.get("module.customer.pool.setting")) + .originalValue(pool) + .modifiedValue(customerPoolMapper.selectByPrimaryKey(id)) + .build() + ); + } + + /** + * 校验公海池是否存在 + * + * @param id 公海池ID + * + * @return 公海池 + */ + private CustomerPool checkPoolExist(String id) { + CustomerPool pool = customerPoolMapper.selectByPrimaryKey(id); + if (pool == null) { + throw new GenericException(Translator.get("customer_pool_not_exist")); + } + return pool; + } + + /** + * 获取负责人默认公海ID + * + * @param ownerIds 负责人ID集合 + * @param organizationId 组织ID + * + * @return 默认公海 + */ + public Map getOwnersDefaultPoolMap(List ownerIds, String organizationId) { + var poolMap = new HashMap(4); + List pools = extCustomerPoolMapper.getAllPool(organizationId); + Map> ownerScopeMap = userExtendService.getMultiScopeMap(ownerIds, organizationId); + ownerIds.forEach(ownerId -> { + List matchPools = matchMultiScope(ownerScopeMap.get(ownerId), pools); + if (CollectionUtils.isEmpty(matchPools)) { + // not found pool for owner + return; + } + poolMap.put(ownerId, matchPools.getFirst()); + }); + + return poolMap; + } + + /** + * 匹配多个范围的公海 + * + * @param scopeIds 范围ID集合 + * @param pools 公海列表 + * + * @return 命中范围的公海列表 + */ + public List matchMultiScope(List scopeIds, List pools) { + /* + * 命中线索池任意范围即返回(默认按照创建时间作为优先级) + */ + if (CollectionUtils.isEmpty(scopeIds) || CollectionUtils.isEmpty(pools)) { + return new ArrayList<>(); + } + return pools.stream() + .filter(pool -> { + List poolScopes = JSON.parseArray(pool.getScopeId(), String.class); + return CollectionUtils.isNotEmpty(poolScopes) && CollectionUtils.containsAny(scopeIds, poolScopes); + }) + .sorted(Comparator.comparing(CustomerPool::getCreateTime).reversed()) + .toList(); + } + + /** + * 计算剩余归属天数 + * + * @param pool 公海池 + * @param collectionTime 领取时间 + * @param createTime 创建时间 + * + * @return 剩余归属天数 + */ + public Integer calcReservedDay(CustomerPool pool, CustomerPoolRecycleRule recycleRule, Long collectionTime, Long createTime) { + if (pool == null || !pool.getAuto() || recycleRule == null) { + return null; + } + + // 判断公海是否存在入库条件 + List conditions = JSON.parseArray(recycleRule.getCondition(), RuleConditionDTO.class); + return RecycleConditionUtils.calcRecycleDays(conditions, Math.min(collectionTime, createTime)); + } + + /** + * 获取负责人最佳匹配公海 + * + * @param pools 公海列表 + * + * @return 公海集合 + */ + public Map, CustomerPool> getOwnersBestMatchPoolMap(List pools) { + var poolMap = new HashMap, CustomerPool>(4); + pools.sort(Comparator.comparing(CustomerPool::getCreateTime).reversed()); + for (CustomerPool pool : pools) { + List exitOwnerIds = poolMap.keySet().stream().flatMap(List::stream).toList(); + List scopeIds = JSON.parseArray(pool.getScopeId(), String.class); + List ownerIds = userExtendService.getScopeOwnerIds(scopeIds, pool.getOrganizationId()); + List defaultOwnerIds = ownerIds.stream().distinct().filter(ownerId -> !exitOwnerIds.contains(ownerId)).toList(); + if (CollectionUtils.isEmpty(defaultOwnerIds)) { + continue; + } + poolMap.put(defaultOwnerIds, pool); + } + return poolMap; + } + + /** + * 判断客户是否需要回收 + * + * @return 是否回收 + */ + public boolean checkRecycled(Customer customer, CustomerPoolRecycleRule recycleRule) { + boolean allMatch = Strings.CS.equals(CombineSearch.SearchMode.AND.name(), recycleRule.getOperator()); + List conditions = JSON.parseArray(recycleRule.getCondition(), RuleConditionDTO.class); + if (allMatch) { + return conditions.stream().allMatch(condition -> matchTime(condition, customer)); + } else { + return conditions.stream().anyMatch(condition -> matchTime(condition, customer)); + } + } + + /** + * 是否匹配时间规则 + * + * @param condition 规则 + * @param customer 客户 + * + * @return 是否匹配 + */ + private boolean matchTime(RuleConditionDTO condition, Customer customer) { + if (Strings.CS.equals(condition.getColumn(), RecycleConditionColumnKey.STORAGE_TIME)) { + if (condition.getScope().contains(RecycleConditionScopeKey.CREATED)) { + return RecycleConditionUtils.matchTime(condition, customer.getCreateTime()); + } else if (condition.getScope().contains(RecycleConditionScopeKey.PICKED)) { + return RecycleConditionUtils.matchTime(condition, customer.getCollectionTime()); + } else { + return RecycleConditionUtils.matchTime(condition, customer.getCreateTime()) || RecycleConditionUtils.matchTime(condition, customer.getCollectionTime()); + } + } else { + return RecycleConditionUtils.matchTime(condition, customer.getFollowTime()); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerRelationService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerRelationService.java new file mode 100644 index 0000000..dc2d9fb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerRelationService.java @@ -0,0 +1,192 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.customer.constants.CustomerRelationType; +import cn.cordys.crm.customer.domain.CustomerRelation; +import cn.cordys.crm.customer.dto.request.CustomerRelationSaveRequest; +import cn.cordys.crm.customer.dto.request.CustomerRelationUpdateRequest; +import cn.cordys.crm.customer.dto.response.CustomerRelationListResponse; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import cn.idev.excel.util.StringUtils; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class CustomerRelationService { + @Resource + private BaseMapper customerRelationMapper; + @Resource + private ExtCustomerMapper extCustomerMapper; + + public List list(String customerId) { + CustomerRelation example = new CustomerRelation(); + example.setTargetCustomerId(customerId); + List sourceRelations = customerRelationMapper.select(example); + + var customerIds = new ArrayList(); + + // 查询上级集团的客户关系 + List result = sourceRelations + .stream().map(item -> { + CustomerRelationListResponse listResponse = new CustomerRelationListResponse(); + listResponse.setRelationType(CustomerRelationType.GROUP.name()); + listResponse.setId(item.getId()); + listResponse.setCustomerId(item.getSourceCustomerId()); + customerIds.add(listResponse.getCustomerId()); + return listResponse; + }).collect(Collectors.toList()); + + example = new CustomerRelation(); + example.setSourceCustomerId(customerId); + List targetRelations = customerRelationMapper.select(example); + + // 查询下级子公司的客户关系 + List subsidiaryCustomer = targetRelations.stream() + .map(item -> { + CustomerRelationListResponse listResponse = new CustomerRelationListResponse(); + listResponse.setRelationType(CustomerRelationType.SUBSIDIARY.name()); + listResponse.setId(item.getId()); + listResponse.setCustomerId(item.getTargetCustomerId()); + customerIds.add(listResponse.getCustomerId()); + return listResponse; + }).toList(); + + result.addAll(subsidiaryCustomer); + + if (CollectionUtils.isNotEmpty(customerIds)) { + // 设置客户名称 + var customerMap = extCustomerMapper.selectOptionByIds(customerIds) + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + result.forEach(item -> item.setCustomerName(customerMap.get(item.getCustomerId()))); + } + + return result; + } + + public void save(String customerId, List requests) { + // 先删除 + deleteByCustomerId(customerId); + // 再创建 + List relations = requests.stream() + .filter(request -> StringUtils.isNotBlank(request.getCustomerId()) && StringUtils.isNotBlank(request.getRelationType())) + .map(item -> getCustomerRelation(item, customerId)).toList(); + + checkTargetCustomer(customerId, relations); + + if (CollectionUtils.isNotEmpty(relations)) { + customerRelationMapper.batchInsert(relations); + } + } + + /** + * 校验添加的子公司是否已经有所属的集团了 + * 一个公司只能有一个集团 + * + * @param customerId + * @param relations + */ + private void checkTargetCustomer(String customerId, List relations) { + List targetCustomerIds = relations.stream() + .map(CustomerRelation::getTargetCustomerId) + .filter(targetCustomerId -> !Strings.CS.equals(targetCustomerId, customerId)).toList(); + + if (CollectionUtils.isEmpty(targetCustomerIds)) { + return; + } + + var customerRelation = new LambdaQueryWrapper(); + customerRelation.in(CustomerRelation::getSourceCustomerId, targetCustomerIds); + List customerRelations = customerRelationMapper.selectListByLambda(customerRelation) + .stream() + .filter(item -> !Strings.CS.equals(item.getSourceCustomerId(), customerId)) + .toList(); + + if (!customerRelations.isEmpty()) { + List sourceIds = customerRelations.stream() + .map(CustomerRelation::getSourceCustomerId) + .toList(); + List customers = extCustomerMapper.getCustomerOptionsByIds(sourceIds); + var userNames = customers.stream() + .map(OptionDTO::getName) + .distinct() + .collect(Collectors.joining(",")); + throw new GenericException(Translator.getWithArgs("customer.relation.target_customer.exist", userNames)); + } + } + + public void deleteByCustomerId(String customerId) { + deleteByCustomerIds(List.of(customerId)); + } + + public void deleteByCustomerIds(List customerIds) { + if (CollectionUtils.isEmpty(customerIds)) { + return; + } + var wrapper = new LambdaQueryWrapper(); + wrapper.in(CustomerRelation::getSourceCustomerId, customerIds); + customerRelationMapper.deleteByLambda(wrapper); + + wrapper = new LambdaQueryWrapper<>(); + wrapper.in(CustomerRelation::getTargetCustomerId, customerIds); + customerRelationMapper.deleteByLambda(wrapper); + } + + public CustomerRelation add(CustomerRelationSaveRequest request, String customerId) { + CustomerRelation customerRelation = getCustomerRelation(request, customerId); + checkTargetCustomer(customerId, List.of(customerRelation)); + customerRelationMapper.insert(customerRelation); + return customerRelation; + } + + private CustomerRelation getCustomerRelation(CustomerRelationSaveRequest request, String customerId) { + CustomerRelation customerRelation = new CustomerRelation(); + customerRelation.setId(IDGenerator.nextStr()); + customerRelation.setCreateTime(System.currentTimeMillis()); + if (Strings.CS.equals(request.getRelationType(), CustomerRelationType.GROUP.name())) { + customerRelation.setSourceCustomerId(request.getCustomerId()); + customerRelation.setTargetCustomerId(customerId); + } else { + customerRelation.setSourceCustomerId(customerId); + customerRelation.setTargetCustomerId(request.getCustomerId()); + } + return customerRelation; + } + + public CustomerRelation update(CustomerRelationUpdateRequest request, String customerId) { + CustomerRelation customerRelation = getCustomerRelation(request, customerId); + customerRelation.setId(request.getId()); + customerRelation.setCreateTime(null); + if (Strings.CS.equals(request.getRelationType(), CustomerRelationType.GROUP.name())) { + customerRelation.setSourceCustomerId(request.getCustomerId()); + customerRelation.setTargetCustomerId(customerId); + } else { + customerRelation.setSourceCustomerId(customerId); + customerRelation.setTargetCustomerId(request.getCustomerId()); + } + checkTargetCustomer(customerId, List.of(customerRelation)); + customerRelationMapper.update(customerRelation); + return customerRelationMapper.selectByPrimaryKey(request.getId()); + } + + public void delete(String id) { + customerRelationMapper.deleteByPrimaryKey(id); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerResourceAccessContextProvider.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerResourceAccessContextProvider.java new file mode 100644 index 0000000..e496751 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerResourceAccessContextProvider.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.permission.ResourceAccessContext; +import cn.cordys.common.permission.ResourceAccessContextProvider; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class CustomerResourceAccessContextProvider implements ResourceAccessContextProvider { + + @Resource + private BaseMapper customerMapper; + + @Override + public String getFormType() { + return FormKey.CUSTOMER.getKey(); + } + + @Override + public ResourceAccessContext getAccessContext(String resourceId, String orgId) { + var customer = customerMapper.selectByPrimaryKey(resourceId); + if (customer == null) { + return null; + } + var context = new ResourceAccessContext(); + context.setOwnerId(customer.getOwner()); + return context; + } + + @Override + public Map batchGetOwnerIds(List resourceIds, String orgId) { + if (resourceIds == null || resourceIds.isEmpty()) { + return Map.of(); + } + return customerMapper.selectByIds(resourceIds).stream() + .filter(customer -> customer.getOwner() != null) + .collect(Collectors.toMap(Customer::getId, Customer::getOwner, (a, b) -> a)); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerService.java new file mode 100644 index 0000000..f24d396 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerService.java @@ -0,0 +1,1019 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.domain.BaseResourceSubField; +import cn.cordys.common.dto.*; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.permission.PermissionUtils; +import cn.cordys.common.response.result.CrmHttpResultCode; +import cn.cordys.common.service.BaseChartService; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.customer.constants.CustomerResultCode; +import cn.cordys.crm.customer.domain.*; +import cn.cordys.crm.customer.dto.request.*; +import cn.cordys.crm.customer.dto.response.CustomerGetResponse; +import cn.cordys.crm.customer.dto.response.CustomerListResponse; +import cn.cordys.crm.customer.mapper.ExtCustomerContactMapper; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.crm.customer.mapper.ExtCustomerPoolMapper; +import cn.cordys.crm.follow.domain.FollowUpPlan; +import cn.cordys.crm.follow.domain.FollowUpRecord; +import cn.cordys.crm.follow.mapper.ExtFollowUpPlanMapper; +import cn.cordys.crm.follow.mapper.ExtFollowUpRecordMapper; +import cn.cordys.crm.follow.service.FollowUpPlanService; +import cn.cordys.crm.follow.service.FollowUpRecordService; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityMapper; +import cn.cordys.crm.system.constants.DictModule; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.constants.SheetKey; +import cn.cordys.crm.system.domain.Dict; +import cn.cordys.crm.system.dto.DictConfigDTO; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.request.BatchPoolReasonRequest; +import cn.cordys.crm.system.dto.request.PoolReasonRequest; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.BatchAffectResponse; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.excel.CustomImportAfterDoConsumer; +import cn.cordys.crm.system.excel.handler.CustomHeadColWidthStyleStrategy; +import cn.cordys.crm.system.excel.handler.CustomTemplateWriteHandler; +import cn.cordys.crm.system.excel.listener.CustomFieldCheckEventListener; +import cn.cordys.crm.system.excel.listener.CustomFieldImportEventListener; +import cn.cordys.crm.system.notice.CommonNoticeSendService; +import cn.cordys.crm.system.service.*; +import cn.cordys.excel.utils.EasyExcelExporter; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import cn.idev.excel.FastExcelFactory; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class CustomerService { + + @Resource + private BaseMapper customerMapper; + @Resource + private ExtCustomerMapper extCustomerMapper; + @Resource + private BaseService baseService; + @Resource + private CustomerFieldService customerFieldService; + @Resource + private CustomerCollaborationService customerCollaborationService; + @Resource + private CustomerOwnerHistoryService customerOwnerHistoryService; + @Resource + private CustomerPoolService customerPoolService; + @Resource + private BaseMapper customerPoolRecycleRuleMapper; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private CustomerRelationService customerRelationService; + @Resource + private FollowUpRecordService followUpRecordService; + @Resource + private FollowUpPlanService followUpPlanService; + @Resource + private DataScopeService dataScopeService; + @Resource + private LogService logService; + @Resource + private CommonNoticeSendService commonNoticeSendService; + @Resource + private PoolCustomerService poolCustomerService; + @Resource + private PermissionCache permissionCache; + @Resource + private UserExtendService userExtendService; + @Resource + private ExtCustomerPoolMapper extCustomerPoolMapper; + @Resource + private CustomerContactService customerContactService; + @Resource + private BaseChartService baseChartService; + @Resource + private DictService dictService; + @Resource + private BaseMapper customerFieldMapper; + @Resource + private BaseMapper customerFieldBlobMapper; + @Resource + private ExtCustomerContactMapper extCustomerContactMapper; + @Resource + private ExtOpportunityMapper extOpportunityMapper; + @Resource + private ExtFollowUpRecordMapper extFollowUpRecordMapper; + @Resource + private ExtFollowUpPlanMapper extFollowUpPlanMapper; + @Resource + private BaseMapper customerCollaborationMapper; + @Resource + private BaseMapper customerContactMapper; + + public PagerWithOption> list(CustomerPageRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extCustomerMapper.list(request, orgId, userId, deptDataPermission); + List buildList = buildListData(list, orgId); + Map> optionMap = buildOptionMap(orgId, list, buildList); + return PageUtils.setPageInfoWithOption(page, buildList, optionMap); + } + + public PagerWithOption> transitionList(CustomerPageRequest request, String userId, String orgId) { + /* + * 数据范围: 当前用户所在公海&私海客户(协作客户&&数据权限客户) + */ + List scopeIds = userExtendService.getUserScopeIds(userId, orgId); + List pools = extCustomerPoolMapper.getPoolByScopeIds(scopeIds, orgId); + request.setTransitionPoolIds(pools.stream().map(CustomerPool::getId).toList()); + request.setTransition(true); + request.setTransitionDataPermission(dataScopeService.getDeptDataPermission(userId, orgId, null, PermissionConstants.CUSTOMER_MANAGEMENT_READ)); + return list(request, userId, orgId, null); + } + + public Map> buildOptionMap(String orgId, List list, List buildList) { + // 处理自定义字段选项数据 + ModuleFormConfigDTO customerFormConfig = getFormConfig(orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, CustomerListResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, moduleFieldValues); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(buildList, + CustomerListResponse::getOwner, CustomerListResponse::getOwnerName); + optionMap.put(BusinessModuleField.CUSTOMER_OWNER.getBusinessKey(), ownerFieldOption); + + return optionMap; + } + + public ModuleFormConfigDTO getFormConfig(String orgId) { + return moduleFormCacheService.getBusinessFormConfig(FormKey.CUSTOMER.getKey(), orgId); + } + + public PagerWithOption> sourceList(CustomerPageRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extCustomerMapper.sourceList(request, orgId, userId, deptDataPermission); + List buildList = buildListData(list, orgId); + Map> optionMap = buildOptionMap(orgId, list, buildList); + return PageUtils.setPageInfoWithOption(page, buildList, optionMap); + } + + public List buildListData(List list, String orgId) { + if (CollectionUtils.isEmpty(list)) { + return list; + } + List customerIds = list.stream().map(CustomerListResponse::getId) + .toList(); + + Map> caseCustomFiledMap = customerFieldService.getResourceFieldMap(customerIds, true); + + List ownerIds = list.stream() + .map(CustomerListResponse::getOwner) + .distinct() + .toList(); + + List followerIds = list.stream() + .map(CustomerListResponse::getFollower) + .distinct() + .toList(); + List createUserIds = list.stream() + .map(CustomerListResponse::getCreateUser) + .distinct() + .toList(); + List updateUserIds = list.stream() + .map(CustomerListResponse::getUpdateUser) + .distinct() + .toList(); + List userIds = Stream.of(ownerIds, followerIds, createUserIds, updateUserIds) + .flatMap(Collection::stream) + .distinct() + .toList(); + Map userNameMap = baseService.getUserNameMap(userIds); + + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + + // 获取负责人默认公海信息 + Map ownersDefaultPoolMap = customerPoolService.getOwnersDefaultPoolMap(ownerIds, orgId); + List poolIds = ownersDefaultPoolMap.values().stream().map(CustomerPool::getId).distinct().toList(); + Map recycleRuleMap; + if (CollectionUtils.isEmpty(poolIds)) { + recycleRuleMap = Map.of(); + } else { + var recycleRuleWrapper = new LambdaQueryWrapper(); + recycleRuleWrapper.in(CustomerPoolRecycleRule::getPoolId, poolIds); + List recycleRules = customerPoolRecycleRuleMapper.selectListByLambda(recycleRuleWrapper); + recycleRuleMap = recycleRules.stream().collect(Collectors.toMap(CustomerPoolRecycleRule::getPoolId, rule -> rule)); + } + + // 公海原因 + DictConfigDTO dictConf = dictService.getDictConf(DictModule.CUSTOMER_POOL_RS.name(), orgId); + List dictList = dictConf.getDictList(); + Map dictMap = dictList.stream().collect(Collectors.toMap(Dict::getId, Dict::getName)); + + list.forEach(customerListResponse -> { + // 获取自定义字段 + List customerFields = caseCustomFiledMap.get(customerListResponse.getId()); + customerListResponse.setModuleFields(customerFields); + // 设置回收公海 + CustomerPool reservePool = ownersDefaultPoolMap.get(customerListResponse.getOwner()); + customerListResponse.setRecyclePoolName(reservePool != null ? reservePool.getName() : null); + // 计算剩余归属天数 + customerListResponse.setReservedDays(customerPoolService.calcReservedDay(reservePool, + reservePool != null ? recycleRuleMap.get(reservePool.getId()) : null, + customerListResponse.getCollectionTime(), customerListResponse.getCreateTime())); + + UserDeptDTO userDeptDTO = userDeptMap.get(customerListResponse.getOwner()); + if (userDeptDTO != null) { + customerListResponse.setDepartmentId(userDeptDTO.getDeptId()); + customerListResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + + if (StringUtils.isNotEmpty(customerListResponse.getFollower())) { + String followerName = baseService.getAndCheckOptionName(userNameMap.get(customerListResponse.getFollower())); + customerListResponse.setFollowerName(followerName); + } + String createUserName = baseService.getAndCheckOptionName(userNameMap.get(customerListResponse.getCreateUser())); + customerListResponse.setCreateUserName(createUserName); + String updateUserName = baseService.getAndCheckOptionName(userNameMap.get(customerListResponse.getUpdateUser())); + customerListResponse.setUpdateUserName(updateUserName); + customerListResponse.setOwnerName(userNameMap.get(customerListResponse.getOwner())); + if (StringUtils.isNotBlank(customerListResponse.getReasonId())) { + String reasonName = baseService.getAndCheckOptionName(dictMap.get(customerListResponse.getReasonId())); + customerListResponse.setReasonName(reasonName); + } + }); + + return list; + } + + public CustomerGetResponse getWithDataPermissionCheck(String id, String userId, String orgId) { + CustomerGetResponse getResponse = get(id); + if (getResponse == null) { + throw new GenericException(Translator.get("customer.not.exist")); + } + boolean hasPermission = dataScopeService.hasDataPermission(userId, orgId, getResponse.getOwner(), PermissionConstants.CUSTOMER_MANAGEMENT_READ); + if (!hasPermission) { + List collaborations = customerCollaborationService.selectByCustomerIdAndUserId(getResponse.getId(), userId); + if (CollectionUtils.isEmpty(collaborations)) { + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } else { + getResponse.setCollaborationType(collaborations.getFirst().getCollaborationType()); + } + } + + return getResponse; + } + + /** + * @param id 客户ID + * @return 客户详情 + */ + public CustomerGetResponse get(String id) { + Customer customer = customerMapper.selectByPrimaryKey(id); + if (customer == null) { + return null; + } + CustomerGetResponse customerGetResponse = BeanUtils.copyBean(new CustomerGetResponse(), customer); + customerGetResponse = baseService.setCreateUpdateOwnerUserName(customerGetResponse); + // 获取模块字段 + List customerFields = customerFieldService.getModuleFieldValuesByResourceId(id); + ModuleFormConfigDTO customerFormConfig = getFormConfig(customer.getOrganizationId()); + + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, customerFields); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(customerGetResponse, + CustomerGetResponse::getOwner, CustomerGetResponse::getOwnerName); + optionMap.put(BusinessModuleField.CUSTOMER_OWNER.getBusinessKey(), ownerFieldOption); + + customerGetResponse.setOptionMap(optionMap); + customerGetResponse.setModuleFields(customerFields); + + // 公海原因 + DictConfigDTO dictConf = dictService.getDictConf(DictModule.CUSTOMER_POOL_RS.name(), customer.getOrganizationId()); + List dictList = dictConf.getDictList(); + Map dictMap = dictList.stream().collect(Collectors.toMap(Dict::getId, Dict::getName)); + + if (customerGetResponse.getOwner() != null) { + // 获取负责人默认公海信息 + Map ownersDefaultPoolMap = customerPoolService.getOwnersDefaultPoolMap(List.of(customerGetResponse.getOwner()), customer.getOrganizationId()); + List poolIds = ownersDefaultPoolMap.values().stream().map(CustomerPool::getId).distinct().toList(); + Map recycleRuleMap; + if (CollectionUtils.isEmpty(poolIds)) { + recycleRuleMap = Map.of(); + } else { + var recycleRuleWrapper = new LambdaQueryWrapper(); + recycleRuleWrapper.in(CustomerPoolRecycleRule::getPoolId, poolIds); + List recycleRules = customerPoolRecycleRuleMapper.selectListByLambda(recycleRuleWrapper); + recycleRuleMap = recycleRules.stream().collect(Collectors.toMap(CustomerPoolRecycleRule::getPoolId, rule -> rule)); + } + + // 设置回收公海 + CustomerPool reservePool = ownersDefaultPoolMap.get(customerGetResponse.getOwner()); + customerGetResponse.setRecyclePoolName(reservePool != null ? reservePool.getName() : null); + // 计算剩余归属天数 + customerGetResponse.setReservedDays(customerPoolService.calcReservedDay(reservePool, + reservePool != null ? recycleRuleMap.get(reservePool.getId()) : null, + customerGetResponse.getCollectionTime(), customerGetResponse.getCreateTime())); + + + UserDeptDTO userDeptDTO = baseService.getUserDeptMapByUserId(customerGetResponse.getOwner(), customer.getOrganizationId()); + if (userDeptDTO != null) { + customerGetResponse.setDepartmentId(userDeptDTO.getDeptId()); + customerGetResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + } + + if (customerGetResponse.getFollower() != null) { + Map userNameMap = baseService.getUserNameMap(List.of(customerGetResponse.getFollower())); + String followerName = baseService.getAndCheckOptionName(userNameMap.get(customerGetResponse.getFollower())); + customerGetResponse.setFollowerName(followerName); + } + + if (StringUtils.isNotBlank(customerGetResponse.getReasonId())) { + String reasonName = baseService.getAndCheckOptionName(dictMap.get(customerGetResponse.getReasonId())); + customerGetResponse.setReasonName(reasonName); + } + + // 附件信息 + customerGetResponse.setAttachmentMap(moduleFormService.getAttachmentMap(customerFormConfig, customerFields)); + + return customerGetResponse; + } + + /** + * 获取客户详情 (⚠️反射调用; 勿修改入参, 返回, 方法名!) + * @param id 客户ID + * @return 客户详情 + */ + public CustomerGetResponse getSimple(String id) { + Customer customer = customerMapper.selectByPrimaryKey(id); + if (customer == null) { + return null; + } + CustomerGetResponse response = BeanUtils.copyBean(new CustomerGetResponse(), customer); + List fvs = customerFieldService.getModuleFieldValuesByResourceId(id); + response.setModuleFields(fvs); + return response; + } + + /** + * 批量获取客户详情 (用于数据源批量查询优化) + * @param ids 客户ID集合 + * @return 客户详情列表 + */ + public List batchGetSimpleByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return List.of(); + } + // 批量查询资源基本信息 + List customers = customerMapper.selectByIds(ids); + if (CollectionUtils.isEmpty(customers)) { + return List.of(); + } + // 批量查询自定义字段值 + Map> fieldValueMap = customerFieldService.getResourceFieldMap(ids, true); + + // 组装结果 + return customers.stream().map(customer -> { + CustomerGetResponse response = BeanUtils.copyBean(new CustomerGetResponse(), customer); + response.setModuleFields(fieldValueMap.get(customer.getId())); + return response; + }).toList(); + } + + @OperationLog(module = LogModule.CUSTOMER_INDEX, type = LogType.ADD) + public Customer add(CustomerAddRequest request, String userId, String orgId) { + Customer customer = BeanUtils.copyBean(new Customer(), request); + if (StringUtils.isBlank(request.getOwner())) { + customer.setOwner(userId); + } + poolCustomerService.validateCapacity(1, customer.getOwner(), orgId); + customer.setCreateTime(System.currentTimeMillis()); + customer.setUpdateTime(System.currentTimeMillis()); + customer.setCollectionTime(customer.getCreateTime()); + customer.setUpdateUser(userId); + customer.setCreateUser(userId); + customer.setOrganizationId(orgId); + customer.setId(IDGenerator.nextStr()); + customer.setInSharedPool(false); + + //保存自定义字段 + customerFieldService.saveModuleField(customer, orgId, userId, request.getModuleFields(), false); + + customerMapper.insert(customer); + + baseService.handleAddLogWithResourceName(customer, request.getModuleFields()); + // 通知 + commonNoticeSendService.sendNotice(NotificationConstants.Module.CUSTOMER, + NotificationConstants.Event.CUSTOMER_ADD, customer.getName(), userId, + orgId, List.of(customer.getOwner()), true); + return customer; + } + + @OperationLog(module = LogModule.CUSTOMER_INDEX, type = LogType.UPDATE, resourceId = "{#request.id}") + public Customer update(CustomerUpdateRequest request, String userId, String orgId) { + Customer originCustomer = customerMapper.selectByPrimaryKey(request.getId()); + if (!Strings.CS.equals(originCustomer.getOwner(), request.getOwner())) { + poolCustomerService.validateCapacity(1, request.getOwner(), orgId); + } + + Customer customer = BeanUtils.copyBean(new Customer(), request); + customer.setUpdateTime(System.currentTimeMillis()); + customer.setUpdateUser(userId); + + if (StringUtils.isNotBlank(request.getOwner())) { + if (!Strings.CS.equals(request.getOwner(), originCustomer.getOwner())) { + //客户负责人变更,联系人同步更新 + customerContactService.updateContactOwner(request.getId(), request.getOwner(), originCustomer.getOwner(), orgId); + + + // 如果责任人有修改,则添加责任人历史 + customerOwnerHistoryService.add(originCustomer, userId, false); + sendTransferNotice(List.of(originCustomer), request.getOwner(), userId, orgId); + // 重置领取时间 + customer.setCollectionTime(System.currentTimeMillis()); + } + } + + // 获取模块字段 + List originCustomerFields = customerFieldService.getModuleFieldValuesByResourceId(request.getId()); + + if (BooleanUtils.isTrue(request.getAgentInvoke())) { + customerFieldService.updateModuleFieldByAgent(customer, originCustomerFields, request.getModuleFields(), orgId, userId); + } else { + // 更新模块字段 + updateModuleField(customer, request.getModuleFields(), orgId, userId); + } + + customerMapper.update(customer); + + customer = customerMapper.selectByPrimaryKey(request.getId()); + baseService.handleUpdateLog(originCustomer, customer, originCustomerFields, request.getModuleFields(), originCustomer.getId(), originCustomer.getName()); + return customer; + } + + private void updateModuleField(Customer customer, List moduleFields, String orgId, String userId) { + if (moduleFields == null) { + // 如果为 null,则不更新 + return; + } + // 先删除 + customerFieldService.deleteByResourceId(customer.getId()); + // 再保存 + customerFieldService.saveModuleField(customer, orgId, userId, moduleFields, true); + } + + @OperationLog(module = LogModule.CUSTOMER_INDEX, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id, String userId, String orgId) { + Customer originCustomer = customerMapper.selectByPrimaryKey(id); + checkResourceRef(List.of(id)); + deleteCustomerResource(List.of(id)); + + // 设置操作对象 + OperationLogContext.setResourceName(originCustomer.getName()); + + commonNoticeSendService.sendNotice(NotificationConstants.Module.CUSTOMER, + NotificationConstants.Event.CUSTOMER_DELETED, originCustomer.getName(), userId, + orgId, List.of(originCustomer.getOwner()), true); + } + + public void batchTransfer(CustomerBatchTransferRequest request, String userId, String orgId) { + List originCustomers = customerMapper.selectByIds(request.getIds()); + long processCount = originCustomers.stream().filter(customer -> !Strings.CS.equals(customer.getOwner(), request.getOwner())).count(); + poolCustomerService.validateCapacity((int) processCount, request.getOwner(), orgId); + + // 添加责任人历史 + customerOwnerHistoryService.batchAdd(request, userId); + extCustomerMapper.batchTransfer(request, userId); + + // 记录日志 + List logs = originCustomers.stream() + .map(customer -> { + Customer originCustomer = new Customer(); + originCustomer.setOwner(customer.getOwner()); + Customer modifieCustomer = new Customer(); + modifieCustomer.setOwner(request.getOwner()); + LogDTO logDTO = new LogDTO(orgId, customer.getId(), userId, LogType.UPDATE, LogModule.CUSTOMER_INDEX, customer.getName()); + logDTO.setOriginalValue(originCustomer); + logDTO.setModifiedValue(modifieCustomer); + return logDTO; + }).toList(); + + logService.batchAdd(logs); + + sendTransferNotice(originCustomers, request.getOwner(), userId, orgId); + } + + private void sendTransferNotice(List originCustomers, String toUser, String userId, String orgId) { + originCustomers.forEach(customer -> commonNoticeSendService.sendNotice( + NotificationConstants.Module.CUSTOMER, + NotificationConstants.Event.CUSTOMER_TRANSFERRED_CUSTOMER, + customer.getName(), + userId, + orgId, + List.of(toUser), + true + )); + } + + public void batchDelete(List ids, String userId, String orgId) { + List customers = customerMapper.selectByIds(ids); + checkResourceRef(ids); + + deleteCustomerResource(ids); + + List logs = customers.stream() + .map(customer -> + new LogDTO(orgId, customer.getId(), userId, LogType.DELETE, LogModule.CUSTOMER_INDEX, customer.getName()) + ) + .toList(); + logService.batchAdd(logs); + + // 消息通知 + customers.forEach(customer -> + commonNoticeSendService.sendNotice(NotificationConstants.Module.CUSTOMER, + NotificationConstants.Event.CUSTOMER_DELETED, customer.getName(), userId, + orgId, List.of(customer.getOwner()), true) + ); + } + + public void deleteCustomerResource(List ids) { + // 删除客户 + customerMapper.deleteByIds(ids); + // 删除客户模块字段 + customerFieldService.deleteByResourceIds(ids); + // 删除客户协作人 + customerCollaborationService.deleteByCustomerIds(ids); + // 删除责任人历史 + customerOwnerHistoryService.deleteByCustomerIds(ids); + // 删除客户关系 + customerRelationService.deleteByCustomerIds(ids); + // 删除跟进记录 + followUpRecordService.deleteByCustomerIds(ids); + // 删除跟进计划 + followUpPlanService.deleteByCustomerIds(ids); + } + + public void checkResourceRef(List ids) { + if (extCustomerMapper.hasRefOpportunity(ids) || extCustomerMapper.hasRefContact(ids)) { + throw new GenericException(CustomerResultCode.CUSTOMER_RESOURCE_REF); + } + } + + private List getOwners(List customers) { + return customers.stream().map(Customer::getOwner) + .distinct() + .toList(); + } + + /** + * 批量移入公海 + * + * @param request 请求参数 + * @param orgId 组织ID + * @param currentUser 当前用户 + */ + public BatchAffectResponse batchToPool(BatchPoolReasonRequest request, String currentUser, String orgId) { + List customers = customerMapper.selectByIds(request.getIds()); + List ownerIds = getOwners(customers); + Map ownersDefaultPoolMap = customerPoolService.getOwnersDefaultPoolMap(ownerIds, orgId); + + int success = 0; + var logs = new ArrayList(); + for (Customer customer : customers) { + CustomerPool customerPool = ownersDefaultPoolMap.get(customer.getOwner()); + if (customerPool == null) { + // 未找到默认公海,不移入 + continue; + } + //更新责任人 + customerContactService.updateContactOwner(customer.getId(), "-", customer.getOwner(), orgId); + + + // 日志 + LogDTO logDTO = new LogDTO(orgId, customer.getId(), currentUser, LogType.MOVE_TO_CUSTOMER_POOL, LogModule.CUSTOMER_INDEX, customer.getName()); + String detail = Translator.getWithArgs("customer.to.pool", customer.getName(), + customerPool.getName()); + logDTO.setDetail(detail); + logs.add(logDTO); + // 消息通知 + commonNoticeSendService.sendNotice(NotificationConstants.Module.CUSTOMER, + NotificationConstants.Event.CUSTOMER_MOVED_HIGH_SEAS, customer.getName(), currentUser, + orgId, List.of(customer.getOwner()), true); + // 插入责任人历史 + customer.setReasonId(request.getReasonId()); + customerOwnerHistoryService.add(customer, currentUser, true); + customer.setPoolId(customerPool.getId()); + customer.setInSharedPool(true); + customer.setOwner(null); + customer.setCollectionTime(null); + customer.setUpdateUser(currentUser); + customer.setUpdateTime(System.currentTimeMillis()); + // 回收客户至公海 + extCustomerMapper.moveToPool(customer); + success++; + } + + logService.batchAdd(logs); + + return BatchAffectResponse.builder().success(success).fail(request.getIds().size() - success).build(); + } + + /** + * 移入公海 + * + * @param request 请求参数 + * @param currentUser 当前用户 + * @param orgId 组织ID + */ + public BatchAffectResponse toPool(PoolReasonRequest request, String currentUser, String orgId) { + BatchPoolReasonRequest batchRequest = new BatchPoolReasonRequest(); + batchRequest.setReasonId(request.getReasonId()); + batchRequest.setIds(List.of(request.getId())); + return batchToPool(batchRequest, currentUser, orgId); + } + + public List getCustomerOptions(String keyword, String organizationId) { + return extCustomerMapper.getCustomerOptions(keyword, organizationId); + } + + public String getCustomerName(String id) { + Customer customer = customerMapper.selectByPrimaryKey(id); + return Optional.ofNullable(customer).map(Customer::getName).orElse(null); + } + + public List getCustomerListByNames(List names) { + var lambdaQueryWrapper = new LambdaQueryWrapper(); + lambdaQueryWrapper.in(Customer::getName, names); + return customerMapper.selectListByLambda(lambdaQueryWrapper); + } + + public ResourceTabEnableDTO getTabEnableConfig(String userId, String orgId) { + List rolePermissions = permissionCache.getRolePermissions(userId, orgId); + return PermissionUtils.getTabEnableConfig(userId, PermissionConstants.CUSTOMER_MANAGEMENT_READ, rolePermissions); + } + + + public String getCustomerNameByIds(List ids) { + List customerList = customerMapper.selectByIds(ids); + if (CollectionUtils.isNotEmpty(customerList)) { + List names = customerList.stream().map(Customer::getName).toList(); + return String.join(",", JSON.parseArray(JSON.toJSONString(names), String.class)); + } + return StringUtils.EMPTY; + } + + /** + * 下载导入的模板 + * + * @param response 响应 + */ + public void downloadImportTpl(HttpServletResponse response, String currentOrg) { + new EasyExcelExporter() + .exportMultiSheetTplWithSharedHandler(response, moduleFormService.getCustomImportHeadsNoRef(FormKey.CUSTOMER.getKey(), currentOrg), + Translator.get("customer.import_tpl.name"), Translator.get(SheetKey.DATA), Translator.get(SheetKey.COMMENT), + new CustomTemplateWriteHandler(moduleFormService.getAllCustomImportFields(FormKey.CUSTOMER.getKey(), currentOrg)), + new CustomHeadColWidthStyleStrategy()); + } + + /** + * 导入检查 + * + * @param file 导入文件 + * @param currentOrg 当前组织 + * + * @return 导入检查信息 + */ + public ImportResponse importPreCheck(MultipartFile file, String currentOrg) { + if (file == null) { + throw new GenericException(Translator.get("file_cannot_be_null")); + } + return checkImportExcel(file, currentOrg); + } + + /** + * 客户导入 + * + * @param file 导入文件 + * @param currentOrg 当前组织 + * @param currentUser 当前用户 + * + * @return 导入返回信息 + */ + public ImportResponse realImport(MultipartFile file, String currentOrg, String currentUser) { + try { + List fields = moduleFormService.getAllFields(FormKey.CUSTOMER.getKey(), currentOrg); + CustomImportAfterDoConsumer afterDo = (customers, customerFields, customerFieldBlobs) -> { + var logs = new ArrayList(); + customers.forEach(customer -> { + customer.setCollectionTime(customer.getCreateTime()); + customer.setInSharedPool(false); + logs.add(new LogDTO(currentOrg, customer.getId(), currentUser, LogType.ADD, LogModule.CUSTOMER_INDEX, customer.getName())); + }); + customerMapper.batchInsert(customers); + customerFieldMapper.batchInsert(customerFields.stream().map(field -> BeanUtils.copyBean(new CustomerField(), field)).toList()); + customerFieldBlobMapper.batchInsert(customerFieldBlobs.stream().map(field -> BeanUtils.copyBean(new CustomerFieldBlob(), field)).toList()); + // record logs + logService.batchAdd(logs); + }; + CustomFieldImportEventListener eventListener = new CustomFieldImportEventListener<>(fields, Customer.class, currentOrg, currentUser, + "customer_field", afterDo, 2000, null, null); + FastExcelFactory.read(file.getInputStream(), eventListener).headRowNumber(1).ignoreEmptyRow(true).sheet().doRead(); + return ImportResponse.builder().errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccessCount()).failCount(eventListener.getErrList().size()).build(); + } catch (Exception e) { + log.error("customer import error: {}", e.getMessage()); + throw new GenericException(e.getMessage()); + } + } + + /** + * 检查导入的文件 + * + * @param file 文件 + * @param currentOrg 当前组织 + * + * @return 检查信息 + */ + private ImportResponse checkImportExcel(MultipartFile file, String currentOrg) { + try { + List fields = moduleFormService.getAllCustomImportFields(FormKey.CUSTOMER.getKey(), currentOrg); + CustomFieldCheckEventListener eventListener = new CustomFieldCheckEventListener(fields, "customer", "customer_field", currentOrg); + FastExcelFactory.read(file.getInputStream(), eventListener).headRowNumber(1).ignoreEmptyRow(true).sheet().doRead(); + return ImportResponse.builder().errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccess()).failCount(eventListener.getErrList().size()).build(); + } catch (Exception e) { + log.error("customer import pre-check error: {}", e.getMessage()); + throw new GenericException(e.getMessage()); + } + } + + public void batchUpdate(ResourceBatchEditRequest request, String userId, String organizationId) { + BaseField field = customerFieldService.getAndCheckField(request.getFieldId(), organizationId); + + if (Strings.CS.equals(field.getBusinessKey(), BusinessModuleField.CUSTOMER_OWNER.getBusinessKey())) { + // 修改负责人,走批量转移接口 + CustomerBatchTransferRequest batchTransferRequest = new CustomerBatchTransferRequest(); + batchTransferRequest.setIds(request.getIds()); + batchTransferRequest.setOwner(request.getFieldValue().toString()); + batchTransfer(batchTransferRequest, userId, organizationId); + return; + } + + List originCustomers = customerMapper.selectByIds(request.getIds()); + + customerFieldService.batchUpdate(request, field, originCustomers, Customer.class, LogModule.CUSTOMER_INDEX, extCustomerMapper::batchUpdate, userId, organizationId); + } + + /** + * @param request 合并请求参数 + * @param currentUser 当前用户 + * @param currentOrgId 当前组织ID + */ + @OperationLog(module = LogModule.CUSTOMER_INDEX, type = LogType.MERGE, resourceId = "{#request.toMergeId}") + public void merge(CustomerMergeRequest request, String currentUser, String currentOrgId) { + /* + * 规则: + * 1. 合并客户联系人, 客户关联商机. + * 3. 合并客户跟进记录/计划. + * 4. 删除被合并的客户, 并添加对应的负责人为合并客户的协作人, 被合并客户的协作人也要. + */ + request.getMergeIds().remove(request.getToMergeId()); + Customer oldCustomer = customerMapper.selectByPrimaryKey(request.getToMergeId()); + if (CollectionUtils.isEmpty(request.getMergeIds()) || oldCustomer == null) { + // 没有可合并的客户数据 + throw new GenericException(Translator.get("no.customer.merge.data")); + } + + // 批量合并产生的修改日志 + List mergeLogs = getMergeRelateLogs(request, currentUser, currentOrgId); + // 联系人需要排除重复项合并 + Map uniqueMap = customerContactService.getUniqueMap(currentOrgId); + List names = new ArrayList<>(); + List phones = new ArrayList<>(); + var contactWrapper = new LambdaQueryWrapper(); + contactWrapper.eq(CustomerContact::getCustomerId, request.getToMergeId()); + List toMergeContacts = customerContactMapper.selectListByLambda(contactWrapper); + if (uniqueMap.get(BusinessModuleField.CUSTOMER_CONTACT_NAME.getKey())) { + names = toMergeContacts.stream().map(CustomerContact::getName).toList(); + } + if (uniqueMap.get(BusinessModuleField.CUSTOMER_CONTACT_PHONE.getKey())) { + phones = toMergeContacts.stream().map(CustomerContact::getPhone).toList(); + } + extCustomerContactMapper.batchMerge(request, currentUser, currentOrgId, names, phones); + extOpportunityMapper.batchMerge(request, currentUser, currentOrgId); + extFollowUpRecordMapper.batchMerge(request, currentUser, currentOrgId); + extFollowUpPlanMapper.batchMerge(request, currentUser, currentOrgId); + + // 合并协作人&&删除被合并客户&&记录删除日志 + mergeCollaboration(request, currentUser, currentOrgId); + List mergeCustomers = customerMapper.selectByIds(request.getMergeIds()); + customerMapper.deleteByIds(request.getMergeIds()); + for (Customer mergeCustomer : mergeCustomers) { + // 被合并客户的删除日志 + mergeLogs.add(new LogDTO(currentOrgId, mergeCustomer.getId(), currentUser, LogType.DELETE, LogModule.CUSTOMER_INDEX, mergeCustomer.getName())); + } + + // 变更合并客户的负责人 + if (!Strings.CS.equals(oldCustomer.getOwner(), request.getOwnerId())) { + LogDTO logDTO = new LogDTO(currentOrgId, request.getToMergeId(), currentUser, LogType.UPDATE, LogModule.CUSTOMER_INDEX, oldCustomer.getName()); + logDTO.setOriginalValue(oldCustomer); + // 客户负责人变更,联系人同步更新 + customerContactService.updateContactOwner(request.getToMergeId(), request.getOwnerId(), oldCustomer.getOwner(), currentOrgId); + // 如果责任人有修改,则添加责任人历史 + customerOwnerHistoryService.add(oldCustomer, currentUser, false); + sendTransferNotice(List.of(oldCustomer), request.getOwnerId(), currentUser, currentOrgId); + Customer customer = BeanUtils.copyBean(new Customer(), oldCustomer); + // 重置领取时间 + customer.setCollectionTime(System.currentTimeMillis()); + customer.setOwner(request.getOwnerId()); + customerMapper.updateById(customer); + // 合并客户的修改日志 + logDTO.setModifiedValue(customer); + mergeLogs.add(logDTO); + } + + // 批量插入合并日志&&其他操作日志 + OperationLogContext.setContext(LogContextInfo.builder().resourceId(request.getToMergeId()).resourceName(oldCustomer.getName()) + .originalValue(Map.of("merge", mergeCustomers.stream().map(Customer::getName).toList())) + .modifiedValue(Map.of("merge", List.of(oldCustomer.getName()))) + .build()); + if (!CollectionUtils.isEmpty(mergeLogs)) { + logService.batchAdd(mergeLogs); + } + } + + public List chart(ChartAnalysisRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { + ModuleFormConfigDTO formConfig = getFormConfig(orgId); + formConfig.getFields().addAll(BaseChartService.getChartBaseFields()); + ChartAnalysisDbRequest chartAnalysisDbRequest = ConditionFilterUtils.parseChartAnalysisRequest(request, formConfig); + CustomerChartAnalysisDbRequest customerChartAnalysisDbRequest = BeanUtils.copyBean(new CustomerChartAnalysisDbRequest(), chartAnalysisDbRequest); + List chartResults = extCustomerMapper.chart(customerChartAnalysisDbRequest, userId, orgId, deptDataPermission); + return baseChartService.translateAxisName(formConfig, chartAnalysisDbRequest, chartResults); + } + + /** + * 合并客户协作人 + * + * @param request 请求参数 + * @param currentUser 当前用户 + * @param currentOrgId 当前组织ID + */ + private void mergeCollaboration(CustomerMergeRequest request, String currentUser, String currentOrgId) { + // 被合并客户的协作人 + var mergeCollaborationWrapper = new LambdaQueryWrapper(); + mergeCollaborationWrapper.in(CustomerCollaboration::getCustomerId, request.getMergeIds()); + List mergeCollaborations = customerCollaborationMapper.selectListByLambda(mergeCollaborationWrapper); + List toCollaborationUserIds = mergeCollaborations.stream().map(CustomerCollaboration::getUserId).distinct().toList(); + Map toCollaborationMap = mergeCollaborations.stream().collect(Collectors.toMap(CustomerCollaboration::getUserId, CustomerCollaboration::getCollaborationType)); + // 被合并的客户负责人 + List mergeCustomers = customerMapper.selectByIds(request.getMergeIds()); + List toCollaborationOwnerIds = mergeCustomers.stream().map(Customer::getOwner).toList(); + // 合并去重 + List mergeIds = Stream.of(toCollaborationUserIds, toCollaborationOwnerIds) + .flatMap(Collection::stream) + .distinct() + .toList(); + // 合并客户已存在的协作人 + var collaborationWrapper = new LambdaQueryWrapper(); + collaborationWrapper.eq(CustomerCollaboration::getCustomerId, request.getToMergeId()); + List customerCollaborations = customerCollaborationMapper.selectListByLambda(collaborationWrapper); + List collaborationUserIds = customerCollaborations.stream().map(CustomerCollaboration::getUserId).toList(); + for (String mergeId : mergeIds) { + if (collaborationUserIds.contains(mergeId) || Strings.CS.equals(mergeId, request.getOwnerId())) { + // 已经是协作人或者是合并客户的负责人,跳过 + continue; + } + CustomerCollaborationAddRequest collaborationAddRequest = new CustomerCollaborationAddRequest(); + collaborationAddRequest.setCustomerId(request.getToMergeId()); + // 如果被合并客户的协作人中有该用户,则继承该协作类型,否则使用默认类型 + collaborationAddRequest.setCollaborationType(toCollaborationMap.getOrDefault(mergeId, "COLLABORATION")); + collaborationAddRequest.setUserId(mergeId); + customerCollaborationService.add(collaborationAddRequest, currentUser, currentOrgId); + } + + // 删除被合并客户的协作人关系 + var delCollaborationWrapper = new LambdaQueryWrapper(); + delCollaborationWrapper.in(CustomerCollaboration::getCustomerId, request.getMergeIds()); + customerCollaborationMapper.deleteByLambda(delCollaborationWrapper); + } + + /** + * 获取合并相关的日志 + * + * @param mergeRequest 合并请求参数 + * @param currentUser 当前用户 + * @param currentOrgId 当前组织ID + * + * @return 日志列表 + */ + private List getMergeRelateLogs(CustomerMergeRequest mergeRequest, String currentUser, String currentOrgId) { + var logs = new ArrayList(); + + List mergeCustomers = customerMapper.selectByIds(mergeRequest.getMergeIds()); + Map customerMap = mergeCustomers.stream().collect(Collectors.toMap(Customer::getId, Customer::getName)); + // 联系人日志 + List mergeContacts = extCustomerContactMapper.getMergeContactList(mergeRequest, currentOrgId); + if (CollectionUtils.isNotEmpty(mergeContacts)) { + for (CustomerContact contact : mergeContacts) { + LogDTO logDTO = new LogDTO(currentOrgId, contact.getId(), currentUser, LogType.UPDATE, LogModule.CUSTOMER_CONTACT, contact.getName()); + contact.setCustomerId(customerMap.get(contact.getCustomerId())); + logDTO.setOriginalValue(contact); + CustomerContact newContact = BeanUtils.copyBean(new CustomerContact(), contact); + newContact.setCustomerId(mergeRequest.getToMergeId()); + logDTO.setModifiedValue(newContact); + logs.add(logDTO); + } + } + + // 商机日志 + List mergeOpportunities = extOpportunityMapper.getMergeOpportunityList(mergeRequest, currentOrgId); + if (CollectionUtils.isNotEmpty(mergeOpportunities)) { + for (Opportunity opportunity : mergeOpportunities) { + LogDTO logDTO = new LogDTO(currentOrgId, opportunity.getId(), currentUser, LogType.UPDATE, LogModule.OPPORTUNITY_INDEX, opportunity.getName()); + opportunity.setCustomerId(customerMap.get(opportunity.getCustomerId())); + logDTO.setOriginalValue(opportunity); + Opportunity newOpportunity = BeanUtils.copyBean(new Opportunity(), opportunity); + newOpportunity.setCustomerId(mergeRequest.getToMergeId()); + logDTO.setModifiedValue(newOpportunity); + logs.add(logDTO); + } + } + + // 跟进记录/计划日志 + List mergeRecords = extFollowUpRecordMapper.getMergeRecordList(mergeRequest, currentOrgId); + List recordCustomerIds = mergeRecords.stream().map(FollowUpRecord::getCustomerId).toList(); + List mergePlans = extFollowUpPlanMapper.getMergePlanList(mergeRequest, currentOrgId); + List planCustomerIds = mergePlans.stream().map(FollowUpPlan::getCustomerId).toList(); + for (String mergeId : mergeRequest.getMergeIds()) { + if (recordCustomerIds.contains(mergeId)) { + LogDTO logDTO = new LogDTO(currentOrgId, mergeId, currentUser, LogType.UPDATE, LogModule.FOLLOW_UP_RECORD, customerMap.get(mergeId)); + FollowUpRecord oldRecord = new FollowUpRecord(); + oldRecord.setCustomerId(customerMap.get(mergeId)); + logDTO.setOriginalValue(oldRecord); + FollowUpRecord newRecord = new FollowUpRecord(); + newRecord.setCustomerId(mergeRequest.getToMergeId()); + logDTO.setModifiedValue(newRecord); + logs.add(logDTO); + } + if (planCustomerIds.contains(mergeId)) { + LogDTO logDTO = new LogDTO(currentOrgId, mergeId, currentUser, LogType.UPDATE, LogModule.FOLLOW_UP_PLAN, customerMap.get(mergeId)); + FollowUpPlan oldPlan = new FollowUpPlan(); + oldPlan.setCustomerId(customerMap.get(mergeId)); + logDTO.setOriginalValue(oldPlan); + FollowUpPlan newPlan = new FollowUpPlan(); + newPlan.setCustomerId(mergeRequest.getToMergeId()); + logDTO.setModifiedValue(newPlan); + logs.add(logDTO); + } + } + + return logs; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/PoolCustomerService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/PoolCustomerService.java new file mode 100644 index 0000000..e4c2de1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/PoolCustomerService.java @@ -0,0 +1,457 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.dto.ChartAnalysisDbRequest; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.service.BaseChartService; +import cn.cordys.common.util.*; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.customer.domain.*; +import cn.cordys.crm.customer.dto.CustomerPoolDTO; +import cn.cordys.crm.customer.dto.CustomerPoolPickRuleDTO; +import cn.cordys.crm.customer.dto.CustomerPoolRecycleRuleDTO; +import cn.cordys.crm.customer.dto.request.CustomerChartAnalysisDbRequest; +import cn.cordys.crm.customer.dto.request.PoolCustomerChartAnalysisRequest; +import cn.cordys.crm.customer.dto.request.PoolCustomerPickRequest; +import cn.cordys.crm.customer.mapper.ExtCustomerCapacityMapper; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.crm.customer.mapper.ExtCustomerOwnerMapper; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.FilterConditionDTO; +import cn.cordys.crm.system.dto.RuleConditionDTO; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.request.PoolBatchAssignRequest; +import cn.cordys.crm.system.dto.request.PoolBatchPickRequest; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.notice.CommonNoticeSendService; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.UserExtendService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +public class PoolCustomerService { + + public static final long DAY_MILLIS = 24 * 60 * 60 * 1000; + @Resource + private BaseMapper customerMapper; + @Resource + private ExtCustomerMapper extCustomerMapper; + @Resource + private BaseMapper userMapper; + @Resource + private BaseMapper ownerMapper; + @Resource + private BaseMapper poolMapper; + @Resource + private BaseMapper pickRuleMapper; + @Resource + private BaseMapper recycleRuleMapper; + @Resource + private ExtCustomerCapacityMapper extCustomerCapacityMapper; + @Resource + private UserExtendService userExtendService; + @Resource + private LogService logService; + @Resource + private CommonNoticeSendService commonNoticeSendService; + @Resource + private CustomerPoolService customerPoolService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private CustomerContactService customerContactService; + @Resource + private CustomerFieldService customerFieldService; + @Resource + private BaseChartService baseChartService; + @Resource + private ExtCustomerOwnerMapper extCustomerOwnerMapper; + + /** + * 获取当前用户公海选项 + * + * @param currentUser 当前用户ID + * @param currentOrgId 当前组织ID + * + * @return 公海选项 + */ + public List getPoolOptions(String currentUser, String currentOrgId) { + var options = new ArrayList(); + var poolWrapper = new LambdaQueryWrapper(); + poolWrapper.eq(CustomerPool::getEnable, true) + .eq(CustomerPool::getOrganizationId, currentOrgId) + .orderByDesc(CustomerPool::getUpdateTime); + + List pools = poolMapper.selectListByLambda(poolWrapper); + if (CollectionUtils.isEmpty(pools)) { + return options; + } + + List userIds = pools.stream() + .flatMap(pool -> + Stream.of(pool.getCreateUser(), pool.getUpdateUser())).toList(); + + List createOrUpdateUsers = userMapper.selectByIds(userIds.toArray(new String[0])); + Map userMap = createOrUpdateUsers.stream() + .collect(Collectors.toMap(User::getId, User::getName)); + + List poolIds = pools.stream() + .map(CustomerPool::getId) + .toList(); + var pickRuleWrapper = new LambdaQueryWrapper(); + pickRuleWrapper.in(CustomerPoolPickRule::getPoolId, poolIds); + + List pickRules = pickRuleMapper.selectListByLambda(pickRuleWrapper); + Map pickRuleMap = pickRules.stream() + .collect(Collectors.toMap(CustomerPoolPickRule::getPoolId, pickRule -> pickRule)); + + var recycleRuleWrapper = new LambdaQueryWrapper(); + recycleRuleWrapper.in(CustomerPoolRecycleRule::getPoolId, poolIds); + + List recycleRules = recycleRuleMapper.selectListByLambda(recycleRuleWrapper); + Map recycleRuleMap = recycleRules.stream() + .collect(Collectors.toMap(CustomerPoolRecycleRule::getPoolId, recycleRule -> recycleRule)); + + Map> hiddenFieldMap = customerPoolService.getCustomerPoolHiddenFieldByPoolIds(poolIds) + .stream() + .collect(Collectors.groupingBy(CustomerPoolHiddenField::getPoolId)); + + List fields = moduleFormCacheService.getBusinessFormConfig(FormKey.CUSTOMER.getKey(), currentOrgId).getFields(); + + + pools.forEach(pool -> { + List scopeIds = userExtendService.getScopeOwnerIds(JSON.parseArray(pool.getScopeId(), String.class), currentOrgId); + List ownerIds = userExtendService.getScopeOwnerIds(JSON.parseArray(pool.getOwnerId(), String.class), currentOrgId); + if (scopeIds.contains(currentUser) || ownerIds.contains(currentUser) || Strings.CS.equals(currentUser, InternalUser.ADMIN.getValue())) { + var poolDTO = new CustomerPoolDTO(); + BeanUtils.copyBean(poolDTO, pool); + + poolDTO.setMembers(userExtendService.getScope(JSON.parseArray(pool.getScopeId(), String.class))); + poolDTO.setOwners(userExtendService.getScope(JSON.parseArray(pool.getOwnerId(), String.class))); + poolDTO.setCreateUserName(userMap.get(pool.getCreateUser())); + poolDTO.setUpdateUserName(userMap.get(pool.getUpdateUser())); + + var pickRule = new CustomerPoolPickRuleDTO(); + BeanUtils.copyBean(pickRule, pickRuleMap.get(pool.getId())); + var recycleRule = new CustomerPoolRecycleRuleDTO(); + CustomerPoolRecycleRule customerPoolRecycleRule = recycleRuleMap.get(pool.getId()); + BeanUtils.copyBean(recycleRule, customerPoolRecycleRule); + recycleRule.setConditions(JSON.parseArray(customerPoolRecycleRule.getCondition(), RuleConditionDTO.class)); + poolDTO.setPickRule(pickRule); + poolDTO.setRecycleRule(recycleRule); + poolDTO.setEditable(ownerIds.contains(currentUser)); + + Set hiddenFieldIds; + if (hiddenFieldMap.get(pool.getId()) != null) { + hiddenFieldIds = hiddenFieldMap.get(pool.getId()).stream() + .map(CustomerPoolHiddenField::getFieldId) + .collect(Collectors.toSet()); + } else { + hiddenFieldIds = Set.of(); + } + + poolDTO.setFieldConfigs(customerPoolService.getFieldConfigs(fields, hiddenFieldIds)); + + options.add(poolDTO); + } + }); + return options; + } + + /** + * 领取客户 + * + * @param request 请求参数 + * @param currentUser 当前用户ID + * @param currentOrgId 当前组织ID + */ + public void pick(PoolCustomerPickRequest request, String currentUser, String currentOrgId) { + CustomerPool pool = poolMapper.selectByPrimaryKey(request.getPoolId()); + validateCapacity(1, currentUser, currentOrgId); + var pickRuleWrapper = new LambdaQueryWrapper(); + pickRuleWrapper.eq(CustomerPoolPickRule::getPoolId, request.getPoolId()); + List customerPoolPickRules = pickRuleMapper.selectListByLambda(pickRuleWrapper); + CustomerPoolPickRule pickRule = customerPoolPickRules.getFirst(); + boolean poolAdmin = userExtendService.isPoolAdmin(JSON.parseArray(pool.getOwnerId(), String.class), currentUser, currentOrgId); + if (!poolAdmin) { + validateDailyPickNum(1, currentUser, pickRule); + } + ownCustomer(request.getCustomerId(), currentUser, pickRule, currentUser, LogType.PICK, currentOrgId, poolAdmin); + } + + /** + * 分配客户 + * + * @param id 客户ID + * @param assignUserId 分配用户ID + */ + public void assign(String id, String assignUserId, String currentOrgId, String currentUser) { + validateCapacity(1, assignUserId, currentOrgId); + ownCustomer(id, assignUserId, null, currentUser, LogType.ASSIGN, currentOrgId, false); + } + + /** + * 删除客户 + * + * @param id 客户ID + */ + @OperationLog(module = LogModule.CUSTOMER_POOL, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + Customer customer = customerMapper.selectByPrimaryKey(id); + CustomerService customerService = CommonBeanFactory.getBean(CustomerService.class); + Objects.requireNonNull(customerService).checkResourceRef(List.of(id)); + customerService.deleteCustomerResource(List.of(id)); + + // 设置操作对象 + OperationLogContext.setResourceName(customer.getName()); + } + + /** + * 批量领取客户 + * + * @param request 请求参数 + * @param currentUser 当前用户ID + * @param currentOrgId 当前组织ID + */ + public void batchPick(PoolBatchPickRequest request, String currentUser, String currentOrgId) { + CustomerPool pool = poolMapper.selectByPrimaryKey(request.getPoolId()); + validateCapacity(request.getBatchIds().size(), currentUser, currentOrgId); + var pickRuleWrapper = new LambdaQueryWrapper(); + pickRuleWrapper.eq(CustomerPoolPickRule::getPoolId, request.getPoolId()); + List customerPoolPickRules = pickRuleMapper.selectListByLambda(pickRuleWrapper); + CustomerPoolPickRule pickRule = customerPoolPickRules.getFirst(); + boolean poolAdmin = userExtendService.isPoolAdmin(JSON.parseArray(pool.getOwnerId(), String.class), currentUser, currentOrgId); + if (!poolAdmin) { + validateDailyPickNum(request.getBatchIds().size(), currentUser, pickRule); + } + request.getBatchIds().forEach(id -> ownCustomer(id, currentUser, pickRule, currentUser, LogType.PICK, currentOrgId, poolAdmin)); + } + + /** + * 批量分配客户 + * + * @param request 请求参数 + * @param assignUserId 分配用户ID + * @param currentOrgId 当前组织ID + */ + public void batchAssign(PoolBatchAssignRequest request, String assignUserId, String currentOrgId, String currentUser) { + validateCapacity(request.getBatchIds().size(), assignUserId, currentOrgId); + request.getBatchIds().forEach(id -> ownCustomer(id, assignUserId, null, currentUser, LogType.ASSIGN, currentOrgId, false)); + } + + /** + * 批量删除客户 + * + * @param ids 客户ID集合 + */ + public void batchDelete(List ids, String userId, String orgId) { + List customers = customerMapper.selectByIds(ids); + CustomerService customerService = CommonBeanFactory.getBean(CustomerService.class); + Objects.requireNonNull(customerService).checkResourceRef(ids); + customerService.deleteCustomerResource(ids); + + List logs = customers.stream() + .map(customer -> + new LogDTO(orgId, customer.getId(), userId, LogType.DELETE, LogModule.CUSTOMER_POOL, customer.getName()) + ) + .toList(); + logService.batchAdd(logs); + } + + /** + * 校验库容 + * + * @param processCount 处理数量 + * @param ownUserId 负责人用户ID + * @param currentOrgId 当前组织ID + */ + public void validateCapacity(int processCount, String ownUserId, String currentOrgId) { + // 实际可处理条数 = 负责人库容容量 - 所领取的数量(部分客户满足不计入条件的需排除) < 处理数量, 提示库容不足. + CustomerCapacity customerCapacity = getUserCapacity(ownUserId, currentOrgId); + if (customerCapacity == null || customerCapacity.getCapacity() == null) { + return; + } + List conditions = StringUtils.isEmpty(customerCapacity.getFilter()) ? + new ArrayList<>() : JSON.parseArray(customerCapacity.getFilter(), FilterConditionDTO.class); + conditions = conditions.stream().filter(condition -> StringUtils.isNotEmpty(condition.getColumn()) + && StringUtils.isNotEmpty(condition.getOperator()) && CollectionUtils.isNotEmpty(condition.getValue())).toList(); + int filter = 0; + if (CollectionUtils.isNotEmpty(conditions)) { + filter = (int) extCustomerMapper.filterOwnerCount(ownUserId, conditions); + } + var customerWrapper = new LambdaQueryWrapper(); + customerWrapper.eq(Customer::getOwner, ownUserId).eq(Customer::getInSharedPool, false); + int ownCount = customerMapper.selectListByLambda(customerWrapper).size(); + if (customerCapacity.getCapacity() - (ownCount - filter) < processCount) { + throw new GenericException(Translator.getWithArgs("customer.capacity.over", Math.max(customerCapacity.getCapacity() - ownCount, 0))); + } + } + + /** + * 校验每日领取数量 + * + * @param pickingCount 领取数量 + * @param ownUserId 负责人用户ID + * @param pickRule 领取规则 + */ + public void validateDailyPickNum(int pickingCount, String ownUserId, CustomerPoolPickRule pickRule) { + if (pickRule.getLimitOnNumber()) { + var customerWrapper = new LambdaQueryWrapper(); + customerWrapper + .eq(Customer::getOwner, ownUserId) + .eq(Customer::getInSharedPool, false) + .between(Customer::getCollectionTime, TimeUtils.getTodayStart(), TimeUtils.getTodayStart() + DAY_MILLIS); + List customers = customerMapper.selectListByLambda(customerWrapper); + int pickedCount = customers.size(); + if (pickingCount + pickedCount > pickRule.getPickNumber()) { + throw new GenericException(Translator.get("customer.daily.pick.over")); + } + } + } + + /** + * 获取用户库容 + * + * @param userId 用户ID + * @param organizationId 组织ID + * + * @return 库容 + */ + public CustomerCapacity getUserCapacity(String userId, String organizationId) { + List scopeIds = userExtendService.getUserScopeIds(userId, organizationId); + return extCustomerCapacityMapper.getCapacityByScopeIds(scopeIds, organizationId); + } + + /** + * 拥有客户 + * + * @param customerId 客户ID + * @param ownerId 拥有人ID + */ + private void ownCustomer(String customerId, String ownerId, CustomerPoolPickRule pickRule, + String operateUserId, String logType, String currentOrgId, boolean isPoolAdmin) { + + Customer customer = customerMapper.selectByPrimaryKey(customerId); + if (customer == null) { + throw new IllegalArgumentException(Translator.get("customer.not.exist")); + } + if (!customer.getInSharedPool()) { + throw new GenericException(Translator.getWithArgs("customer.pool.occupied", customer.getName())); + } + + if (!isPoolAdmin && pickRule != null) { + if (pickRule.getLimitNew()) { + LocalDateTime joinPoolTime = Instant.ofEpochMilli(customer.getUpdateTime()) + .atZone(ZoneId.systemDefault()).toLocalDateTime(); + LocalDateTime releaseDate = joinPoolTime.plusDays(pickRule.getNewPickInterval()); + if (releaseDate.isAfter(LocalDateTime.now())) { + throw new GenericException(Translator.getWithArgs( + "pool.data.release.date", + releaseDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + )); + } + } + + if (pickRule.getLimitPreOwner()) { + List customerOwners = ownerMapper.selectListByLambda( + new LambdaQueryWrapper().eq(CustomerOwner::getCustomerId, customerId) + ); + if (CollectionUtils.isNotEmpty(customerOwners)) { + CustomerOwner lastOwner = customerOwners.stream() + .max(Comparator.comparingLong(CustomerOwner::getCollectionTime)) + .orElse(null); + if (lastOwner != null && Strings.CS.equals(lastOwner.getOwner(), ownerId)) { + long nextPickMillis = lastOwner.getEndTime() + + pickRule.getPickIntervalDays() * DAY_MILLIS; + if (System.currentTimeMillis() < nextPickMillis) { + LocalDateTime nextPickTime = Instant.ofEpochMilli(nextPickMillis) + .atZone(ZoneId.systemDefault()).toLocalDateTime(); + throw new GenericException(Translator.getWithArgs( + "customer.pre_owner.pick.limit", + nextPickTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + )); + } + } + } + } + } + + long now = System.currentTimeMillis(); + customer.setPoolId(null); + customer.setInSharedPool(false); + customer.setOwner(ownerId); + customer.setCollectionTime(now); + customer.setUpdateUser(ownerId); + customer.setUpdateTime(now); + extCustomerMapper.updateIncludeNullById(customer); + + // 只更新最近一次销售负责人的联系人(联系人为空的) + String recentOwner = extCustomerOwnerMapper.getRecentOwner(customerId); + customerContactService.updatePoolContactOwner(customerId, ownerId, recentOwner, currentOrgId); + + logService.add(new LogDTO(currentOrgId, customer.getId(), operateUserId, logType, + LogModule.CUSTOMER_POOL, customer.getName())); + + if (Strings.CS.equals(logType, LogType.ASSIGN)) { + commonNoticeSendService.sendNotice( + NotificationConstants.Module.CUSTOMER, + NotificationConstants.Event.HIGH_SEAS_CUSTOMER_DISTRIBUTED, + customer.getName(), operateUserId, currentOrgId, + List.of(ownerId), true + ); + } + } + + public void batchUpdate(ResourceBatchEditRequest request, String userId, String organizationId) { + BaseField field = customerFieldService.getAndCheckField(request.getFieldId(), organizationId); + + if (Strings.CS.equals(field.getBusinessKey(), BusinessModuleField.CUSTOMER_OWNER.getBusinessKey())) { + // 修改负责人,走批量分配的接口 + var batchAssignRequest = new PoolBatchAssignRequest(); + batchAssignRequest.setBatchIds(request.getIds()); + batchAssignRequest.setAssignUserId(request.getFieldValue().toString()); + batchAssign(batchAssignRequest, batchAssignRequest.getAssignUserId(), organizationId, userId); + return; + } + + List originCustomers = customerMapper.selectByIds(request.getIds()); + + customerFieldService.batchUpdate(request, field, originCustomers, Customer.class, LogModule.CUSTOMER_POOL, extCustomerMapper::batchUpdate, userId, organizationId); + } + + public List chart(PoolCustomerChartAnalysisRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { + ModuleFormConfigDTO formConfig = Objects.requireNonNull(CommonBeanFactory.getBean(CustomerService.class)).getFormConfig(orgId); + formConfig.getFields().addAll(BaseChartService.getChartBaseFields()); + ChartAnalysisDbRequest chartAnalysisDbRequest = ConditionFilterUtils.parseChartAnalysisRequest(request, formConfig); + CustomerChartAnalysisDbRequest customerChartAnalysisDbRequest = BeanUtils.copyBean(new CustomerChartAnalysisDbRequest(), chartAnalysisDbRequest); + customerChartAnalysisDbRequest.setPoolId(request.getPoolId()); + List chartResults = extCustomerMapper.chart(customerChartAnalysisDbRequest, userId, orgId, deptDataPermission); + return baseChartService.translateAxisName(formConfig, chartAnalysisDbRequest, chartResults); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/RoleLogService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/RoleLogService.java new file mode 100644 index 0000000..12d5ab5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/RoleLogService.java @@ -0,0 +1,144 @@ +package cn.cordys.crm.customer.service; + +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.permission.Permission; +import cn.cordys.common.permission.PermissionDefinitionItem; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.domain.Department; +import cn.cordys.crm.system.service.BaseModuleLogService; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.crm.system.service.RoleService; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class RoleLogService extends BaseModuleLogService { + + @Resource + private DepartmentService departmentService; + @Resource + private RoleService roleService; + + @Override + public List handleLogField(List differences, String orgId) { + differences.forEach(differ -> { + if (Strings.CS.equals(differ.getColumn(), "deptIds")) { + handleDeptIdsLogDetail(differ); + } else if (Strings.CS.equals(differ.getColumn(), "permissions")) { + handlePermissionSettingLogDetail(differ); + } else if (Strings.CS.equals(differ.getColumn(), "dataScope")) { + if (differ.getOldValue() != null) { + differ.setOldValueName(Translator.get("role.data_permission." + differ.getOldValue().toString().toLowerCase())); + } + if (differ.getNewValue() != null) { + differ.setNewValueName(Translator.get("role.data_permission." + differ.getNewValue().toString().toLowerCase())); + } + differ.setColumnName(Translator.get("log.dataScope")); + } else { + BaseModuleLogService.translatorDifferInfo(differ); + } + }); + return differences; + } + + private void handleDeptIdsLogDetail(JsonDifferenceDTO differ) { + var oldValue = differ.getOldValue(); + var newValue = differ.getNewValue(); + var deptNameMap = getDeptNameMap(oldValue, newValue); + + if (oldValue instanceof List originDeptIds) { + var deptNames = originDeptIds.stream() + .map(id -> deptNameMap.get(String.valueOf(id))) + .filter(Objects::nonNull) + .toList(); + differ.setOldValueName(deptNames); + } + if (newValue instanceof List newDeptIds) { + var deptNames = newDeptIds.stream() + .map(id -> deptNameMap.get(String.valueOf(id))) + .filter(Objects::nonNull) + .toList(); + differ.setNewValueName(deptNames); + } + differ.setColumnName(Translator.get("role.log.dept.name")); + } + + private void handlePermissionSettingLogDetail(JsonDifferenceDTO differ) { + var oldValue = differ.getOldValue(); + var originPermissionSet = new HashSet(); + if (oldValue instanceof List originPermissionIds) { + originPermissionIds.forEach(id -> originPermissionSet.add(String.valueOf(id))); + } + var newValue = differ.getNewValue(); + var newPermissionSet = new HashSet(); + if (newValue instanceof List newPermissionIds) { + newPermissionIds.forEach(id -> newPermissionSet.add(String.valueOf(id))); + } + + var originPermissionMap = new HashMap>(); + var newPermissionMap = new HashMap>(); + var permissionDefinitions = roleService.getPermissionDefinitions(); + for (PermissionDefinitionItem permissionDefinition : permissionDefinitions) { + for (PermissionDefinitionItem resourceItem : permissionDefinition.getChildren()) { + var resourceName = Translator.get(resourceItem.getName()); + var originPermissionNames = new ArrayList(); + var newPermissionNames = new ArrayList(); + for (Permission permissionItem : resourceItem.getPermissions()) { + if (originPermissionSet.contains(permissionItem.getId())) { + originPermissionNames.add(translatePermissionName(permissionItem)); + } + if (newPermissionSet.contains(permissionItem.getId())) { + newPermissionNames.add(translatePermissionName(permissionItem)); + } + } + if (!originPermissionNames.isEmpty()) { + originPermissionMap.put(resourceName, originPermissionNames); + } + if (!newPermissionNames.isEmpty()) { + newPermissionMap.put(resourceName, newPermissionNames); + } + } + } + + differ.setOldValueName(getPermissionColumnName(originPermissionMap)); + differ.setNewValueName(getPermissionColumnName(newPermissionMap)); + + differ.setColumnName(Translator.get("role.log.permission.name")); + } + + private String getPermissionColumnName(Map> permissionMap) { + var sb = new StringBuilder(); + permissionMap.forEach((resource, names) -> { + sb.append(resource).append(": ").append(String.join(",", names)).append("\n"); + }); + return sb.toString(); + } + + private String translatePermissionName(Permission permissionItem) { + if (StringUtils.isNotBlank(permissionItem.getName())) { + return Translator.get(permissionItem.getName()); + } else { + return roleService.translateDefaultPermissionName(permissionItem.getId()); + } + } + + private Map getDeptNameMap(Object oldValue, Object newValue) { + var deptIds = new ArrayList(); + if (oldValue instanceof List originDeptIds) { + originDeptIds.forEach(id -> deptIds.add(String.valueOf(id))); + } + if (newValue instanceof List newDeptIds) { + newDeptIds.forEach(id -> deptIds.add(String.valueOf(id))); + } + return departmentService.getDepartmentOptionsByIds(deptIds) + .stream() + .collect(Collectors.toMap(Department::getId, Department::getName)); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/utils/CustomerContactFieldUtils.java b/backend/crm/src/main/java/cn/cordys/crm/customer/utils/CustomerContactFieldUtils.java new file mode 100644 index 0000000..671981e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/utils/CustomerContactFieldUtils.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.customer.utils; + +import cn.cordys.common.util.TimeUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.customer.dto.response.CustomerContactListResponse; + +import java.util.LinkedHashMap; + +public class CustomerContactFieldUtils { + + public static LinkedHashMap getSystemFieldMap(CustomerContactListResponse data) { + LinkedHashMap systemFieldMap = new LinkedHashMap<>(); + systemFieldMap.put("customerId", data.getCustomerName()); + systemFieldMap.put("name", data.getName()); + systemFieldMap.put("phone", data.getPhone()); + systemFieldMap.put("owner", data.getOwnerName()); + systemFieldMap.put("enable", Translator.get("log.enable." + data.getEnable())); + systemFieldMap.put("disableReason", data.getDisableReason()); + systemFieldMap.put("departmentId", data.getDepartmentName()); + + systemFieldMap.put("createUser", data.getCreateUserName()); + systemFieldMap.put("createTime", TimeUtils.getDateTimeStr(data.getCreateTime())); + systemFieldMap.put("updateUser", data.getUpdateUserName()); + systemFieldMap.put("updateTime", TimeUtils.getDateTimeStr(data.getUpdateTime())); + return systemFieldMap; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/utils/CustomerFieldUtils.java b/backend/crm/src/main/java/cn/cordys/crm/customer/utils/CustomerFieldUtils.java new file mode 100644 index 0000000..b1f665a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/utils/CustomerFieldUtils.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.customer.utils; + +import cn.cordys.common.util.TimeUtils; +import cn.cordys.crm.customer.dto.response.CustomerListResponse; + +import java.util.LinkedHashMap; + +public class CustomerFieldUtils { + + public static LinkedHashMap getSystemFieldMap(CustomerListResponse data) { + LinkedHashMap systemFieldMap = new LinkedHashMap<>(); + systemFieldMap.put("name", data.getName()); + systemFieldMap.put("owner", data.getOwnerName()); + systemFieldMap.put("collectionTime", TimeUtils.getDateTimeStr(data.getCollectionTime())); + systemFieldMap.put("createUser", data.getCreateUserName()); + systemFieldMap.put("createTime", TimeUtils.getDateTimeStr(data.getCreateTime())); + systemFieldMap.put("updateUser", data.getUpdateUserName()); + systemFieldMap.put("updateTime", TimeUtils.getDateTimeStr(data.getUpdateTime())); + systemFieldMap.put("follower", data.getFollowerName()); + systemFieldMap.put("followTime", TimeUtils.getDateTimeStr(data.getFollowTime())); + systemFieldMap.put("reservedDays", data.getReservedDays()); + systemFieldMap.put("recyclePoolName", data.getRecyclePoolName()); + systemFieldMap.put("departmentId", data.getDepartmentName()); + return systemFieldMap; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/utils/PoolCustomerFieldUtils.java b/backend/crm/src/main/java/cn/cordys/crm/customer/utils/PoolCustomerFieldUtils.java new file mode 100644 index 0000000..a0a60fb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/utils/PoolCustomerFieldUtils.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.customer.utils; + +import cn.cordys.crm.customer.dto.response.CustomerListResponse; + +import java.util.LinkedHashMap; + +public class PoolCustomerFieldUtils { + + public static LinkedHashMap getSystemFieldMap(CustomerListResponse data) { + LinkedHashMap systemFieldMap = CustomerFieldUtils.getSystemFieldMap(data); + systemFieldMap.put("reasonId", data.getReasonName()); + return systemFieldMap; + } + +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/controller/DashboardController.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/controller/DashboardController.java new file mode 100644 index 0000000..bc1ce74 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/controller/DashboardController.java @@ -0,0 +1,112 @@ +package cn.cordys.crm.dashboard.controller; + + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.dashboard.domain.Dashboard; +import cn.cordys.crm.dashboard.dto.request.*; +import cn.cordys.crm.dashboard.dto.response.DashboardDetailResponse; +import cn.cordys.crm.dashboard.dto.response.DashboardPageResponse; +import cn.cordys.crm.dashboard.service.DashboardService; +import cn.cordys.security.SessionUtils; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "仪表板") +@RestController +@RequestMapping("/dashboard") +public class DashboardController { + + @Resource + private DashboardService dashboardService; + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.DASHBOARD_ADD) + @Operation(summary = "仪表板-添加") + public Dashboard addDashboard(@Validated @RequestBody DashboardAddRequest request) { + return dashboardService.addDashboard(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + + @GetMapping("/detail/{id}") + @Operation(summary = "仪表板-详情") + @RequiresPermissions(PermissionConstants.DASHBOARD_READ) + public DashboardDetailResponse getDashboardDetail(@PathVariable String id) { + return dashboardService.getDashboardDetail(id); + } + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.DASHBOARD_EDIT) + @Operation(summary = "仪表板-更新") + public void updateDashboard(@Validated @RequestBody DashboardUpdateRequest request) { + dashboardService.updateDashboard(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + + @PostMapping("/rename") + @RequiresPermissions(PermissionConstants.DASHBOARD_EDIT) + @Operation(summary = "仪表板-重命名") + public void rename(@Validated @RequestBody DashboardRenameRequest request) { + dashboardService.rename(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.DASHBOARD_DELETE) + @Operation(summary = "仪表板-删除") + public void delete(@PathVariable String id) { + dashboardService.delete(id); + } + + + @PostMapping("/page") + @RequiresPermissions(PermissionConstants.DASHBOARD_READ) + @Operation(summary = "仪表板列表") + public Pager> list(@Validated @RequestBody DashboardPageRequest request) { + return dashboardService.getList(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/collect/{id}") + @RequiresPermissions(PermissionConstants.DASHBOARD_READ) + @Operation(summary = "仪表板收藏") + public void collect(@PathVariable String id) { + dashboardService.collect(id, SessionUtils.getUserId()); + } + + + @GetMapping("/un-collect/{id}") + @RequiresPermissions(PermissionConstants.DASHBOARD_READ) + @Operation(summary = "仪表板取消收藏") + public void unCollect(@PathVariable String id) { + dashboardService.unCollect(id, SessionUtils.getUserId()); + } + + + @PostMapping("/collect/page") + @RequiresPermissions(PermissionConstants.DASHBOARD_READ) + @Operation(summary = "仪表板收藏列表") + public Pager> collectPage(@Validated @RequestBody BasePageRequest request) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, dashboardService.collectList(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId())); + } + + @PostMapping("/edit/pos") + @Operation(summary = "仪表板-拖拽排序") + @RequiresPermissions(PermissionConstants.DASHBOARD_EDIT) + public void editPos(@Validated @RequestBody DashboardEditPosRequest request) { + dashboardService.editPos(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/controller/DashboardModuleController.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/controller/DashboardModuleController.java new file mode 100644 index 0000000..20f3aa3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/controller/DashboardModuleController.java @@ -0,0 +1,78 @@ +package cn.cordys.crm.dashboard.controller; + + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.dashboard.domain.DashboardModule; +import cn.cordys.crm.dashboard.dto.DashboardTreeNode; +import cn.cordys.crm.dashboard.dto.request.DashboardModuleAddRequest; +import cn.cordys.crm.dashboard.dto.request.DashboardModuleRenameRequest; +import cn.cordys.crm.dashboard.service.DashboardModuleService; +import cn.cordys.crm.system.dto.request.NodeMoveRequest; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.NotEmpty; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@Tag(name = "仪表板模块") +@RestController +@RequestMapping("/dashboard/module") +public class DashboardModuleController { + + @Resource + private DashboardModuleService dashboardModuleService; + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.DASHBOARD_ADD) + @Operation(summary = "仪表板-添加文件夹") + public DashboardModule addFileModule(@Validated @RequestBody DashboardModuleAddRequest request) { + return dashboardModuleService.addFileModule(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + + @PostMapping("/rename") + @RequiresPermissions(PermissionConstants.DASHBOARD_EDIT) + @Operation(summary = "仪表板-重命名文件夹") + public void rename(@Validated @RequestBody DashboardModuleRenameRequest request) { + dashboardModuleService.rename(request, SessionUtils.getUserId()); + } + + + @PostMapping("/delete") + @RequiresPermissions(PermissionConstants.DASHBOARD_DELETE) + @Operation(summary = "仪表板-刪除文件夹") + public void deleteDashboardModule(@RequestBody @NotEmpty List ids) { + dashboardModuleService.delete(ids, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/tree") + @Operation(summary = "仪表板-文件树查询") + @RequiresPermissions(PermissionConstants.DASHBOARD_READ) + public List getTree() { + return dashboardModuleService.getTree(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/count") + @Operation(summary = "仪表板-文件树数量") + @RequiresPermissions(PermissionConstants.DASHBOARD_READ) + public Map moduleCount() { + return dashboardModuleService.moduleCount(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/move") + @Operation(summary = "仪表板-文件夹移动") + @RequiresPermissions(PermissionConstants.DASHBOARD_EDIT) + public void moveNode(@Validated @RequestBody NodeMoveRequest request) { + dashboardModuleService.moveNode(request, SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/domain/Dashboard.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/domain/Dashboard.java new file mode 100644 index 0000000..f7e184d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/domain/Dashboard.java @@ -0,0 +1,33 @@ +package cn.cordys.crm.dashboard.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "dashboard") +public class Dashboard extends BaseModel { + + @Schema(description = "名称") + private String name; + + @Schema(description = "仪表板url") + private String resourceUrl; + + @Schema(description = "模块id") + private String dashboardModuleId; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "同一节点下顺序") + private Long pos; + + @Schema(description = "范围") + private String scopeId; + + @Schema(description = "描述") + private String description; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/domain/DashboardCollection.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/domain/DashboardCollection.java new file mode 100644 index 0000000..4e871b6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/domain/DashboardCollection.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.dashboard.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "dashboard_collection") +public class DashboardCollection extends BaseModel { + + @Schema(description = "用户id") + private String userId; + + @Schema(description = "仪表板id") + private String dashboardId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/domain/DashboardModule.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/domain/DashboardModule.java new file mode 100644 index 0000000..01ac69a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/domain/DashboardModule.java @@ -0,0 +1,24 @@ +package cn.cordys.crm.dashboard.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "dashboard_module") +public class DashboardModule extends BaseModel { + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "名称") + private String name; + + @Schema(description = "父节点id") + private String parentId; + + @Schema(description = "同一节点下顺序") + private Long pos; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/DashboardLogDTO.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/DashboardLogDTO.java new file mode 100644 index 0000000..5e57752 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/DashboardLogDTO.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.dashboard.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class DashboardLogDTO { + + @Schema(description = "名称") + private String name; + + @Schema(description = "仪表板id") + private String resourceId; + + @Schema(description = "文件夹名称") + private String dashboardModuleName; + + @Schema(description = "可查看人员集合") + private List members; + + @Schema(description = "描述") + private String description; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/DashboardTreeNode.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/DashboardTreeNode.java new file mode 100644 index 0000000..4b0e851 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/DashboardTreeNode.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.dashboard.dto; + +import cn.cordys.common.dto.BaseTreeNode; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class DashboardTreeNode extends BaseTreeNode { + + @Schema(description = "节点类型, 如:仪表板文件夹, 仪表板") + private String type; + + @Schema(description = "是否收藏") + private boolean myCollect = false; + + @Schema(description = "仪表板url") + private String resourceUrl; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/DropNode.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/DropNode.java new file mode 100644 index 0000000..3f66496 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/DropNode.java @@ -0,0 +1,9 @@ +package cn.cordys.crm.dashboard.dto; + +import lombok.Data; + +@Data +public class DropNode { + private String id; + private long pos; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/MoveNodeSortDTO.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/MoveNodeSortDTO.java new file mode 100644 index 0000000..4e97db4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/MoveNodeSortDTO.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.dashboard.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class MoveNodeSortDTO { + @Schema(description = "排序范围ID") + private String sortRangeId; + + @Schema(description = "要排序的节点") + private DropNode sortNode; + + @Schema(description = "前一个节点") + private DropNode previousNode; + + @Schema(description = "后一个节点") + private DropNode nextNode; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardAddRequest.java new file mode 100644 index 0000000..1cace58 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardAddRequest.java @@ -0,0 +1,36 @@ +package cn.cordys.crm.dashboard.dto.request; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class DashboardAddRequest { + + @Size(min = 1, max = 255, message = "{department.name.length_range}", groups = {Created.class, Updated.class}) + @NotBlank(message = "{dashboard.name.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "仪表板名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Schema(description = "仪表板url") + @NotBlank(message = "{dashboard.parent.id.not_blank}") + private String resourceUrl; + + @Schema(description = "文件夹id") + @NotBlank(message = "{dashboard.parent.id.not_blank}") + private String dashboardModuleId; + + @NotNull + @Schema(description = "范围ID集合") + private List scopeIds; + + @Schema(description = "描述") + private String description; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardEditPosRequest.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardEditPosRequest.java new file mode 100644 index 0000000..283d828 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardEditPosRequest.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.dashboard.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class DashboardEditPosRequest { + + @Schema(description = "文件夹id") + private String dashboardModuleId; + + @Schema(description = "移动仪表板id", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{source_id.not_blank}") + private String moveId; + + @Schema(description = "目标仪表板id", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{target_id.not_blank}") + private String targetId; + + @Schema(description = "移动类型", requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"BEFORE", "AFTER", "APPEND"}) + @NotBlank(message = "{moveMode.not_blank}") + private String moveMode; + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardModuleAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardModuleAddRequest.java new file mode 100644 index 0000000..abc3593 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardModuleAddRequest.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.dashboard.dto.request; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class DashboardModuleAddRequest { + + @Size(min = 1, max = 255, message = "{department.name.length_range}", groups = {Created.class, Updated.class}) + @NotBlank(message = "{dashboard_module.name.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + + @Schema(description = "父级id") + @NotBlank(message = "{dashboard_module.parent.id.not_blank}") + private String parentId; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardModuleRenameRequest.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardModuleRenameRequest.java new file mode 100644 index 0000000..1376601 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardModuleRenameRequest.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.dashboard.dto.request; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class DashboardModuleRenameRequest { + + @NotBlank(message = "{dashboardModule.id.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Size(min = 1, max = 255, message = "{department.name.length_range}", groups = {Created.class, Updated.class}) + @NotBlank(message = "{dashboardModule.name.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardPageRequest.java new file mode 100644 index 0000000..9840d66 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardPageRequest.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.dashboard.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class DashboardPageRequest extends BasePageRequest { + + @Schema(description = "文件ids") + private List dashboardModuleIds; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardRenameRequest.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardRenameRequest.java new file mode 100644 index 0000000..c1a7b7e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardRenameRequest.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.dashboard.dto.request; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class DashboardRenameRequest { + + @NotBlank(message = "{dashboard.id.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Size(min = 1, max = 255, message = "{department.name.length_range}", groups = {Created.class, Updated.class}) + @NotBlank(message = "{dashboard.name.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @NotBlank(message = "{dashboardModule.id.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "当前文件夹id", requiredMode = Schema.RequiredMode.REQUIRED) + private String dashboardModuleId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardUpdateRequest.java new file mode 100644 index 0000000..1b578fc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/request/DashboardUpdateRequest.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.dashboard.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class DashboardUpdateRequest extends DashboardAddRequest { + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + private String id; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/response/DashboardDetailResponse.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/response/DashboardDetailResponse.java new file mode 100644 index 0000000..15d64bc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/response/DashboardDetailResponse.java @@ -0,0 +1,36 @@ +package cn.cordys.crm.dashboard.dto.response; + +import cn.cordys.crm.system.dto.ScopeNameDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + + +@Data +public class DashboardDetailResponse { + + @Schema(description = "id") + private String id; + + @Schema(description = "仪表板名称") + private String name; + + @Schema(description = "仪表板url") + private String resourceUrl; + + @Schema(description = "文件夹id") + private String dashboardModuleId; + + @Schema(description = "文件夹名称") + private String dashboardModuleName; + + @Schema(description = "可查看人员id") + private String scopeId; + + @Schema(description = "可查看人员集合") + private List members; + + @Schema(description = "描述") + private String description; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/response/DashboardPageResponse.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/response/DashboardPageResponse.java new file mode 100644 index 0000000..8414465 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/dto/response/DashboardPageResponse.java @@ -0,0 +1,62 @@ +package cn.cordys.crm.dashboard.dto.response; + +import cn.cordys.crm.system.dto.ScopeNameDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class DashboardPageResponse { + + @Schema(description = "id") + private String id; + + @Schema(description = "名称") + private String name; + + @Schema(description = "仪表板url") + private String resourceUrl; + + @Schema(description = "文件夹id") + private String dashboardModuleId; + + @Schema(description = "文件夹名称") + private String dashboardModuleName; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "同一节点下顺序") + private Long pos; + + @Schema(description = "范围") + private String scopeId; + + @Schema(description = "可查看人员集合") + private List members; + + @Schema(description = "描述") + private String description; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "修改人名称") + private String updateUserName; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "是否收藏") + private boolean myCollect = false; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardCollectionMapper.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardCollectionMapper.java new file mode 100644 index 0000000..aa07fe1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardCollectionMapper.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.dashboard.mapper; + +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.crm.dashboard.dto.response.DashboardPageResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtDashboardCollectionMapper { + + List collectList(@Param("request") BasePageRequest request, @Param("userId") String userId, @Param("orgId") String orgId); + + void deleteByDashboardId(@Param("dashboardId") String dashboardId); + + void unCollect(@Param("dashboardId") String dashboardId, @Param("userId") String userId); + + int countMyCollect(@Param("userId") String userId); + + List getByUserId(@Param("userId") String userId); + + int checkCollect(@Param("id") String id, @Param("userId") String userId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardCollectionMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardCollectionMapper.xml new file mode 100644 index 0000000..558249b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardCollectionMapper.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + order by + + + d.`create_time` ${sortType} + + + order by d.create_time desc + + + + + order by d.create_time desc + + + + + + delete from dashboard_collection + where dashboard_id = #{dashboardId} + + + + + delete from dashboard_collection + where dashboard_id = #{dashboardId} + and user_id =#{userId} + + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardMapper.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardMapper.java new file mode 100644 index 0000000..24deebb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardMapper.java @@ -0,0 +1,33 @@ +package cn.cordys.crm.dashboard.mapper; + + +import cn.cordys.common.dto.NodeSortQueryParam; +import cn.cordys.crm.dashboard.dto.DashboardTreeNode; +import cn.cordys.crm.dashboard.dto.DropNode; +import cn.cordys.crm.dashboard.dto.request.DashboardPageRequest; +import cn.cordys.crm.dashboard.dto.response.DashboardDetailResponse; +import cn.cordys.crm.dashboard.dto.response.DashboardPageResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtDashboardMapper { + + int countByName(@Param("name") String name, @Param("dashboardModuleId") String dashboardModuleId, @Param("orgId") String orgId, @Param("id") String id); + + DashboardDetailResponse getDetail(@Param("id") String id); + + Long getNextPosByOrgId(@Param("orgId") String orgId); + + List selectDashboardNode(@Param("departmentIds") List departmentIds, @Param("orgId") String orgId, @Param("userId") String userId); + + List list(@Param("request") DashboardPageRequest request, @Param("userId") String userId, @Param("orgId") String orgId, @Param("departmentIds") List departmentIds); + + DropNode selectDragInfoById(@Param("dragNodeId") String dragNodeId); + + DropNode selectNodeByPosOperator(@Param("nodeSortQueryParam") NodeSortQueryParam nodeSortQueryParam); + + void updatePos(@Param("id") String id, @Param("pos") long pos); + + List selectIdByOrgIdOrderByPos(@Param("orgId") String orgId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardMapper.xml new file mode 100644 index 0000000..8f6779c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardMapper.xml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + order by + + + d.`create_time` ${sortType} + + + d.`name` ${sortType} + + + d.`dashboard_module_id` ${sortType} + + + d.`create_user` ${sortType} + + + order by d.create_time desc + + + + + order by d.create_time desc + + + + + + + + + + + + + update dashboard + set pos =#{pos} + where id = #{id} + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardModuleMapper.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardModuleMapper.java new file mode 100644 index 0000000..18d9e30 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardModuleMapper.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.dashboard.mapper; + +import cn.cordys.common.dto.BaseTree; +import cn.cordys.common.dto.NodeSortQueryParam; +import cn.cordys.crm.dashboard.domain.DashboardModule; +import cn.cordys.crm.dashboard.dto.DashboardTreeNode; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtDashboardModuleMapper { + + + int countByName(@Param("name") String name, @Param("parentId") String parentId, @Param("orgId") String orgId, @Param("id") String id); + + Long getNextPosByOrgId(@Param("orgId") String orgId); + + int countDefaultModuleByIds(@Param("ids") List ids, @Param("orgId") String orgId); + + int countDashboardByIds(@Param("dashboardModuleIds") List dashboardModuleIds, @Param("orgId") String orgId); + + List selectTreeNode(@Param("orgId") String orgId); + + List selectChildrenIds(@Param("parentId") String parentId); + + void batchUpdate(@Param("dashboardModules") List dashboardModules); + + BaseTree selectBaseModuleById(@Param("dragNodeId") String dragNodeId); + + BaseTree selectModuleByParentIdAndPosOperator(@Param("nodeSortQueryParam") NodeSortQueryParam nodeSortQueryParam); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardModuleMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardModuleMapper.xml new file mode 100644 index 0000000..d854bac --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/mapper/ExtDashboardModuleMapper.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + UPDATE dashboard_module + SET pos = CASE + + WHEN id = #{dashboardModule.id} THEN #{dashboardModule.pos} + + END + WHERE id IN + + #{dashboardModule.id} + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/service/DashboardModuleService.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/service/DashboardModuleService.java new file mode 100644 index 0000000..ced6cc8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/service/DashboardModuleService.java @@ -0,0 +1,366 @@ +package cn.cordys.crm.dashboard.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.NodeSortDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.NodeSortUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.dashboard.domain.DashboardModule; +import cn.cordys.crm.dashboard.dto.DashboardTreeNode; +import cn.cordys.crm.dashboard.dto.request.DashboardModuleAddRequest; +import cn.cordys.crm.dashboard.dto.request.DashboardModuleRenameRequest; +import cn.cordys.crm.dashboard.mapper.ExtDashboardCollectionMapper; +import cn.cordys.crm.dashboard.mapper.ExtDashboardMapper; +import cn.cordys.crm.dashboard.mapper.ExtDashboardModuleMapper; +import cn.cordys.crm.system.dto.request.NodeMoveRequest; +import cn.cordys.crm.system.mapper.ExtOrganizationUserMapper; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.MoveNodeService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class DashboardModuleService extends MoveNodeService { + + @Resource + private ExtDashboardModuleMapper extDashboardModuleMapper; + @Resource + private BaseMapper dashboardModuleMapper; + @Resource + private LogService logService; + @Resource + private ExtDashboardMapper extDashboardMapper; + @Resource + private ExtDashboardCollectionMapper extDashboardCollectionMapper; + @Resource + private ExtOrganizationUserMapper extOrganizationUserMapper; + @Resource + private DepartmentService departmentService; + + private static void buildIdNodeMap(List nodeList, Map idNodeMap) { + for (BaseTreeNode node : nodeList) { + idNodeMap.put(node.getId(), node); + if (CollectionUtils.isNotEmpty(node.getChildren())) { + buildIdNodeMap(node.getChildren(), idNodeMap); + } + } + } + + /** + * 添加文件夹 + * + * @param request + * @param orgId + * @param userId + * + * @return + */ + @OperationLog(module = LogModule.DASHBOARD, type = LogType.ADD) + public DashboardModule addFileModule(DashboardModuleAddRequest request, String orgId, String userId) { + //同一层级文件名称唯一 + checkFileName(request.getName(), request.getParentId(), orgId, null); + String id = IDGenerator.nextStr(); + DashboardModule dashboardModule = new DashboardModule(); + dashboardModule.setId(id); + dashboardModule.setName(request.getName()); + dashboardModule.setParentId(request.getParentId()); + dashboardModule.setOrganizationId(orgId); + dashboardModule.setPos(getNextPos(orgId)); + dashboardModule.setCreateTime(System.currentTimeMillis()); + dashboardModule.setUpdateTime(System.currentTimeMillis()); + dashboardModule.setCreateUser(userId); + dashboardModule.setUpdateUser(userId); + dashboardModuleMapper.insert(dashboardModule); + + //日志 + OperationLogContext.setContext(LogContextInfo.builder() + .modifiedValue(dashboardModule) + .resourceId(id) + .resourceName(dashboardModule.getName()) + .build()); + return dashboardModule; + } + + public Long getNextPos(String orgId) { + Long pos = extDashboardModuleMapper.getNextPosByOrgId(orgId); + return (pos == null ? 0 : pos) + NodeSortUtils.DEFAULT_NODE_INTERVAL_POS; + } + + /** + * 文件名重复校验 + * + * @param name + * @param parentId + * @param orgId + * @param id + */ + private void checkFileName(String name, String parentId, String orgId, String id) { + if (extDashboardModuleMapper.countByName(name, parentId, orgId, id) > 0) { + throw new GenericException(Translator.get("dashboard_module_name_exist")); + } + } + + /** + * 重命名文件夹 + * + * @param request + * @param userId + */ + @OperationLog(module = LogModule.DASHBOARD, type = LogType.UPDATE) + public void rename(DashboardModuleRenameRequest request, String userId) { + DashboardModule originalDashboardModule = checkDashboardModule(request.getId()); + checkFileName(request.getName(), originalDashboardModule.getParentId(), originalDashboardModule.getOrganizationId(), originalDashboardModule.getId()); + + DashboardModule dashboardModule = BeanUtils.copyBean(new DashboardModule(), request); + dashboardModule.setUpdateTime(System.currentTimeMillis()); + dashboardModule.setName(request.getName()); + dashboardModule.setUpdateUser(userId); + dashboardModuleMapper.update(dashboardModule); + + // 添加日志上下文 + String resourceName = Optional.ofNullable(dashboardModule.getName()).orElse(originalDashboardModule.getName()); + OperationLogContext.setContext( + LogContextInfo.builder() + .originalValue(originalDashboardModule) + .modifiedValue(checkDashboardModule(request.getId())) + .resourceId(request.getId()) + .resourceName(resourceName) + .build() + ); + } + + public DashboardModule checkDashboardModule(String id) { + DashboardModule dashboardModule = dashboardModuleMapper.selectByPrimaryKey(id); + if (dashboardModule == null) { + throw new GenericException(Translator.get("dashboard_module_blank")); + } + return dashboardModule; + } + + /** + * 删除文件夹 + * + * @param ids + * @param userId + * @param orgId + */ + public void delete(List ids, String userId, String orgId) { + deleteCheck(ids, orgId); + List dashboardModules = dashboardModuleMapper.selectByIds(ids); + //刪除文件夹 + dashboardModuleMapper.deleteByIds(ids); + List logs = new ArrayList<>(); + // 添加日志上下文 + dashboardModules.forEach(dashboardModule -> { + LogDTO logDTO = new LogDTO(dashboardModule.getOrganizationId(), dashboardModule.getId(), userId, LogType.DELETE, LogModule.DASHBOARD, dashboardModule.getName()); + logDTO.setOriginalValue(dashboardModule); + logs.add(logDTO); + }); + logService.batchAdd(logs); + } + + /** + * 删除校验 + * + * @param ids + * @param orgId + */ + private void deleteCheck(List ids, String orgId) { + if (extDashboardModuleMapper.countDashboardByIds(ids, orgId) > 0) { + throw new GenericException(Translator.get("dashboard_module_cannot_delete")); + } + } + + /** + * 仪表板树结构 + * + * @param userId + * @param orgId + * + * @return + */ + public List getTree(String userId, String orgId) { + List moduleNode = extDashboardModuleMapper.selectTreeNode(orgId); + //查询模块下仪表板数据 + List departmentIds = getDepartmentIds(userId, orgId); + List dashboardNode = extDashboardMapper.selectDashboardNode(departmentIds, orgId, userId); + + Set myCollects = new HashSet<>(extDashboardCollectionMapper.getByUserId(userId)); + dashboardNode.forEach(node -> { + if (myCollects.contains(node.getId())) { + node.setMyCollect(true); + } + }); + + dashboardNode.addAll(moduleNode); + + return DashboardTreeNode.buildTree(dashboardNode); + } + + private List getDepartmentIds(String userId, String orgId) { + List departmentIds = new ArrayList<>(); + if (!Strings.CI.equals(userId, InternalUser.ADMIN.getValue())) { + String departmentId = extOrganizationUserMapper.getDepartmentByUserId(userId); + List departmentTree = departmentService.getTree(orgId); + departmentIds = getParentIds(departmentTree, departmentId); + } + return departmentIds; + } + + public List getParentIds(List departmentTree, String departmentId) { + List ids = new ArrayList<>(); + if (CollectionUtils.isEmpty(departmentTree) || StringUtils.isBlank(departmentId)) { + return ids; + } + + Map idNodeMap = new HashMap<>(); + buildIdNodeMap(departmentTree, idNodeMap); + + BaseTreeNode currentNode = idNodeMap.get(departmentId); + while (currentNode != null && currentNode.getParentId() != null) { + ids.add(currentNode.getId()); + if ("NONE".equals(currentNode.getParentId())) { + break; + } + currentNode = idNodeMap.get(currentNode.getParentId()); + } + + return ids; + } + + /** + * 数量 + * + * @param userId + * @param orgId + * + * @return + */ + public Map moduleCount(String userId, String orgId) { + List moduleNode = extDashboardModuleMapper.selectTreeNode(orgId); + //查询模块下仪表板数据 + List departmentIds = getDepartmentIds(userId, orgId); + List dashboardNode = extDashboardMapper.selectDashboardNode(departmentIds, orgId, userId); + + List dashboardTreeNodes = DashboardTreeNode.buildTree(moduleNode); + + Map moduleCountMap = dashboardNode.stream() + .collect(Collectors.groupingBy( + BaseTreeNode::getParentId, + Collectors.summingInt(node -> 1) + )); + + Map countMap = new HashMap<>(); + for (DashboardTreeNode root : dashboardTreeNodes) { + // 递归计算每个节点的仪表板数量 + calculateDashboardCount(root, countMap, moduleCountMap); + } + + //我的收藏 + int myCollect = extDashboardCollectionMapper.countMyCollect(userId); + countMap.put("myCollect", (long) myCollect); + return countMap; + } + + + private int calculateDashboardCount(BaseTreeNode node, Map countMap, Map moduleCountMap) { + int count = 0; + + if (moduleCountMap.containsKey(node.getId())) { + count += moduleCountMap.get(node.getId()); + } + + if (node.getChildren() != null && !node.getChildren().isEmpty()) { + for (BaseTreeNode child : node.getChildren()) { + count += calculateDashboardCount(child, countMap, moduleCountMap); + } + } + + countMap.put(node.getId(), (long) count); + + return count; + } + + + /** + * 仪表板文件夹移动 + * + * @param request + * @param userId + */ + public void moveNode(NodeMoveRequest request, String userId) { + NodeSortDTO nodeSortDTO = super.getNodeSortDTO(request, + extDashboardModuleMapper::selectBaseModuleById, + extDashboardModuleMapper::selectModuleByParentIdAndPosOperator, + false); + + + DashboardModule dashboardModule = new DashboardModule(); + dashboardModule.setParentId(nodeSortDTO.getParent().getId()); + dashboardModule.setId(request.getDragNodeId()); + if (dashboardModuleMapper.countByExample(dashboardModule) == 0) { + DashboardModule moveModule = dashboardModuleMapper.selectByPrimaryKey(request.getDragNodeId()); + moveModule.setParentId(nodeSortDTO.getParent().getId()); + checkFileName(moveModule.getName(), moveModule.getParentId(), moveModule.getOrganizationId(), null); + + moveModule.setUpdateUser(userId); + moveModule.setUpdateTime(System.currentTimeMillis()); + dashboardModuleMapper.update(moveModule); + } + + super.sort(nodeSortDTO); + } + + @Override + public void updatePos(String id, long pos) { + DashboardModule dashboardModule = new DashboardModule(); + dashboardModule.setId(id); + dashboardModule.setPos(pos); + dashboardModuleMapper.update(dashboardModule); + } + + @Override + public void refreshPos(String parentId) { + List childrenIds = extDashboardModuleMapper.selectChildrenIds(parentId); + List dashboardModules = new ArrayList<>(); + for (int i = 0; i < childrenIds.size(); i++) { + String nodeId = childrenIds.get(i); + DashboardModule updateDashboard = new DashboardModule(); + updateDashboard.setId(nodeId); + updateDashboard.setPos((i + 1) * LIMIT_POS); + dashboardModules.add(updateDashboard); + } + extDashboardModuleMapper.batchUpdate(dashboardModules); + } + + /** + * 获取文件夹名称 + * + * @param id + * + * @return + */ + public String getDashboardModuleName(String id) { + DashboardModule dashboardModule = checkDashboardModule(id); + return dashboardModule.getName(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/service/DashboardService.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/service/DashboardService.java new file mode 100644 index 0000000..e0105ff --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/service/DashboardService.java @@ -0,0 +1,404 @@ +package cn.cordys.crm.dashboard.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.service.SSRFValidationService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.NodeSortUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.dashboard.domain.Dashboard; +import cn.cordys.crm.dashboard.domain.DashboardCollection; +import cn.cordys.crm.dashboard.dto.DashboardLogDTO; +import cn.cordys.crm.dashboard.dto.MoveNodeSortDTO; +import cn.cordys.crm.dashboard.dto.request.*; +import cn.cordys.crm.dashboard.dto.response.DashboardDetailResponse; +import cn.cordys.crm.dashboard.dto.response.DashboardPageResponse; +import cn.cordys.crm.dashboard.mapper.ExtDashboardCollectionMapper; +import cn.cordys.crm.dashboard.mapper.ExtDashboardMapper; +import cn.cordys.crm.system.dto.ScopeNameDTO; +import cn.cordys.crm.system.dto.request.NodeMoveRequest; +import cn.cordys.crm.system.mapper.ExtOrganizationUserMapper; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.crm.system.service.UserExtendService; +import cn.cordys.mybatis.BaseMapper; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.ibatis.session.ExecutorType; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.mybatis.spring.SqlSessionUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class DashboardService extends DashboardSortService { + + @Resource + private ExtDashboardMapper extDashboardMapper; + @Resource + private BaseMapper dashboardMapper; + @Resource + private UserExtendService userExtendService; + @Resource + private DashboardModuleService dashboardModuleService; + @Resource + private BaseMapper dashboardCollectionMapper; + @Resource + private ExtDashboardCollectionMapper extDashboardCollectionMapper; + @Resource + private ExtUserMapper extUserMapper; + @Resource + private SqlSessionFactory sqlSessionFactory; + @Resource + private ExtOrganizationUserMapper extOrganizationUserMapper; + @Resource + private DepartmentService departmentService; + @Resource + private SSRFValidationService ssrfValidationService; + + /** + * 添加仪表板 + * + * @param request + * @param orgId + * @param userId + * + * @return + */ + @OperationLog(module = LogModule.DASHBOARD, type = LogType.ADD) + public Dashboard addDashboard(DashboardAddRequest request, String orgId, String userId) { + ssrfValidationService.validateAgainstWhitelist(request.getResourceUrl()); + + checkDashboardName(request.getName(), request.getDashboardModuleId(), orgId, null); + dashboardModuleService.checkDashboardModule(request.getDashboardModuleId()); + String id = IDGenerator.nextStr(); + Dashboard dashboard = new Dashboard(); + dashboard.setId(id); + dashboard.setName(request.getName()); + dashboard.setResourceUrl(request.getResourceUrl()); + dashboard.setPos(getNextPos(orgId)); + dashboard.setOrganizationId(orgId); + dashboard.setDashboardModuleId(request.getDashboardModuleId()); + dashboard.setScopeId(JSON.toJSONString(request.getScopeIds())); + dashboard.setDescription(request.getDescription()); + dashboard.setCreateTime(System.currentTimeMillis()); + dashboard.setUpdateTime(System.currentTimeMillis()); + dashboard.setCreateUser(userId); + dashboard.setUpdateUser(userId); + + dashboardMapper.insert(dashboard); + + //日志 + OperationLogContext.setContext(LogContextInfo.builder() + .modifiedValue(dashboard) + .resourceId(id) + .resourceName(dashboard.getName()) + .build()); + return dashboard; + } + + private Long getNextPos(String orgId) { + Long pos = extDashboardMapper.getNextPosByOrgId(orgId); + return (pos == null ? 0 : pos) + NodeSortUtils.DEFAULT_NODE_INTERVAL_POS; + } + + + private void checkDashboardName(String name, String dashboardModuleId, String orgId, String id) { + if (extDashboardMapper.countByName(name, dashboardModuleId, orgId, id) > 0) { + throw new GenericException(Translator.get("dashboard_name_exist")); + } + } + + + /** + * 仪表板详情 + * + * @param id + * + * @return + */ + public DashboardDetailResponse getDashboardDetail(String id) { + DashboardDetailResponse response = extDashboardMapper.getDetail(id); + if (response != null && !StringUtils.isBlank(response.getScopeId())) { + response.setMembers(userExtendService.getScope(JSON.parseArray(response.getScopeId(), String.class))); + } + return response; + } + + /** + * 更新仪表板 + * + * @param request + * @param orgId + * @param userId + */ + @OperationLog(module = LogModule.DASHBOARD, type = LogType.UPDATE) + public void updateDashboard(DashboardUpdateRequest request, String orgId, String userId) { + checkDashboardName(request.getName(), request.getDashboardModuleId(), orgId, request.getId()); + Dashboard originalDashboard = checkDashboard(request.getId()); + DashboardLogDTO originalDetail = getLogDetail(originalDashboard.getId()); + + Dashboard dashboard = new Dashboard(); + BeanUtils.copyBean(dashboard, request); + dashboard.setScopeId(JSON.toJSONString(request.getScopeIds())); + dashboard.setUpdateTime(System.currentTimeMillis()); + dashboard.setUpdateUser(userId); + dashboardMapper.update(dashboard); + + DashboardLogDTO newDetail = getLogDetail(originalDashboard.getId()); + + // 添加日志上下文 + String resourceName = Optional.ofNullable(dashboard.getName()).orElse(originalDashboard.getName()); + OperationLogContext.setContext( + LogContextInfo.builder() + .originalValue(originalDetail) + .modifiedValue(newDetail) + .resourceId(request.getId()) + .resourceName(resourceName) + .build() + ); + } + + private DashboardLogDTO getLogDetail(String id) { + DashboardDetailResponse dashboardDetail = getDashboardDetail(id); + DashboardLogDTO detailDTO = new DashboardLogDTO(); + BeanUtils.copyBean(detailDTO, dashboardDetail); + List names = dashboardDetail.getMembers().stream().map(ScopeNameDTO::getName).toList(); + detailDTO.setMembers(names); + return detailDTO; + } + + private Dashboard checkDashboard(String id) { + Dashboard dashboard = dashboardMapper.selectByPrimaryKey(id); + if (dashboard == null) { + throw new GenericException(Translator.get("dashboard_blank")); + } + return dashboard; + } + + + /** + * 重命名仪表板 + * + * @param request + * @param userId + */ + @OperationLog(module = LogModule.DASHBOARD, type = LogType.UPDATE) + public void rename(DashboardRenameRequest request, String userId, String orgId) { + Dashboard originalDashboard = checkDashboard(request.getId()); + checkDashboardName(request.getName(), request.getDashboardModuleId(), orgId, request.getId()); + + Dashboard dashboard = BeanUtils.copyBean(new Dashboard(), request); + dashboard.setUpdateTime(System.currentTimeMillis()); + dashboard.setName(request.getName()); + dashboard.setUpdateUser(userId); + dashboardMapper.update(dashboard); + + // 添加日志上下文 + String resourceName = Optional.ofNullable(dashboard.getName()).orElse(originalDashboard.getName()); + OperationLogContext.setContext( + LogContextInfo.builder() + .originalValue(originalDashboard) + .modifiedValue(checkDashboard(request.getId())) + .resourceId(request.getId()) + .resourceName(resourceName) + .build() + ); + } + + + /** + * 删除仪表板 + * + * @param id + */ + @OperationLog(module = LogModule.DASHBOARD, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + Dashboard dashboard = checkDashboard(id); + dashboardMapper.deleteByPrimaryKey(id); + extDashboardCollectionMapper.deleteByDashboardId(id); + + // 设置操作对象 + OperationLogContext.setResourceName(dashboard.getName()); + } + + + /** + * 列表查询 + * + * @param request + * @param userId + * @param orgId + * + * @return + */ + public Pager> getList(DashboardPageRequest request, String userId, String orgId) { + List departmentIds = new ArrayList<>(); + if (!Strings.CI.equals(userId, InternalUser.ADMIN.getValue())) { + String departmentId = extOrganizationUserMapper.getDepartmentByUserId(userId); + List departmentTree = departmentService.getTree(orgId); + departmentIds = dashboardModuleService.getParentIds(departmentTree, departmentId); + } + + + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List dashboardList = extDashboardMapper.list(request, userId, orgId, departmentIds); + handleData(dashboardList, userId); + return PageUtils.setPageInfo(page, dashboardList); + } + + /** + * 数据处理 + * + * @param dashboardList + */ + private void handleData(List dashboardList, String userId) { + if (CollectionUtils.isNotEmpty(dashboardList)) { + //创建人 更新人 + List ids = new ArrayList<>(); + ids.addAll(dashboardList.stream().map(DashboardPageResponse::getCreateUser).toList()); + ids.addAll(dashboardList.stream().map(DashboardPageResponse::getUpdateUser).toList()); + List options = extUserMapper.selectUserOptionByIds(ids); + Map userMap = options + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + + Set myCollects = new HashSet<>(extDashboardCollectionMapper.getByUserId(userId)); + + dashboardList.forEach(dashboard -> { + dashboard.setMembers(userExtendService.getScope(JSON.parseArray(dashboard.getScopeId(), String.class))); + if (userMap.containsKey(dashboard.getCreateUser())) { + dashboard.setCreateUserName(userMap.get(dashboard.getCreateUser())); + } + if (userMap.containsKey(dashboard.getUpdateUser())) { + dashboard.setUpdateUserName(userMap.get(dashboard.getUpdateUser())); + } + if (myCollects.contains(dashboard.getId())) { + dashboard.setMyCollect(true); + } + }); + } + } + + + /** + * 收藏仪表板 + * + * @param id + * @param userId + */ + public void collect(String id, String userId) { + checkCollect(id, userId); + DashboardCollection dashboardCollection = new DashboardCollection(); + dashboardCollection.setId(IDGenerator.nextStr()); + dashboardCollection.setDashboardId(id); + dashboardCollection.setUserId(userId); + dashboardCollection.setCreateTime(System.currentTimeMillis()); + dashboardCollection.setUpdateTime(System.currentTimeMillis()); + dashboardCollection.setCreateUser(userId); + dashboardCollection.setUpdateUser(userId); + dashboardCollectionMapper.insert(dashboardCollection); + } + + private void checkCollect(String id, String userId) { + if (extDashboardCollectionMapper.checkCollect(id, userId) > 0) { + throw new GenericException(Translator.get("dashboard_collect_exist")); + } + } + + + /** + * 我的收藏列表 + * + * @param request + * @param userId + * @param orgId + * + * @return + */ + public List collectList(BasePageRequest request, String userId, String orgId) { + List dashboardList = extDashboardCollectionMapper.collectList(request, userId, orgId); + handleData(dashboardList, userId); + return dashboardList; + } + + public void unCollect(String dashboardId, String userId) { + extDashboardCollectionMapper.unCollect(dashboardId, userId); + } + + + /** + * 仪表板排序 + * + * @param request + * @param userId + * @param orgId + */ + public void editPos(DashboardEditPosRequest request, String userId, String orgId) { + Dashboard dashboard = checkDashboard(request.getMoveId()); + if (!Strings.CS.equals(request.getDashboardModuleId(), dashboard.getDashboardModuleId())) { + dashboardModuleService.checkDashboardModule(request.getDashboardModuleId()); + checkDashboardName(dashboard.getName(), request.getDashboardModuleId(), orgId, dashboard.getId()); + + dashboard.setDashboardModuleId(request.getDashboardModuleId()); + dashboard.setUpdateUser(userId); + dashboard.setUpdateTime(System.currentTimeMillis()); + dashboardMapper.update(dashboard); + } + if (Strings.CS.equals(request.getTargetId(), request.getMoveId())) { + return; + } + moveNode(request, orgId); + } + + + public void moveNode(DashboardEditPosRequest posRequest, String orgId) { + NodeMoveRequest request = super.getNodeMoveRequest(posRequest.getMoveId(), posRequest.getTargetId(), posRequest.getMoveMode(), true); + MoveNodeSortDTO sortDTO = super.getNodeSortDTO( + orgId, + request, + extDashboardMapper::selectDragInfoById, + extDashboardMapper::selectNodeByPosOperator + ); + super.sort(sortDTO); + } + + + @Override + public void updatePos(String id, long pos) { + extDashboardMapper.updatePos(id, pos); + } + + @Override + public void refreshPos(String orgId) { + List posIds = extDashboardMapper.selectIdByOrgIdOrderByPos(orgId); + SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); + ExtDashboardMapper batchUpdateMapper = sqlSession.getMapper(ExtDashboardMapper.class); + for (int i = 0; i < posIds.size(); i++) { + batchUpdateMapper.updatePos(posIds.get(i), i * NodeSortUtils.DEFAULT_NODE_INTERVAL_POS); + } + sqlSession.flushStatements(); + SqlSessionUtils.closeSqlSession(sqlSession, sqlSessionFactory); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/dashboard/service/DashboardSortService.java b/backend/crm/src/main/java/cn/cordys/crm/dashboard/service/DashboardSortService.java new file mode 100644 index 0000000..30dd642 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/dashboard/service/DashboardSortService.java @@ -0,0 +1,103 @@ +package cn.cordys.crm.dashboard.service; + +import cn.cordys.common.dto.NodeSortCountResultDTO; +import cn.cordys.common.dto.NodeSortQueryParam; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.util.NodeSortUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.dashboard.dto.DropNode; +import cn.cordys.crm.dashboard.dto.MoveNodeSortDTO; +import cn.cordys.crm.system.dto.request.NodeMoveRequest; +import org.apache.commons.lang3.Strings; + +import java.util.function.Function; + +public abstract class DashboardSortService { + + private static final String MOVE_POS_OPERATOR_LESS = "lessThan"; + private static final String MOVE_POS_OPERATOR_MORE = "moreThan"; + private static final String DRAG_NODE_NOT_EXIST = "drag_node.not.exist"; + + public abstract void updatePos(String id, long pos); + + public abstract void refreshPos(String testPlanId); + + /** + * 构建节点移动的请求参数 + * + * @param isDesc 是否是降序排列 + */ + public NodeMoveRequest getNodeMoveRequest(String moveId, String targetId, String moveMode, boolean isDesc) { + NodeMoveRequest request = new NodeMoveRequest(); + request.setDragNodeId(moveId); + request.setDropNodeId(targetId); + request.setAndConvertDropPosition(moveMode, isDesc); + return request; + } + + + /** + * 构建节点排序的参数 + * + * @param sortRangeId 排序范围ID + * @param request 拖拽的前端请求参数 + * @param selectIdNodeFunc 通过id查询节点的函数 + * @param selectPosNodeFunc 通过parentId和pos运算符查询节点的函数 + */ + public MoveNodeSortDTO getNodeSortDTO(String sortRangeId, NodeMoveRequest request, Function selectIdNodeFunc, Function selectPosNodeFunc) { + if (Strings.CS.equals(request.getDragNodeId(), request.getDropNodeId())) { + //两种节点不能一样 + throw new GenericException(Translator.get("invalid_parameter") + ": drag node and drop node"); + } + + DropNode dragNode = selectIdNodeFunc.apply(request.getDragNodeId()); + if (dragNode == null) { + throw new GenericException(Translator.get(DRAG_NODE_NOT_EXIST) + ":" + request.getDragNodeId()); + } + + DropNode dropNode = selectIdNodeFunc.apply(request.getDropNodeId()); + if (dropNode == null) { + throw new GenericException(Translator.get(DRAG_NODE_NOT_EXIST) + ":" + request.getDropNodeId()); + } + + DropNode previousNode; + DropNode nextNode; + + if (request.getDropPosition() == 1) { + //dropPosition=1: 放到dropNode节点后,原dropNode后面的节点之前 + previousNode = dropNode; + NodeSortQueryParam sortParam = new NodeSortQueryParam(); + sortParam.setPos(previousNode.getPos()); + sortParam.setOperator(MOVE_POS_OPERATOR_MORE); + sortParam.setParentId(sortRangeId); + nextNode = selectPosNodeFunc.apply(sortParam); + } else if (request.getDropPosition() == -1) { + //dropPosition=-1: 放到dropNode节点前,原dropNode前面的节点之后 + nextNode = dropNode; + NodeSortQueryParam sortParam = new NodeSortQueryParam(); + sortParam.setPos(nextNode.getPos()); + sortParam.setOperator(MOVE_POS_OPERATOR_LESS); + sortParam.setParentId(sortRangeId); + previousNode = selectPosNodeFunc.apply(sortParam); + } else { + throw new GenericException(Translator.get("invalid_parameter") + ": dropPosition"); + } + + return new MoveNodeSortDTO(sortRangeId, dragNode, previousNode, nextNode); + } + + + //排序 + public void sort(MoveNodeSortDTO sortDTO) { + // 获取相邻节点 + DropNode previousNode = sortDTO.getPreviousNode(); + DropNode nextNode = sortDTO.getNextNode(); + NodeSortCountResultDTO countResultDTO = NodeSortUtils.countModuleSort( + previousNode == null ? -1 : previousNode.getPos(), + nextNode == null ? -1 : nextNode.getPos()); + updatePos(sortDTO.getSortNode().getId(), countResultDTO.getPos()); + if (countResultDTO.isRefreshPos()) { + refreshPos(sortDTO.getSortRangeId()); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/constants/FollowUpPlanStatusType.java b/backend/crm/src/main/java/cn/cordys/crm/follow/constants/FollowUpPlanStatusType.java new file mode 100644 index 0000000..7918baf --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/constants/FollowUpPlanStatusType.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.follow.constants; + +public enum FollowUpPlanStatusType { + + + /** + * 未开始 + */ + PREPARED, + /** + * 进行中 + */ + UNDERWAY, + /** + * 已完成 + */ + COMPLETED, + /** + * 已取消 + */ + CANCELLED +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/constants/FollowUpPlanType.java b/backend/crm/src/main/java/cn/cordys/crm/follow/constants/FollowUpPlanType.java new file mode 100644 index 0000000..e4de349 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/constants/FollowUpPlanType.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.follow.constants; + +public enum FollowUpPlanType { + /** + * 客户 + */ + CUSTOMER, + /** + * 线索 + */ + CLUE +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/controller/FollowUpPlanController.java b/backend/crm/src/main/java/cn/cordys/crm/follow/controller/FollowUpPlanController.java new file mode 100644 index 0000000..86bfca2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/controller/FollowUpPlanController.java @@ -0,0 +1,103 @@ +package cn.cordys.crm.follow.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.follow.domain.FollowUpPlan; +import cn.cordys.crm.follow.dto.request.FollowUpPlanAddRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanStatusRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanUpdateRequest; +import cn.cordys.crm.follow.dto.request.PlanHomePageRequest; +import cn.cordys.crm.follow.dto.response.FollowUpPlanDetailResponse; +import cn.cordys.crm.follow.dto.response.FollowUpPlanListResponse; +import cn.cordys.crm.follow.service.FollowUpPlanService; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "跟进计划统一页面") +@RestController +@RequestMapping("/follow/plan") +public class FollowUpPlanController { + + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private FollowUpPlanService followUpPlanService; + @Resource + private DataScopeService dataScopeService; + + + @GetMapping("/module/form") + @Operation(summary = "获取表单配置") + public ModuleFormConfigDTO getModuleFormConfig() { + return moduleFormCacheService.getBusinessFormConfig(FormKey.FOLLOW_PLAN.getKey(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/tab") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "数据权限TAB") + public ResourceTabEnableDTO getTabEnableConfig() { + return followUpPlanService.getTabEnableConfig(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/page") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "跟进记录列表") + public PagerWithOption> list(@Validated @RequestBody PlanHomePageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.FOLLOW_PLAN.getKey()); + DeptDataPermissionDTO clueDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CLUE_MANAGEMENT_READ); + DeptDataPermissionDTO customerDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CUSTOMER_MANAGEMENT_READ); + return followUpPlanService.totalList(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), clueDataPermission, customerDataPermission); + } + + @GetMapping("/delete/{id}") + @Operation(summary = "删除跟进计划") + public void delete(@PathVariable String id) { + followUpPlanService.checkPlanPermission(id, OrganizationContext.getOrganizationId()); + followUpPlanService.delete(id); + } + + @PostMapping("/status/update") + @Operation(summary = "更新跟进计划状态") + public void updateStatus(@Validated @RequestBody FollowUpPlanStatusRequest request) { + followUpPlanService.checkPlanPermission(request.getId(), OrganizationContext.getOrganizationId()); + followUpPlanService.updateStatus(request, SessionUtils.getUserId()); + } + + @GetMapping("/get/{id}") + @Operation(summary = "跟进计划详情") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.OPPORTUNITY_MANAGEMENT_READ}, logical = Logical.OR) + public FollowUpPlanDetailResponse get(@PathVariable String id) { + return followUpPlanService.get(id, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @Operation(summary = "更新跟进计划") + public FollowUpPlan update(@Validated @RequestBody FollowUpPlanUpdateRequest request) { + followUpPlanService.checkPlanPermission(request.getId(), OrganizationContext.getOrganizationId()); + return followUpPlanService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/add") + @Operation(summary = "添加跟进计划") + public FollowUpPlan add(@Validated @RequestBody FollowUpPlanAddRequest request) { + return followUpPlanService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/controller/FollowUpPlanUserViewController.java b/backend/crm/src/main/java/cn/cordys/crm/follow/controller/FollowUpPlanUserViewController.java new file mode 100644 index 0000000..034c86f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/controller/FollowUpPlanUserViewController.java @@ -0,0 +1,94 @@ +package cn.cordys.crm.follow.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.UserViewResourceType; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import cn.cordys.crm.system.dto.response.UserViewListResponse; +import cn.cordys.crm.system.dto.response.UserViewResponse; +import cn.cordys.crm.system.service.UserViewService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "计划视图") +@RestController +@RequestMapping("/follow/plan/view") +public class FollowUpPlanUserViewController { + + @Resource + private UserViewService userViewService; + + + @PostMapping("/add") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "添加计划视图") + public UserView add(@Validated @RequestBody UserViewAddRequest request) { + return userViewService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), UserViewResourceType.FOLLOW_PLAN.name()); + } + + + @PostMapping("/update") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "编辑计划视图") + public UserView update(@Validated @RequestBody UserViewUpdateRequest request) { + return userViewService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "删除计划视图") + public void delete(@PathVariable String id) { + userViewService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/detail/{id}") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "计划视图详情") + public UserViewResponse viewDetail(@PathVariable String id) { + return userViewService.getViewDetail(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), FormKey.FOLLOW_PLAN.getKey()); + } + + + @GetMapping("/list") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "计划视图列表") + public List queryList() { + return userViewService.list(UserViewResourceType.FOLLOW_PLAN.name(), SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/fixed/{id}") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "计划视图固定/取消固定") + public void fixed(@PathVariable String id) { + userViewService.fixed(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/edit/pos") + @Operation(summary = "计划视图-拖拽排序") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + public void editPos(@Validated @RequestBody PosRequest request) { + userViewService.editPos(request, SessionUtils.getUserId(), UserViewResourceType.FOLLOW_PLAN.name()); + } + + + @GetMapping("/enable/{id}") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "计划视图-启用/禁用") + public void enable(@PathVariable String id) { + userViewService.enable(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/controller/FollowUpRecordController.java b/backend/crm/src/main/java/cn/cordys/crm/follow/controller/FollowUpRecordController.java new file mode 100644 index 0000000..564ef5c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/controller/FollowUpRecordController.java @@ -0,0 +1,96 @@ +package cn.cordys.crm.follow.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.follow.domain.FollowUpRecord; +import cn.cordys.crm.follow.dto.request.FollowUpRecordAddRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordUpdateRequest; +import cn.cordys.crm.follow.dto.request.RecordHomePageRequest; +import cn.cordys.crm.follow.dto.response.FollowUpRecordDetailResponse; +import cn.cordys.crm.follow.dto.response.FollowUpRecordListResponse; +import cn.cordys.crm.follow.service.FollowUpRecordService; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "跟进记录统一页面") +@RestController +@RequestMapping("/follow/record") +public class FollowUpRecordController { + + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private FollowUpRecordService followUpRecordService; + @Resource + private DataScopeService dataScopeService; + + + @GetMapping("/module/form") + @Operation(summary = "获取表单配置") + public ModuleFormConfigDTO getModuleFormConfig() { + return moduleFormCacheService.getBusinessFormConfig(FormKey.FOLLOW_RECORD.getKey(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/tab") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "数据权限TAB") + public ResourceTabEnableDTO getTabEnableConfig() { + return followUpRecordService.getTabEnableConfig(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/page") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.OPPORTUNITY_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "跟进记录列表") + public PagerWithOption> list(@Validated @RequestBody RecordHomePageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.FOLLOW_RECORD.getKey()); + DeptDataPermissionDTO clueDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CLUE_MANAGEMENT_READ); + DeptDataPermissionDTO customerDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CUSTOMER_MANAGEMENT_READ); + return followUpRecordService.totalList(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), clueDataPermission, customerDataPermission); + } + + @GetMapping("/delete/{id}") + @Operation(summary = "删除跟进记录") + public void delete(@PathVariable String id) { + followUpRecordService.checkRecordPermission(id, OrganizationContext.getOrganizationId()); + followUpRecordService.delete(id); + } + + @GetMapping("/get/{id}") + @Operation(summary = "跟进记录详情") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.OPPORTUNITY_MANAGEMENT_READ}, logical = Logical.OR) + public FollowUpRecordDetailResponse get(@PathVariable String id) { + return followUpRecordService.get(id, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/add") + @Operation(summary = "添加跟进记录") + public FollowUpRecord add(@Validated @RequestBody FollowUpRecordAddRequest request) { + return followUpRecordService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @Operation(summary = "更新跟进记录") + public FollowUpRecord update(@Validated @RequestBody FollowUpRecordUpdateRequest request) { + followUpRecordService.checkRecordPermission(request.getId(), OrganizationContext.getOrganizationId()); + return followUpRecordService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/controller/FollowUpRecordUserViewController.java b/backend/crm/src/main/java/cn/cordys/crm/follow/controller/FollowUpRecordUserViewController.java new file mode 100644 index 0000000..0663a13 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/controller/FollowUpRecordUserViewController.java @@ -0,0 +1,94 @@ +package cn.cordys.crm.follow.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.UserViewResourceType; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import cn.cordys.crm.system.dto.response.UserViewListResponse; +import cn.cordys.crm.system.dto.response.UserViewResponse; +import cn.cordys.crm.system.service.UserViewService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "记录视图") +@RestController +@RequestMapping("/follow/record/view") +public class FollowUpRecordUserViewController { + + @Resource + private UserViewService userViewService; + + + @PostMapping("/add") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "添加记录视图") + public UserView add(@Validated @RequestBody UserViewAddRequest request) { + return userViewService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), UserViewResourceType.FOLLOW_RECORD.name()); + } + + + @PostMapping("/update") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "编辑记录视图") + public UserView update(@Validated @RequestBody UserViewUpdateRequest request) { + return userViewService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "删除记录视图") + public void delete(@PathVariable String id) { + userViewService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/detail/{id}") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "记录视图详情") + public UserViewResponse viewDetail(@PathVariable String id) { + return userViewService.getViewDetail(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), FormKey.FOLLOW_RECORD.getKey()); + } + + + @GetMapping("/list") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "记录视图列表") + public List queryList() { + return userViewService.list(UserViewResourceType.FOLLOW_RECORD.name(), SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/fixed/{id}") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "记录视图固定/取消固定") + public void fixed(@PathVariable String id) { + userViewService.fixed(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/edit/pos") + @Operation(summary = "记录视图-拖拽排序") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + public void editPos(@Validated @RequestBody PosRequest request) { + userViewService.editPos(request, SessionUtils.getUserId(), UserViewResourceType.FOLLOW_RECORD.name()); + } + + + @GetMapping("/enable/{id}") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ}, logical = Logical.OR) + @Operation(summary = "记录视图-启用/禁用") + public void enable(@PathVariable String id) { + userViewService.enable(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpPlan.java b/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpPlan.java new file mode 100644 index 0000000..539cf25 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpPlan.java @@ -0,0 +1,48 @@ +package cn.cordys.crm.follow.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "follow_up_plan") +public class FollowUpPlan extends BaseModel { + + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "商机id") + private String opportunityId; + + @Schema(description = "类型") + private String type; + + @Schema(description = "线索id") + private String clueId; + + @Schema(description = "预计沟通内容") + private String content; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "联系人") + private String contactId; + + @Schema(description = "预计开始时间") + private Long estimatedTime; + + @Schema(description = "跟进方式") + private String method; + + @Schema(description = "状态") + private String status; + + @Schema(description = "是否转为跟进记录") + private Boolean converted; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpPlanField.java b/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpPlanField.java new file mode 100644 index 0000000..8fd7e8c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpPlanField.java @@ -0,0 +1,10 @@ +package cn.cordys.crm.follow.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "follow_up_plan_field") +public class FollowUpPlanField extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpPlanFieldBlob.java b/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpPlanFieldBlob.java new file mode 100644 index 0000000..5e3e104 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpPlanFieldBlob.java @@ -0,0 +1,10 @@ +package cn.cordys.crm.follow.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "follow_up_plan_field_blob") +public class FollowUpPlanFieldBlob extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpRecord.java b/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpRecord.java new file mode 100644 index 0000000..c5ec958 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpRecord.java @@ -0,0 +1,42 @@ +package cn.cordys.crm.follow.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "follow_up_record") +public class FollowUpRecord extends BaseModel { + + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "商机id") + private String opportunityId; + + @Schema(description = "类型") + private String type; + + @Schema(description = "线索id") + private String clueId; + + @Schema(description = "跟进内容") + private String content; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "跟进时间") + private Long followTime; + + @Schema(description = "跟进方式") + private String followMethod; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "联系人") + private String contactId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpRecordField.java b/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpRecordField.java new file mode 100644 index 0000000..c46073a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpRecordField.java @@ -0,0 +1,11 @@ +package cn.cordys.crm.follow.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "follow_up_record_field") +public class FollowUpRecordField extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpRecordFieldBlob.java b/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpRecordFieldBlob.java new file mode 100644 index 0000000..c22d288 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/domain/FollowUpRecordFieldBlob.java @@ -0,0 +1,11 @@ +package cn.cordys.crm.follow.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "follow_up_record_field_blob") +public class FollowUpRecordFieldBlob extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/dto/CustomerDataDTO.java b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/CustomerDataDTO.java new file mode 100644 index 0000000..621f19f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/CustomerDataDTO.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.follow.dto; + +import lombok.Data; + +import java.util.List; + +@Data +public class CustomerDataDTO { + + private boolean all = false; + private boolean owner = false; + private boolean self = false; + + private List userIds; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpPlanAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpPlanAddRequest.java new file mode 100644 index 0000000..ed8d066 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpPlanAddRequest.java @@ -0,0 +1,53 @@ +package cn.cordys.crm.follow.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class FollowUpPlanAddRequest { + + @Size(max = 32) + @Schema(description = "客户id") + private String customerId; + + @Size(max = 32) + @Schema(description = "商机id") + private String opportunityId; + + @Size(max = 32) + @NotBlank + @Schema(description = "类型:CUSTOMER/CLUE", requiredMode = Schema.RequiredMode.REQUIRED) + private String type; + + @Size(max = 32) + @Schema(description = "线索id") + private String clueId; + + @Size(max = 3000) + @Schema(description = "预计沟通内容", requiredMode = Schema.RequiredMode.REQUIRED) + private String content; + + @Size(max = 32) + @Schema(description = "负责人", requiredMode = Schema.RequiredMode.REQUIRED) + private String owner; + + @Size(max = 32) + @Schema(description = "联系人") + private String contactId; + + @Schema(description = "预计开始时间") + private Long estimatedTime; + + @Size(max = 32) + @NotBlank + @Schema(description = "跟进方式", requiredMode = Schema.RequiredMode.REQUIRED) + private String method; + + @Schema(description = "自定义字段") + private List moduleFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpPlanPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpPlanPageRequest.java new file mode 100644 index 0000000..4079377 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpPlanPageRequest.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.follow.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class FollowUpPlanPageRequest extends BasePageRequest { + + @Schema(description = "资源id: 客户id/商机id/线索id", requiredMode = Schema.RequiredMode.REQUIRED) + private String sourceId; + + @Schema(description = "状态: ALL/PREPARED/UNDERWAY/COMPLETED/CANCELLED", requiredMode = Schema.RequiredMode.REQUIRED) + private String status; + + @Schema(description = "是否是为我的计划(默认false,个人中心查询时传入true)") + private boolean myPlan = false; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpPlanStatusRequest.java b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpPlanStatusRequest.java new file mode 100644 index 0000000..bae1c4a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpPlanStatusRequest.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.follow.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class FollowUpPlanStatusRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 32) + private String id; + + + @Schema(description = "状态: PREPARED/UNDERWAY/COMPLETED/CANCELLED", requiredMode = Schema.RequiredMode.REQUIRED) + private String status; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpPlanUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpPlanUpdateRequest.java new file mode 100644 index 0000000..9c6b6c3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpPlanUpdateRequest.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.follow.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class FollowUpPlanUpdateRequest extends FollowUpPlanAddRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 32) + private String id; + + @Schema(description = "是否转为跟进记录") + private Boolean converted; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpRecordAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpRecordAddRequest.java new file mode 100644 index 0000000..b18f143 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpRecordAddRequest.java @@ -0,0 +1,54 @@ +package cn.cordys.crm.follow.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class FollowUpRecordAddRequest { + + @Size(max = 32) + @Schema(description = "客户id") + private String customerId; + + @Size(max = 32) + @Schema(description = "商机id") + private String opportunityId; + + @Size(max = 32) + @NotBlank + @Schema(description = "类型:CUSTOMER/CLUE", requiredMode = Schema.RequiredMode.REQUIRED) + private String type; + + @Size(max = 32) + @Schema(description = "线索id") + private String clueId; + + @Size(max = 3000) + @Schema(description = "跟进内容", requiredMode = Schema.RequiredMode.REQUIRED) + private String content; + + @Schema(description = "跟进时间") + private Long followTime; + + @Size(max = 32) + @Schema(description = "跟进方式", requiredMode = Schema.RequiredMode.REQUIRED) + private String followMethod; + + @Size(max = 32) + @Schema(description = "负责人", requiredMode = Schema.RequiredMode.REQUIRED) + private String owner; + + @Size(max = 32) + @Schema(description = "联系人") + private String contactId; + + + @Schema(description = "自定义字段") + private List moduleFields; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpRecordPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpRecordPageRequest.java new file mode 100644 index 0000000..39d5c34 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpRecordPageRequest.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.follow.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class FollowUpRecordPageRequest extends BasePageRequest { + + @Schema(description = "资源id: 客户id/商机id/线索id", requiredMode = Schema.RequiredMode.REQUIRED) + private String sourceId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpRecordUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpRecordUpdateRequest.java new file mode 100644 index 0000000..5ecb6eb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/FollowUpRecordUpdateRequest.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.follow.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class FollowUpRecordUpdateRequest extends FollowUpRecordAddRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 32) + private String id; + + +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/PlanHomePageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/PlanHomePageRequest.java new file mode 100644 index 0000000..4983af9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/PlanHomePageRequest.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.follow.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +@Data +public class PlanHomePageRequest extends BasePageRequest { + + @NotEmpty + @Schema(description = "筛选状态", requiredMode = Schema.RequiredMode.REQUIRED) + private String status; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/RecordHomePageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/RecordHomePageRequest.java new file mode 100644 index 0000000..a19d6db --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/request/RecordHomePageRequest.java @@ -0,0 +1,10 @@ +package cn.cordys.crm.follow.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import lombok.Data; + +@Data +public class RecordHomePageRequest extends BasePageRequest { + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/dto/response/FollowUpPlanDetailResponse.java b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/response/FollowUpPlanDetailResponse.java new file mode 100644 index 0000000..b04201d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/response/FollowUpPlanDetailResponse.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.follow.dto.response; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class FollowUpPlanDetailResponse extends FollowUpPlanListResponse { + + @Schema(description = "选项集合") + private Map> optionMap; + + /** + * 附件集合 + */ + @Schema(description = "附件集合") + private Map> attachmentMap; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/dto/response/FollowUpPlanListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/response/FollowUpPlanListResponse.java new file mode 100644 index 0000000..462438f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/response/FollowUpPlanListResponse.java @@ -0,0 +1,101 @@ +package cn.cordys.crm.follow.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class FollowUpPlanListResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "客户名称") + private String customerName; + + @Schema(description = "商机id") + private String opportunityId; + + @Schema(description = "商机名称") + private String opportunityName; + + @Schema(description = "类型") + private String type; + + @Schema(description = "线索id") + private String clueId; + + @Schema(description = "线索名称") + private String clueName; + + @Schema(description = "计划内容") + private String content; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "联系人") + private String contactId; + + @Schema(description = "联系人名称") + private String contactName; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人") + private String updateUser; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "计划开始时间") + private Long estimatedTime; + + @Schema(description = "状态") + private String status; + + @Schema(description = "归属部门") + private String departmentId; + + @Schema(description = "归属部门名称") + private String departmentName; + + @Schema(description = "电话") + private String phone; + + @Schema(description = "跟进方式") + private String method; + + @Schema(description = "是否转为跟进记录") + private Boolean converted; + + @Schema(description = "属于公海或者线索池") + private String poolId; + + @Schema(description = "资源类型") + private String resourceType; + + @Schema(description = "自定义字段集合") + private List moduleFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/dto/response/FollowUpRecordDetailResponse.java b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/response/FollowUpRecordDetailResponse.java new file mode 100644 index 0000000..345f233 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/response/FollowUpRecordDetailResponse.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.follow.dto.response; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class FollowUpRecordDetailResponse extends FollowUpRecordListResponse { + + @Schema(description = "选项集合") + private Map> optionMap; + + /** + * 附件集合 + */ + @Schema(description = "附件集合") + private Map> attachmentMap; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/dto/response/FollowUpRecordListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/response/FollowUpRecordListResponse.java new file mode 100644 index 0000000..7efd777 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/dto/response/FollowUpRecordListResponse.java @@ -0,0 +1,95 @@ +package cn.cordys.crm.follow.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class FollowUpRecordListResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "客户名称") + private String customerName; + + @Schema(description = "商机id") + private String opportunityId; + + @Schema(description = "商机名称") + private String opportunityName; + + @Schema(description = "类型") + private String type; + + @Schema(description = "线索id") + private String clueId; + + @Schema(description = "线索名称") + private String clueName; + + @Schema(description = "跟进内容") + private String content; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "跟进时间") + private Long followTime; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "联系人") + private String contactId; + + @Schema(description = "联系人名称") + private String contactName; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人") + private String updateUser; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "归属部门") + private String departmentId; + + @Schema(description = "归属部门名称") + private String departmentName; + + @Schema(description = "电话") + private String phone; + + @Schema(description = "跟进方式") + private String followMethod; + + @Schema(description = "属于公海或者线索池") + private String poolId; + + @Schema(description = "资源类型") + private String resourceType; + + @Schema(description = "自定义字段集合") + private List moduleFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/mapper/ExtFollowUpPlanMapper.java b/backend/crm/src/main/java/cn/cordys/crm/follow/mapper/ExtFollowUpPlanMapper.java new file mode 100644 index 0000000..0181c33 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/mapper/ExtFollowUpPlanMapper.java @@ -0,0 +1,55 @@ +package cn.cordys.crm.follow.mapper; + +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.crm.customer.dto.request.CustomerMergeRequest; +import cn.cordys.crm.follow.domain.FollowUpPlan; +import cn.cordys.crm.follow.dto.CustomerDataDTO; +import cn.cordys.crm.follow.dto.request.FollowUpPlanPageRequest; +import cn.cordys.crm.follow.dto.request.PlanHomePageRequest; +import cn.cordys.crm.follow.dto.response.FollowUpPlanListResponse; +import cn.cordys.crm.home.dto.request.HomeStatisticSearchWrapperRequest; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtFollowUpPlanMapper { + + + List selectList(@Param("request") FollowUpPlanPageRequest request, @Param("userId") String userId, @Param("orgId") String orgId, + @Param("resourceType") String resourceType, @Param("type") String type, @Param("resourceTypeList") List resourceTypeList); + + /** + * 计划的汇总查询 + * + * @param request 请求参数 + * @param userId 用户ID + * @param orgId 组织ID + * @param clueDataPermission 线索数据权限 + * @param customerDataPermission 客户数据权限 + * + * @return 记录列表 + */ + List selectTotalList(@Param("request") PlanHomePageRequest request, @Param("userId") String userId, @Param("orgId") String orgId, + @Param("clueDataPermission") DeptDataPermissionDTO clueDataPermission, @Param("customerDataPermission") DeptDataPermissionDTO customerDataPermission); + + List selectPlanByTimestamp(@Param("timestamp") long timestamp); + + Long getNewFollowUpPlan(@Param("request") HomeStatisticSearchWrapperRequest request); + + /** + * 批量合并客户(商机)记录 + * + * @param request 请求参数 + * @param userId 用户ID + * @param orgId 组织ID + */ + void batchMerge(@Param("request") CustomerMergeRequest request, @Param("userId") String userId, @Param("orgId") String orgId); + + /** + * 获取待合并的计划列表 + * @param request 请求参数 + * @param orgId 组织ID + * @return 计划列表 + */ + List getMergePlanList(@Param("request") CustomerMergeRequest request, @Param("orgId") String orgId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/mapper/ExtFollowUpPlanMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/follow/mapper/ExtFollowUpPlanMapper.xml new file mode 100644 index 0000000..02ebbd4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/mapper/ExtFollowUpPlanMapper.xml @@ -0,0 +1,553 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LEFT JOIN follow_up_plan_field_blob ${tablePrefix}_${i} + ON fup.id = ${tablePrefix}_${i}.resource_id AND ${tablePrefix}_${i}.field_id = #{condition.name} + + + LEFT JOIN follow_up_plan_field ${tablePrefix}_${i} + ON fup.id = ${tablePrefix}_${i}.resource_id AND ${tablePrefix}_${i}.field_id = #{condition.name} + + + LEFT JOIN sys_organization_user sou ON fup.owner = sou.user_id + + + LEFT JOIN customer_contact cc ON fup.contact_id = cc.id + + + + + + + + + + + + LEFT JOIN follow_up_plan_field sort_table ON fup.id = sort_table.resource_id AND sort_table.field_id = #{sortName} + LEFT JOIN sys_module_field module_table ON sort_table.field_id = module_table.id + + LEFT JOIN sys_organization_user sou on sou.user_id = fup.owner + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + order by + + + fup.`customer_id` ${sortType}, fup.id ASC + + + fup.`opportunity_id` ${sortType}, fup.id ASC + + + fup.`clue_id` ${sortType}, fup.id ASC + + + fup.`contact_id` ${sortType}, fup.id ASC + + + fup.`type` ${sortType}, fup.id ASC + + + fup.`owner` ${sortType}, fup.id ASC + + + fup.`method` ${sortType}, fup.id ASC + + + fup.`status` ${sortType}, fup.id ASC + + + fup.`converted` ${sortType}, fup.id ASC + + + fup.`estimated_time` ${sortType}, fup.id ASC + + + fup.`create_time` ${sortType}, fup.id ASC + + + fup.`update_time` ${sortType}, fup.id ASC + + + fup.`create_user` ${sortType}, fup.id ASC + + + fup.`update_user` ${sortType}, fup.id ASC + + + sou.`id` ${sortType}, c.id ASC + + + CASE + WHEN module_table.type = 'INPUT_NUMBER' OR module_table.type = 'FORMULA' + THEN sort_table.field_value+0 + END ${sortType}, + sort_table.field_value ${sortType}, + fup.id ${sortType} + + + + + order by fup.estimated_time desc, fup.create_time desc, fup.id ASC + + + + + + update follow_up_plan set customer_id = #{request.toMergeId}, update_user = #{userId}, update_time = unix_timestamp() * 1000 + where customer_id in + + #{id} + + and organization_id = #{orgId} + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/mapper/ExtFollowUpRecordMapper.java b/backend/crm/src/main/java/cn/cordys/crm/follow/mapper/ExtFollowUpRecordMapper.java new file mode 100644 index 0000000..b86cadc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/mapper/ExtFollowUpRecordMapper.java @@ -0,0 +1,58 @@ +package cn.cordys.crm.follow.mapper; + +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.crm.customer.dto.request.CustomerMergeRequest; +import cn.cordys.crm.follow.domain.FollowUpRecord; +import cn.cordys.crm.follow.dto.CustomerDataDTO; +import cn.cordys.crm.follow.dto.request.FollowUpRecordPageRequest; +import cn.cordys.crm.follow.dto.request.RecordHomePageRequest; +import cn.cordys.crm.follow.dto.response.FollowUpRecordListResponse; +import cn.cordys.crm.home.dto.request.HomeStatisticSearchWrapperRequest; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtFollowUpRecordMapper { + + List selectPoolList(@Param("request") FollowUpRecordPageRequest request, @Param("userId") String userId, @Param("orgId") String orgId, @Param("resourceType") String resourceType, @Param("type") String type); + + + List selectList(@Param("request") FollowUpRecordPageRequest request, @Param("userId") String userId, @Param("orgId") String orgId, + @Param("resourceType") String resourceType, @Param("type") String type); + + /** + * 记录的汇总查询 + * + * @param request 请求参数 + * @param userId 用户ID + * @param orgId 组织ID + * @param clueDataPermission 线索业务数据权限 + * @param customerDataPermission 客户业务数据权限 + * + * @return 记录列表 + */ + List selectTotalList(@Param("request") RecordHomePageRequest request, @Param("userId") String userId, @Param("orgId") String orgId, + @Param("clueDataPermission") DeptDataPermissionDTO clueDataPermission, @Param("customerDataPermission") DeptDataPermissionDTO customerDataPermission); + + FollowUpRecord selectRecord(@Param("customerId") String customerId, @Param("opportunityId") String opportunityId, @Param("clueId") String clueId, @Param("orgId") String orgId, @Param("type") String type); + + Long getNewContactCount(@Param("request") HomeStatisticSearchWrapperRequest request); + + /** + * 批量合并客户(商机)记录 + * + * @param request 请求参数 + * @param userId 用户ID + * @param orgId 组织ID + */ + void batchMerge(@Param("request") CustomerMergeRequest request, @Param("userId") String userId, @Param("orgId") String orgId); + + /** + * 获取待合并的记录列表 + * @param request 请求参数 + * @param orgId 组织ID + * @return 记录列表 + */ + List getMergeRecordList(@Param("request") CustomerMergeRequest request, @Param("orgId") String orgId); + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/mapper/ExtFollowUpRecordMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/follow/mapper/ExtFollowUpRecordMapper.xml new file mode 100644 index 0000000..ea2fc13 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/mapper/ExtFollowUpRecordMapper.xml @@ -0,0 +1,575 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LEFT JOIN follow_up_record_field_blob ${tablePrefix}_${i} + ON fur.id = ${tablePrefix}_${i}.resource_id AND ${tablePrefix}_${i}.field_id = #{condition.name} + + + LEFT JOIN follow_up_record_field ${tablePrefix}_${i} + ON fur.id = ${tablePrefix}_${i}.resource_id AND ${tablePrefix}_${i}.field_id = #{condition.name} + + + LEFT JOIN sys_organization_user sou ON fur.owner = sou.user_id + + + LEFT JOIN customer_contact cc ON fur.contact_id = cc.id + + + + + + + + + + + + + + LEFT JOIN follow_up_record_field_blob ${tablePrefix}_${i} + ON fur.id = ${tablePrefix}_${i}.resource_id AND ${tablePrefix}_${i}.field_id = #{condition.name} + + + LEFT JOIN follow_up_record_field ${tablePrefix}_${i} + ON fur.id = ${tablePrefix}_${i}.resource_id AND ${tablePrefix}_${i}.field_id = #{condition.name} + + + LEFT JOIN sys_organization_user sou ON fur.owner = sou.user_id + + + LEFT JOIN customer_contact cc ON fur.contact_id = cc.id + + + + + + + + + + LEFT JOIN follow_up_record_field sort_table ON fur.id = sort_table.resource_id AND sort_table.field_id = #{sortName} + LEFT JOIN sys_module_field module_table ON sort_table.field_id = module_table.id + + LEFT JOIN sys_organization_user sou on sou.user_id = fur.owner + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + order by + + + fur.`customer_id` ${sortType}, fur.id ASC + + + fur.`opportunity_id` ${sortType}, fur.id ASC + + + fur.`clue_id` ${sortType}, fur.id ASC + + + fur.`contact_id` ${sortType}, fur.id ASC + + + fur.`type` ${sortType}, fur.id ASC + + + fur.`owner` ${sortType}, fur.id ASC + + + fur.`follow_method` ${sortType}, fur.id ASC + + + fur.`follow_time` ${sortType}, fur.id ASC + + + fur.`create_time` ${sortType}, fur.id ASC + + + fur.`update_time` ${sortType}, fur.id ASC + + + fur.`create_user` ${sortType}, fur.id ASC + + + fur.`update_user` ${sortType}, fur.id ASC + + + sou.`id` ${sortType}, c.id ASC + + + CASE + WHEN module_table.type = 'INPUT_NUMBER' OR module_table.type = 'FORMULA' + THEN sort_table.field_value+0 + END ${sortType}, + sort_table.field_value ${sortType}, + fur.id ${sortType} + + + + + order by fur.follow_time desc, fur.create_time desc , fur.id ASC + + + + + + update follow_up_record set customer_id = #{request.toMergeId}, update_user = #{userId}, update_time = unix_timestamp() * 1000 + where customer_id in + + #{id} + + and organization_id = #{orgId} + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/service/BaseFollowUpService.java b/backend/crm/src/main/java/cn/cordys/crm/follow/service/BaseFollowUpService.java new file mode 100644 index 0000000..37931d4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/service/BaseFollowUpService.java @@ -0,0 +1,130 @@ +package cn.cordys.crm.follow.service; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.customer.constants.CustomerCollaborationType; +import cn.cordys.crm.customer.domain.CustomerCollaboration; +import cn.cordys.crm.customer.service.CustomerCollaborationService; +import cn.cordys.crm.follow.dto.CustomerDataDTO; +import cn.cordys.crm.system.domain.OrganizationUser; +import cn.cordys.crm.system.mapper.ExtOrganizationUserMapper; +import cn.cordys.security.SessionUtils; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class BaseFollowUpService { + + @Resource + private CustomerCollaborationService customerCollaborationService; + @Resource + private DataScopeService dataScopeService; + @Resource + private ExtOrganizationUserMapper extOrganizationUserMapper; + + public CustomerDataDTO getCustomerPermission(String userId, String sourceId, String permission) { + CustomerDataDTO customerDataDTO = new CustomerDataDTO(); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), permission); + + // 全部数据 + if (deptDataPermission.getAll() || Strings.CI.equalsAny(userId, InternalUser.ADMIN.getValue())) { + customerDataDTO.setAll(true); + return customerDataDTO; + } + + + // 查询协作人信息 + List collaborations = customerCollaborationService.selectByCustomerId(sourceId); + + List userList = collaborations.stream() + .filter(collaboration -> Strings.CS.equals(collaboration.getUserId(), userId)) + .toList(); + + if (CollectionUtils.isNotEmpty(userList)) { + CustomerCollaboration first = userList.getFirst(); + if (Strings.CS.equals(first.getCollaborationType(), CustomerCollaborationType.READ_ONLY.name())) { + customerDataDTO.setOwner(true); + } + } else { + // 不是协作人 + customerDataDTO.setOwner(true); + } + + + // 获取协作类型的协作的联系人 + Set collaborationUserIds = collaborations.stream() + .filter(collaboration -> Strings.CS.equals(collaboration.getCollaborationType(), CustomerCollaborationType.COLLABORATION.name())) + .map(CustomerCollaboration::getUserId) + .collect(Collectors.toSet()); + + + // 部门数据权限 + if (CollectionUtils.isNotEmpty(deptDataPermission.getDeptIds())) { + List users = new ArrayList<>(); + if (CollectionUtils.isNotEmpty(collaborationUserIds)) { + users = extOrganizationUserMapper.selectUserByUserIds(new ArrayList<>(collaborationUserIds)); + } + List currentUser = extOrganizationUserMapper.selectUserByUserIds(List.of(userId)); + if (customerDataDTO.isOwner()) { + List depUsers = users.stream() + .filter(user -> !deptDataPermission.getDeptIds().contains(user.getDepartmentId())) + .toList(); + if (CollectionUtils.isNotEmpty(depUsers)) { + customerDataDTO.setUserIds(depUsers.stream().map(OrganizationUser::getUserId).toList()); + } + } else { + List depUsers = users.stream() + .filter(user -> !deptDataPermission.getDeptIds().contains(user.getDepartmentId())) + .toList(); + List ids = depUsers.stream().map(OrganizationUser::getUserId).toList(); + List userIds = new ArrayList<>(); + if (!deptDataPermission.getDeptIds().contains(currentUser.getFirst().getDepartmentId())) { + userIds.add(currentUser.getFirst().getUserId()); + } + userIds.addAll(ids); + customerDataDTO.setUserIds(userIds); + } + + } + + if (deptDataPermission.getSelf()) { + if (customerDataDTO.isOwner()) { + customerDataDTO.setUserIds(new ArrayList<>(collaborationUserIds)); + } else { + customerDataDTO.setSelf(true); + } + } + + return customerDataDTO; + } + + public CustomerDataDTO getOpportunityPermission(String userId, String permission) { + CustomerDataDTO customerDataDTO = new CustomerDataDTO(); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), permission); + + // 全部数据 + if (deptDataPermission.getAll() || Strings.CI.equalsAny(userId, InternalUser.ADMIN.getValue())) { + customerDataDTO.setAll(true); + return customerDataDTO; + } + + if (deptDataPermission.getSelf()) { + customerDataDTO.setSelf(true); + } + return customerDataDTO; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpPlanFieldService.java b/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpPlanFieldService.java new file mode 100644 index 0000000..8d52e9d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpPlanFieldService.java @@ -0,0 +1,35 @@ +package cn.cordys.crm.follow.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.crm.follow.domain.FollowUpPlanField; +import cn.cordys.crm.follow.domain.FollowUpPlanFieldBlob; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(rollbackFor = Exception.class) +public class FollowUpPlanFieldService extends BaseResourceFieldService { + + @Resource + private BaseMapper followUpPlanFieldMapper; + @Resource + private BaseMapper followUpPlanFieldBlobMapper; + + @Override + protected String getFormKey() { + return FormKey.FOLLOW_PLAN.getKey(); + } + + @Override + protected BaseMapper getResourceFieldMapper() { + return followUpPlanFieldMapper; + } + + @Override + protected BaseMapper getResourceFieldBlobMapper() { + return followUpPlanFieldBlobMapper; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpPlanLogService.java b/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpPlanLogService.java new file mode 100644 index 0000000..7bbc4ab --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpPlanLogService.java @@ -0,0 +1,81 @@ +package cn.cordys.crm.follow.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.system.service.BaseModuleLogService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.text.SimpleDateFormat; +import java.util.List; + +@Service +@Transactional(rollbackFor = Exception.class) +public class FollowUpPlanLogService extends BaseModuleLogService { + + @Resource + private BaseMapper customerMapper; + + @Override + public List handleLogField(List differences, String orgId) { + differences = super.handleModuleLogField(differences, orgId, FormKey.FOLLOW_PLAN.getKey()); + + for (JsonDifferenceDTO differ : differences) { + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.FOLLOW_PLAN_CUSTOMER.getBusinessKey())) { + if (differ.getOldValue() != null) { + Customer customer = customerMapper.selectByPrimaryKey(differ.getOldValue().toString()); + if (customer != null) { + differ.setOldValueName(customer.getName()); + } + } + if (differ.getNewValue() != null) { + Customer customer = customerMapper.selectByPrimaryKey(differ.getNewValue().toString()); + if (customer != null) { + differ.setNewValueName(customer.getName()); + } + } + continue; + } + + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.FOLLOW_PLAN_CLUE.getBusinessKey())) { + setClueName(differ); + continue; + } + + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.FOLLOW_PLAN_CONTENT.getBusinessKey())) { + differ.setColumnName(Translator.get("log.follow_record_content")); + continue; + } + + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.FOLLOW_PLAN_OWNER.getBusinessKey())) { + setUserFieldName(differ); + continue; + } + + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.FOLLOW_PLAN_CONTACT.getBusinessKey())) { + setContactFieldName(differ); + continue; + } + + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.FOLLOW_PLAN_ESTIMATED_TIME.getBusinessKey())) { + differ.setOldValueName(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(Long.parseLong(differ.getOldValue().toString()))); + differ.setNewValueName(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(Long.parseLong(differ.getNewValue().toString()))); + continue; + } + + + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.FOLLOW_PLAN_OPPORTUNITY.getBusinessKey())) { + setOpportunityName(differ); + } + + } + return differences; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpPlanResourceAccessContextProvider.java b/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpPlanResourceAccessContextProvider.java new file mode 100644 index 0000000..8ee3293 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpPlanResourceAccessContextProvider.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.follow.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.permission.ResourceAccessContext; +import cn.cordys.common.permission.ResourceAccessContextProvider; +import cn.cordys.crm.follow.domain.FollowUpPlan; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class FollowUpPlanResourceAccessContextProvider implements ResourceAccessContextProvider { + + @Resource + private BaseMapper followUpPlanMapper; + + @Override + public String getFormType() { + return FormKey.FOLLOW_PLAN.getKey(); + } + + @Override + public ResourceAccessContext getAccessContext(String resourceId, String orgId) { + FollowUpPlan plan = followUpPlanMapper.selectByPrimaryKey(resourceId); + if (plan == null) { + return null; + } + ResourceAccessContext context = new ResourceAccessContext(); + context.setOwnerId(plan.getOwner()); + return context; + } + + @Override + public Map batchGetOwnerIds(List resourceIds, String orgId) { + if (resourceIds == null || resourceIds.isEmpty()) { + return Map.of(); + } + return followUpPlanMapper.selectByIds(resourceIds).stream() + .filter(plan -> plan.getOwner() != null) + .collect(Collectors.toMap(FollowUpPlan::getId, FollowUpPlan::getOwner, (a, b) -> a)); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpPlanService.java b/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpPlanService.java new file mode 100644 index 0000000..0fe11ee --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpPlanService.java @@ -0,0 +1,468 @@ +package cn.cordys.crm.follow.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.ModuleKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.common.dto.RolePermissionDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.permission.PermissionUtils; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.follow.constants.FollowUpPlanStatusType; +import cn.cordys.crm.follow.constants.FollowUpPlanType; +import cn.cordys.crm.follow.domain.FollowUpPlan; +import cn.cordys.crm.follow.dto.CustomerDataDTO; +import cn.cordys.crm.follow.dto.request.*; +import cn.cordys.crm.follow.dto.response.FollowUpPlanDetailResponse; +import cn.cordys.crm.follow.dto.response.FollowUpPlanListResponse; +import cn.cordys.crm.follow.mapper.ExtFollowUpPlanMapper; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.dto.response.UserResponse; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Stream; + +@Service +@Transactional(rollbackFor = Exception.class) +public class FollowUpPlanService extends BaseFollowUpService { + + @Resource + private BaseMapper followUpPlanMapper; + @Resource + private FollowUpPlanFieldService followUpPlanFieldService; + @Resource + private ExtFollowUpPlanMapper extFollowUpPlanMapper; + @Resource + private BaseService baseService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private PermissionCache permissionCache; + + /** + * 新建跟进计划 + * + * @param request + * @param userId + * @param orgId + * + * @return + */ + public FollowUpPlan add(FollowUpPlanAddRequest request, String userId, String orgId) { + FollowUpPlan followUpPlan = BeanUtils.copyBean(new FollowUpPlan(), request); + followUpPlan.setCreateTime(System.currentTimeMillis()); + followUpPlan.setUpdateTime(System.currentTimeMillis()); + followUpPlan.setUpdateUser(userId); + followUpPlan.setCreateUser(userId); + followUpPlan.setId(IDGenerator.nextStr()); + followUpPlan.setOrganizationId(orgId); + followUpPlan.setStatus(FollowUpPlanStatusType.PREPARED.name()); + followUpPlan.setConverted(false); + if (StringUtils.isBlank(request.getOwner())) { + followUpPlan.setOwner(userId); + } + //保存自定义字段 + followUpPlanFieldService.saveModuleField(followUpPlan, orgId, userId, request.getModuleFields(), false); + followUpPlanMapper.insert(followUpPlan); + return followUpPlan; + } + + + /** + * 更新跟进计划 + * + * @param request + * @param userId + * @param orgId + * + * @return + */ + @OperationLog(module = LogModule.FOLLOW_UP_PLAN, type = LogType.UPDATE, resourceId = "{#request.id}") + public FollowUpPlan update(FollowUpPlanUpdateRequest request, String userId, String orgId) { + FollowUpPlan followUpPlan = followUpPlanMapper.selectByPrimaryKey(request.getId()); + Optional.ofNullable(followUpPlan).ifPresentOrElse(plan -> { + //更新跟进计划 + FollowUpPlan newPlan = BeanUtils.copyBean(new FollowUpPlan(), plan); + FollowUpPlan updateFollowUpPlan = newPlan(newPlan, request, userId); + // 获取模块字段 + List originCustomerFields = followUpPlanFieldService.getModuleFieldValuesByResourceId(request.getId()); + //更新模块字段 + updateModuleField(updateFollowUpPlan, request.getModuleFields(), orgId, userId); + followUpPlanMapper.update(updateFollowUpPlan); + baseService.handleUpdateLog(followUpPlan, updateFollowUpPlan, originCustomerFields, request.getModuleFields(), followUpPlan.getId(), Translator.get("update_follow_up_plan")); + }, () -> { + throw new GenericException("plan_not_found"); + }); + return followUpPlan; + } + + private void updateModuleField(FollowUpPlan followUpPlan, List moduleFields, String orgId, String userId) { + if (moduleFields == null) { + // 如果为 null,则不更新 + return; + } + // 先删除 + followUpPlanFieldService.deleteByResourceId(followUpPlan.getId()); + // 再保存 + followUpPlanFieldService.saveModuleField(followUpPlan, orgId, userId, moduleFields, true); + } + + private FollowUpPlan newPlan(FollowUpPlan plan, FollowUpPlanUpdateRequest request, String userId) { + plan.setCustomerId(request.getCustomerId()); + plan.setOpportunityId(request.getOpportunityId()); + plan.setType(request.getType()); + plan.setClueId(request.getClueId()); + plan.setOwner(request.getOwner()); + plan.setContactId(request.getContactId()); + plan.setContent(request.getContent()); + plan.setMethod(request.getMethod()); + plan.setEstimatedTime(request.getEstimatedTime()); + plan.setUpdateTime(System.currentTimeMillis()); + plan.setUpdateUser(userId); + plan.setConverted(request.getConverted()); + return plan; + } + + + /** + * 跟进计划列表查询 + * + * @param request + * @param userId + * @param orgId + * @param resourceType + * @param type + * + * @return + */ + public PagerWithOption> list(FollowUpPlanPageRequest request, String userId, String orgId, String resourceType, String type) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extFollowUpPlanMapper.selectList(request, userId, orgId, resourceType, type, null); + List buildList = buildListData(list, orgId); + Map> optionMap = buildOptionMap(orgId, list, buildList); + return PageUtils.setPageInfoWithOption(page, buildList, optionMap); + } + + /** + * 跟进计划汇总查询 + * + * @param request 请求参数 + * @param userId 用户ID + * @param orgId 组织ID + * @param clueDataPermission 线索数据权限 + * @param customerDataPermission 客户数据权限 + * + * @return 跟进计划汇总列表 + */ + public PagerWithOption> totalList(PlanHomePageRequest request, String userId, String orgId, + DeptDataPermissionDTO clueDataPermission, DeptDataPermissionDTO customerDataPermission) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + ConditionFilterUtils.parseCondition(request, FormKey.FOLLOW_PLAN.getKey()); + List list = extFollowUpPlanMapper.selectTotalList(request, userId, orgId, clueDataPermission, customerDataPermission); + List buildList = buildListData(list, orgId); + Map> optionMap = buildOptionMap(orgId, list, buildList); + return PageUtils.setPageInfoWithOption(page, buildList, optionMap); + } + + + public Map> buildOptionMap(String orgId, List list, List buildList) { + // 处理自定义字段选项数据 + ModuleFormConfigDTO customerFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.FOLLOW_PLAN.getKey(), orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, FollowUpPlanListResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, moduleFieldValues); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(buildList, + FollowUpPlanListResponse::getOwner, FollowUpPlanListResponse::getOwnerName); + optionMap.put(BusinessModuleField.OPPORTUNITY_OWNER.getBusinessKey(), ownerFieldOption); + + // 联系人 + List contactFieldOption = moduleFormService.getBusinessFieldOption(buildList, + FollowUpPlanListResponse::getContactId, FollowUpPlanListResponse::getContactName); + optionMap.put(BusinessModuleField.OPPORTUNITY_CONTACT.getBusinessKey(), contactFieldOption); + return optionMap; + } + + public List buildListData(List list, String orgId) { + List ids = list.stream().map(FollowUpPlanListResponse::getId).toList(); + Map> resourceFieldMap = followUpPlanFieldService.getResourceFieldMap(ids, true); + Map> resolveFvMap = followUpPlanFieldService.setBusinessRefFieldValue(list, moduleFormService.getFlattenFormFields(FormKey.FOLLOW_PLAN.getKey(), orgId), resourceFieldMap); + + List createUserIds = list.stream().map(FollowUpPlanListResponse::getCreateUser).toList(); + List updateUserIds = list.stream().map(FollowUpPlanListResponse::getUpdateUser).toList(); + List ownerIds = list.stream().map(FollowUpPlanListResponse::getOwner).toList(); + List userIds = Stream.of(createUserIds, updateUserIds, ownerIds) + .flatMap(Collection::stream) + .distinct() + .toList(); + Map userNameMap = baseService.getUserNameMap(userIds); + + List contactIds = list.stream().map(FollowUpPlanListResponse::getContactId).toList(); + Map contactMap = baseService.getContactMap(contactIds); + Map contactPhoneMap = baseService.getContactPhone(contactIds); + + Map userDeptMap = baseService.getUserDepAndPhoneByUserIds(ownerIds, orgId); + + List customerIds = list.stream().map(FollowUpPlanListResponse::getCustomerId).toList(); + Map customerMap = baseService.getCustomerMap(customerIds); + + List opportunityIds = list.stream().map(FollowUpPlanListResponse::getOpportunityId).toList(); + Map opportunityMap = baseService.getOpportunityMap(opportunityIds); + + List clueIds = list.stream().map(FollowUpPlanListResponse::getClueId).toList(); + Map clueMap = baseService.getClueMap(clueIds); + + list.forEach(planResponse -> { + // 获取自定义字段 + List planCustomerFields = resolveFvMap.get(planResponse.getId()); + planResponse.setModuleFields(planCustomerFields); + planResponse.setCreateUserName(userNameMap.get(planResponse.getCreateUser())); + planResponse.setUpdateUserName(userNameMap.get(planResponse.getUpdateUser())); + planResponse.setOwnerName(userNameMap.get(planResponse.getOwner())); + planResponse.setContactName(contactMap.get(planResponse.getContactId())); + planResponse.setCustomerName(customerMap.get(planResponse.getCustomerId())); + planResponse.setOpportunityName(opportunityMap.get(planResponse.getOpportunityId())); + planResponse.setClueName(clueMap.get(planResponse.getClueId())); + planResponse.setPhone(contactPhoneMap.get(planResponse.getContactId())); + planResponse.setResourceType(planResponse.getType()); + + UserResponse userResponse = userDeptMap.get(planResponse.getOwner()); + if (userResponse != null) { + planResponse.setDepartmentId(userResponse.getDepartmentId()); + planResponse.setDepartmentName(userResponse.getDepartmentName()); + } + }); + return list; + } + + + /** + * 跟进计划详情 + * + * @param id + * + * @return + */ + public FollowUpPlanDetailResponse get(String id, String orgId) { + FollowUpPlan followUpPlan = followUpPlanMapper.selectByPrimaryKey(id); + FollowUpPlanDetailResponse response = BeanUtils.copyBean(new FollowUpPlanDetailResponse(), followUpPlan); + List buildList = buildListData(List.of(response), orgId); + + ModuleFormConfigDTO customerFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.FOLLOW_PLAN.getKey(), orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(List.of(response), FollowUpPlanDetailResponse::getModuleFields); + moduleFieldValues = followUpPlanFieldService.setBusinessRefFieldValue(List.of(response), + moduleFormService.getFlattenFormFields(FormKey.FOLLOW_PLAN.getKey(), followUpPlan.getOrganizationId()), new HashMap<>(Map.of(id, moduleFieldValues))).get(id); + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, moduleFieldValues); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(buildList, + FollowUpPlanListResponse::getOwner, FollowUpPlanListResponse::getOwnerName); + optionMap.put(BusinessModuleField.FOLLOW_PLAN_OWNER.getBusinessKey(), ownerFieldOption); + + // 联系人 + List contactFieldOption = moduleFormService.getBusinessFieldOption(buildList, + FollowUpPlanListResponse::getContactId, FollowUpPlanListResponse::getContactName); + optionMap.put(BusinessModuleField.FOLLOW_PLAN_CONTACT.getBusinessKey(), contactFieldOption); + + //客户 + List customerOption = moduleFormService.getBusinessFieldOption(buildList, + FollowUpPlanListResponse::getCustomerId, FollowUpPlanListResponse::getCustomerName); + optionMap.put(BusinessModuleField.FOLLOW_PLAN_CUSTOMER.getBusinessKey(), customerOption); + + //商机 + List opportunityOption = moduleFormService.getBusinessFieldOption(buildList, + FollowUpPlanListResponse::getOpportunityId, FollowUpPlanListResponse::getOpportunityName); + optionMap.put(BusinessModuleField.FOLLOW_PLAN_OPPORTUNITY.getBusinessKey(), opportunityOption); + + //线索 + List clueOption = moduleFormService.getBusinessFieldOption(buildList, + FollowUpPlanListResponse::getClueId, FollowUpPlanListResponse::getClueName); + optionMap.put(BusinessModuleField.FOLLOW_PLAN_CLUE.getBusinessKey(), clueOption); + + + response.setOptionMap(optionMap); + + // 附件信息 + response.setAttachmentMap(moduleFormService.getAttachmentMap(customerFormConfig, moduleFieldValues)); + return response; + } + + + /** + * 取消跟进计划 + * + * @param id + */ + @OperationLog(module = LogModule.FOLLOW_UP_PLAN, type = LogType.CANCEL, resourceId = "{#id}") + public void cancelPlan(String id, String operator) { + FollowUpPlan followUpPlan = followUpPlanMapper.selectByPrimaryKey(id); + if (followUpPlan == null) { + throw new GenericException("plan_not_found"); + } + //检查跟进计划是否可以取消,如果是已完成,并且已转记录的跟进计划,则不允许取消 + if (followUpPlan.getStatus().equals(FollowUpPlanStatusType.COMPLETED.name()) && followUpPlan.getConverted()) { + return; + } + FollowUpPlan plan = new FollowUpPlan(); + plan.setId(followUpPlan.getId()); + plan.setStatus(FollowUpPlanStatusType.CANCELLED.name()); + plan.setUpdateUser(operator); + plan.setUpdateTime(System.currentTimeMillis()); + followUpPlanMapper.updateById(plan); + OperationLogContext.setResourceName(Translator.get("cancel_follow_up_plan")); + } + + + /** + * 删除跟进计划 + * + * @param id + */ + @OperationLog(module = LogModule.FOLLOW_UP_PLAN, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + FollowUpPlan followUpPlan = followUpPlanMapper.selectByPrimaryKey(id); + if (followUpPlan == null) { + throw new GenericException("plan_not_found"); + } + deleteByIds(List.of(id)); + + // 设置操作对象 + OperationLogContext.setResourceName(Translator.get("delete_follow_up_plan")); + } + + + /** + * 更新状态 + * + * @param request + * @param userId + */ + public void updateStatus(FollowUpPlanStatusRequest request, String userId) { + //检查跟进计划是否可以更新状态,如果是已完成,并且已转记录的跟进计划,则不允许更新状态 + FollowUpPlan followUpPlan = followUpPlanMapper.selectByPrimaryKey(request.getId()); + if (followUpPlan == null) { + throw new GenericException("plan_not_found"); + } + if (followUpPlan.getStatus().equals(FollowUpPlanStatusType.COMPLETED.name()) && followUpPlan.getConverted()) { + return; + } + followUpPlan = new FollowUpPlan(); + followUpPlan.setStatus(request.getStatus()); + followUpPlan.setId(request.getId()); + followUpPlan.setUpdateUser(userId); + followUpPlan.setUpdateTime(System.currentTimeMillis()); + followUpPlanMapper.update(followUpPlan); + } + + public void deleteByCustomerIds(List customerIds) { + if (CollectionUtils.isEmpty(customerIds)) { + return; + } + List ids = getByCustomerIds(customerIds) + .stream() + .map(FollowUpPlan::getId) + .toList(); + deleteByIds(ids); + } + + private void deleteByIds(List ids) { + if (ids.isEmpty()) { + return; + } + followUpPlanFieldService.deleteByResourceIds(ids); + followUpPlanMapper.deleteByIds(ids); + } + + private List getByCustomerIds(List customerIds) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.in(FollowUpPlan::getCustomerId, customerIds); + return followUpPlanMapper.selectListByLambda(queryWrapper); + } + + private List getByClueIds(List clueIds) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.in(FollowUpPlan::getClueId, clueIds); + queryWrapper.eq(FollowUpPlan::getType, FollowUpPlanType.CLUE.name()); + return followUpPlanMapper.selectListByLambda(queryWrapper); + } + + public void deleteByClueIds(List clueIds) { + if (CollectionUtils.isEmpty(clueIds)) { + return; + } + List ids = getByClueIds(clueIds) + .stream() + .map(FollowUpPlan::getId) + .toList(); + deleteByIds(ids); + } + + /** + * 获取跟进计划对应数据权限配置 + * + * @param currentUser 当前用户ID + * @param organizationId 组织ID + * + * @return 数据权限配置 + */ + public ResourceTabEnableDTO getTabEnableConfig(String currentUser, String organizationId) { + List rolePermissions = permissionCache.getRolePermissions(currentUser, organizationId); + // 由于统一的记录/计划页面会展示多个业务模块的数据,因此需要合并多个数据权限, 展示范围更大的数据权限配置 + ResourceTabEnableDTO clueTabConfig = PermissionUtils.getTabEnableConfig(currentUser, PermissionConstants.CLUE_MANAGEMENT_READ, rolePermissions); + ResourceTabEnableDTO customerTabConfig = PermissionUtils.getTabEnableConfig(currentUser, PermissionConstants.CUSTOMER_MANAGEMENT_READ, rolePermissions); + return clueTabConfig.or(customerTabConfig); + } + + /** + * 拦截跟进记录的操作权限 + * + * @param id 记录ID + * @param orgId 组织ID + */ + public void checkPlanPermission(String id, String orgId) { + FollowUpPlanDetailResponse planDetail = get(id, orgId); + boolean hasPermission; + if (Strings.CS.equals(planDetail.getType(), ModuleKey.CLUE.name())) { + hasPermission = PermissionUtils.hasPermission(PermissionConstants.CLUE_MANAGEMENT_UPDATE); + } else if (StringUtils.isNotEmpty(planDetail.getOpportunityId())) { + hasPermission = PermissionUtils.hasPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_UPDATE); + } else { + hasPermission = PermissionUtils.hasPermission(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE); + } + if (!hasPermission) { + throw new GenericException(Translator.get("no.operation.permission")); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpRecordFieldService.java b/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpRecordFieldService.java new file mode 100644 index 0000000..a76b06c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpRecordFieldService.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.follow.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.crm.follow.domain.FollowUpRecordField; +import cn.cordys.crm.follow.domain.FollowUpRecordFieldBlob; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class FollowUpRecordFieldService extends BaseResourceFieldService { + @Resource + private BaseMapper followUpRecordFieldMapper; + @Resource + private BaseMapper followUpRecordFieldBlobMapper; + + @Override + protected String getFormKey() { + return FormKey.FOLLOW_RECORD.getKey(); + } + + @Override + protected BaseMapper getResourceFieldMapper() { + return followUpRecordFieldMapper; + } + + @Override + protected BaseMapper getResourceFieldBlobMapper() { + return followUpRecordFieldBlobMapper; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpRecordLogService.java b/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpRecordLogService.java new file mode 100644 index 0000000..309fc75 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpRecordLogService.java @@ -0,0 +1,73 @@ +package cn.cordys.crm.follow.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.system.service.BaseModuleLogService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.text.SimpleDateFormat; +import java.util.List; + +@Service +@Transactional(rollbackFor = Exception.class) +public class FollowUpRecordLogService extends BaseModuleLogService { + + @Resource + private BaseMapper customerMapper; + + @Override + public List handleLogField(List differences, String orgId) { + differences = super.handleModuleLogField(differences, orgId, FormKey.FOLLOW_RECORD.getKey()); + + for (JsonDifferenceDTO differ : differences) { + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.FOLLOW_RECORD_CUSTOMER.getBusinessKey())) { + if (differ.getOldValue() != null) { + Customer customer = customerMapper.selectByPrimaryKey(differ.getOldValue().toString()); + if (customer != null) { + differ.setOldValueName(customer.getName()); + } + } + if (differ.getNewValue() != null) { + Customer customer = customerMapper.selectByPrimaryKey(differ.getNewValue().toString()); + if (customer != null) { + differ.setNewValueName(customer.getName()); + } + } + continue; + } + + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.FOLLOW_RECORD_CLUE.getBusinessKey())) { + setClueName(differ); + continue; + } + + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.FOLLOW_RECORD_CONTENT.getBusinessKey())) { + differ.setColumnName(Translator.get("log.follow_record_content")); + continue; + } + + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.FOLLOW_RECORD_OWNER.getBusinessKey())) { + setUserFieldName(differ); + continue; + } + + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.FOLLOW_RECORD_CONTACT.getBusinessKey())) { + setContactFieldName(differ); + continue; + } + + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.FOLLOW_RECORD_OPPORTUNITY.getBusinessKey())) { + setOpportunityName(differ); + } + } + return differences; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpRecordResourceAccessContextProvider.java b/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpRecordResourceAccessContextProvider.java new file mode 100644 index 0000000..aa7f209 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpRecordResourceAccessContextProvider.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.follow.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.permission.ResourceAccessContext; +import cn.cordys.common.permission.ResourceAccessContextProvider; +import cn.cordys.crm.follow.domain.FollowUpRecord; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class FollowUpRecordResourceAccessContextProvider implements ResourceAccessContextProvider { + + @Resource + private BaseMapper followUpRecordMapper; + + @Override + public String getFormType() { + return FormKey.FOLLOW_RECORD.getKey(); + } + + @Override + public ResourceAccessContext getAccessContext(String resourceId, String orgId) { + FollowUpRecord record = followUpRecordMapper.selectByPrimaryKey(resourceId); + if (record == null) { + return null; + } + ResourceAccessContext context = new ResourceAccessContext(); + context.setOwnerId(record.getOwner()); + return context; + } + + @Override + public Map batchGetOwnerIds(List resourceIds, String orgId) { + if (resourceIds == null || resourceIds.isEmpty()) { + return Map.of(); + } + return followUpRecordMapper.selectByIds(resourceIds).stream() + .filter(record -> record.getOwner() != null) + .collect(Collectors.toMap(FollowUpRecord::getId, FollowUpRecord::getOwner, (a, b) -> a)); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpRecordService.java b/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpRecordService.java new file mode 100644 index 0000000..482d84d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/follow/service/FollowUpRecordService.java @@ -0,0 +1,580 @@ +package cn.cordys.crm.follow.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.ModuleKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.common.dto.RolePermissionDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.permission.PermissionUtils; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.clue.domain.Clue; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.follow.constants.FollowUpPlanType; +import cn.cordys.crm.follow.domain.FollowUpRecord; +import cn.cordys.crm.follow.dto.CustomerDataDTO; +import cn.cordys.crm.follow.dto.request.FollowUpRecordAddRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordPageRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordUpdateRequest; +import cn.cordys.crm.follow.dto.request.RecordHomePageRequest; +import cn.cordys.crm.follow.dto.response.FollowUpRecordDetailResponse; +import cn.cordys.crm.follow.dto.response.FollowUpRecordListResponse; +import cn.cordys.crm.follow.mapper.ExtFollowUpRecordMapper; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.dto.response.UserResponse; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Stream; + +@Service +@Transactional(rollbackFor = Exception.class) +public class FollowUpRecordService extends BaseFollowUpService { + @Resource + private BaseMapper followUpRecordMapper; + @Resource + private FollowUpRecordFieldService followUpRecordFieldService; + @Resource + private ExtFollowUpRecordMapper extFollowUpRecordMapper; + @Resource + private BaseService baseService; + @Resource + private BaseMapper customerMapper; + @Resource + private BaseMapper opportunityMapper; + @Resource + private BaseMapper clueMapper; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private PermissionCache permissionCache; + + /** + * 添加跟进记录 + * + * @param request + * @param userId + * @param orgId + * + * @return + */ + public FollowUpRecord add(FollowUpRecordAddRequest request, String userId, String orgId) { + FollowUpRecord followUpRecord = BeanUtils.copyBean(new FollowUpRecord(), request); + long time = System.currentTimeMillis(); + followUpRecord.setCreateTime(time); + followUpRecord.setUpdateTime(time); + followUpRecord.setUpdateUser(userId); + followUpRecord.setCreateUser(userId); + followUpRecord.setId(IDGenerator.nextStr()); + followUpRecord.setOrganizationId(orgId); + if (StringUtils.isBlank(request.getOwner())) { + followUpRecord.setOwner(userId); + } + + //保存自定义字段 + followUpRecordFieldService.saveModuleField(followUpRecord, orgId, userId, request.getModuleFields(), false); + + followUpRecordMapper.insert(followUpRecord); + + handleFollowTimeAndFollower(request.getCustomerId(), request.getOpportunityId(), request.getClueId(), request.getFollowTime(), request.getOwner()); + return followUpRecord; + } + + + /** + * 处理最新跟进使时间&最新跟进人 + * + * @param customerId + * @param opportunityId + * @param clueId + * @param followTime + * @param owner + */ + private void handleFollowTimeAndFollower(String customerId, String opportunityId, String clueId, Long followTime, String owner) { + if (StringUtils.isNotBlank(customerId)) { + Customer customer = new Customer(); + customer.setId(customerId); + customer.setFollowTime(followTime); + customer.setFollower(owner); + customerMapper.update(customer); + } + + if (StringUtils.isNotBlank(opportunityId)) { + Opportunity opportunity = new Opportunity(); + opportunity.setId(opportunityId); + opportunity.setFollowTime(followTime); + opportunity.setFollower(owner); + opportunityMapper.update(opportunity); + } + + if (StringUtils.isNotBlank(clueId)) { + Clue clue = new Clue(); + clue.setId(clueId); + clue.setFollowTime(followTime); + clue.setFollower(owner); + clueMapper.update(clue); + } + } + + + /** + * 更新跟进记录 + * + * @param request + * @param userId + * + * @return + */ + @OperationLog(module = LogModule.FOLLOW_UP_RECORD, type = LogType.UPDATE, resourceId = "{#request.id}") + public FollowUpRecord update(FollowUpRecordUpdateRequest request, String userId, String orgId) { + FollowUpRecord followUpRecord = followUpRecordMapper.selectByPrimaryKey(request.getId()); + Optional.ofNullable(followUpRecord).ifPresentOrElse(record -> { + //更新跟进记录 + FollowUpRecord newRecord = BeanUtils.copyBean(new FollowUpRecord(), record); + FollowUpRecord updateFollowUpRecord = newRecord(newRecord, request, userId); + // 获取模块字段 + List originCustomerFields = followUpRecordFieldService.getModuleFieldValuesByResourceId(request.getId()); + //更新模块字段 + updateModuleField(updateFollowUpRecord, request.getModuleFields(), orgId, userId); + followUpRecordMapper.update(updateFollowUpRecord); + handleFollowTimeAndFollower(updateFollowUpRecord.getCustomerId(), updateFollowUpRecord.getOpportunityId(), updateFollowUpRecord.getClueId(), updateFollowUpRecord.getFollowTime(), updateFollowUpRecord.getOwner()); + baseService.handleUpdateLog(followUpRecord, updateFollowUpRecord, originCustomerFields, request.getModuleFields(), followUpRecord.getId(), Translator.get("update_follow_up_record")); + }, () -> { + throw new GenericException("record_not_found"); + }); + + return followUpRecord; + } + + private void updateModuleField(FollowUpRecord updateFollowUpRecord, List moduleFields, String orgId, String userId) { + if (moduleFields == null) { + // 如果为 null,则不更新 + return; + } + // 先删除 + followUpRecordFieldService.deleteByResourceId(updateFollowUpRecord.getId()); + // 再保存 + followUpRecordFieldService.saveModuleField(updateFollowUpRecord, orgId, userId, moduleFields, true); + } + + + private FollowUpRecord newRecord(FollowUpRecord record, FollowUpRecordUpdateRequest request, String userId) { + record.setCustomerId(request.getCustomerId()); + record.setOpportunityId(request.getOpportunityId()); + record.setType(request.getType()); + record.setClueId(request.getClueId()); + record.setOwner(request.getOwner()); + record.setContactId(request.getContactId()); + record.setFollowTime(request.getFollowTime()); + record.setFollowMethod(request.getFollowMethod()); + record.setContent(request.getContent()); + record.setUpdateTime(System.currentTimeMillis()); + record.setUpdateUser(userId); + return record; + } + + + /** + * 池/公海 跟进记录列表查询 + * + * @param request + * @param userId + * @param orgId + * @param resourceType + * @param type + * + * @return + */ + public PagerWithOption> poolList(FollowUpRecordPageRequest request, String userId, String orgId, String resourceType, String type) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extFollowUpRecordMapper.selectPoolList(request, userId, orgId, resourceType, type); + buildListData(list, orgId); + + // 处理自定义字段选项数据 + ModuleFormConfigDTO customerFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.FOLLOW_RECORD.getKey(), orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, FollowUpRecordListResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, moduleFieldValues); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(list, + FollowUpRecordListResponse::getOwner, FollowUpRecordListResponse::getOwnerName); + optionMap.put(BusinessModuleField.OPPORTUNITY_OWNER.getBusinessKey(), ownerFieldOption); + + // 联系人 + List contactFieldOption = moduleFormService.getBusinessFieldOption(list, + FollowUpRecordListResponse::getContactId, FollowUpRecordListResponse::getContactName); + optionMap.put(BusinessModuleField.OPPORTUNITY_CONTACT.getBusinessKey(), contactFieldOption); + + return PageUtils.setPageInfoWithOption(page, list, optionMap); + } + + private void buildListData(List list, String orgId) { + List ids = list.stream().map(FollowUpRecordListResponse::getId).toList(); + Map> recordCustomFieldMap = followUpRecordFieldService.getResourceFieldMap(ids, true); + Map> resolveFvMap = followUpRecordFieldService.setBusinessRefFieldValue(list, moduleFormService.getFlattenFormFields(FormKey.FOLLOW_RECORD.getKey(), orgId), recordCustomFieldMap); + + + List createUserIds = list.stream().map(FollowUpRecordListResponse::getCreateUser).toList(); + List updateUserIds = list.stream().map(FollowUpRecordListResponse::getUpdateUser).toList(); + List ownerIds = list.stream().map(FollowUpRecordListResponse::getOwner).toList(); + List userIds = Stream.of(createUserIds, updateUserIds, ownerIds) + .flatMap(Collection::stream) + .distinct() + .toList(); + Map userNameMap = baseService.getUserNameMap(userIds); + + List contactIds = list.stream().map(FollowUpRecordListResponse::getContactId).toList(); + Map contactMap = baseService.getContactMap(contactIds); + Map contactPhoneMap = baseService.getContactPhone(contactIds); + + Map userDeptMap = baseService.getUserDepAndPhoneByUserIds(ownerIds, orgId); + + List customerIds = list.stream().map(FollowUpRecordListResponse::getCustomerId).toList(); + Map customerMap = baseService.getCustomerMap(customerIds); + + List opportunityIds = list.stream().map(FollowUpRecordListResponse::getOpportunityId).toList(); + Map opportunityMap = baseService.getOpportunityMap(opportunityIds); + + List clueIds = list.stream().map(FollowUpRecordListResponse::getClueId).toList(); + Map clueMap = baseService.getClueMap(clueIds); + + list.forEach(recordListResponse -> { + // 获取自定义字段 + List customerFields = resolveFvMap.get(recordListResponse.getId()); + recordListResponse.setModuleFields(customerFields); + + recordListResponse.setCreateUserName(userNameMap.get(recordListResponse.getCreateUser())); + recordListResponse.setUpdateUserName(userNameMap.get(recordListResponse.getUpdateUser())); + recordListResponse.setOwnerName(userNameMap.get(recordListResponse.getOwner())); + recordListResponse.setContactName(contactMap.get(recordListResponse.getContactId())); + recordListResponse.setCustomerName(customerMap.get(recordListResponse.getCustomerId())); + recordListResponse.setOpportunityName(opportunityMap.get(recordListResponse.getOpportunityId())); + recordListResponse.setClueName(clueMap.get(recordListResponse.getClueId())); + recordListResponse.setPhone(contactPhoneMap.get(recordListResponse.getContactId())); + recordListResponse.setResourceType(recordListResponse.getType()); + + UserResponse userResponse = userDeptMap.get(recordListResponse.getOwner()); + if (userResponse != null) { + recordListResponse.setDepartmentId(userResponse.getDepartmentId()); + recordListResponse.setDepartmentName(userResponse.getDepartmentName()); + } + }); + } + + + /** + * 获取跟进记录详情 + * + * @param id + * + * @return + */ + public FollowUpRecordDetailResponse get(String id, String orgId) { + FollowUpRecord followUpRecord = followUpRecordMapper.selectByPrimaryKey(id); + if (followUpRecord == null) { + throw new GenericException(Translator.get("record_not_found")); + } + FollowUpRecordDetailResponse response = BeanUtils.copyBean(new FollowUpRecordDetailResponse(), followUpRecord); + buildListData(List.of(response), orgId); + + ModuleFormConfigDTO customerFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.FOLLOW_RECORD.getKey(), orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(List.of(response), FollowUpRecordDetailResponse::getModuleFields); + moduleFieldValues = followUpRecordFieldService.setBusinessRefFieldValue(List.of(response), + moduleFormService.getFlattenFormFields(FormKey.FOLLOW_RECORD.getKey(), followUpRecord.getOrganizationId()), new HashMap<>(Map.of(id, moduleFieldValues))).get(id); + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, moduleFieldValues); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(response, + FollowUpRecordDetailResponse::getOwner, FollowUpRecordDetailResponse::getOwnerName); + optionMap.put(BusinessModuleField.FOLLOW_RECORD_OWNER.getBusinessKey(), ownerFieldOption); + + // 联系人 + List contactFieldOption = moduleFormService.getBusinessFieldOption(response, + FollowUpRecordDetailResponse::getContactId, FollowUpRecordDetailResponse::getContactName); + optionMap.put(BusinessModuleField.FOLLOW_RECORD_CONTACT.getBusinessKey(), contactFieldOption); + + //客户 + List customerOption = moduleFormService.getBusinessFieldOption(response, + FollowUpRecordDetailResponse::getCustomerId, FollowUpRecordDetailResponse::getCustomerName); + optionMap.put(BusinessModuleField.FOLLOW_RECORD_CUSTOMER.getBusinessKey(), customerOption); + + //商机 + List opportunityOption = moduleFormService.getBusinessFieldOption(response, + FollowUpRecordDetailResponse::getOpportunityId, FollowUpRecordDetailResponse::getOpportunityName); + optionMap.put(BusinessModuleField.FOLLOW_RECORD_OPPORTUNITY.getBusinessKey(), opportunityOption); + + //线索 + List clueOption = moduleFormService.getBusinessFieldOption(response, + FollowUpRecordDetailResponse::getClueId, FollowUpRecordDetailResponse::getClueName); + optionMap.put(BusinessModuleField.FOLLOW_RECORD_CLUE.getBusinessKey(), clueOption); + + response.setOptionMap(optionMap); + + // 附件信息 + response.setAttachmentMap(moduleFormService.getAttachmentMap(customerFormConfig, moduleFieldValues)); + return response; + } + + + /** + * 跟进记录 + * + * @param request + * @param userId + * @param orgId + * @param resourceType + * @param type + * + * @return + */ + public PagerWithOption> list(FollowUpRecordPageRequest request, String userId, String orgId, String resourceType, String type) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extFollowUpRecordMapper.selectList(request, userId, orgId, resourceType, type); + buildListData(list, orgId); + Map> optionMap = buildOptionMap(orgId, list); + return PageUtils.setPageInfoWithOption(page, list, optionMap); + } + + /** + * 跟进记录的汇总列表 + * + * @param request 请求参数 + * @param userId 用户ID + * @param orgId 组织ID + * @param clueDataPermission 线索业务数据权限 + * @param customerDataPermission 客户业务数据权限 + * + * @return 记录的汇总列表 + */ + public PagerWithOption> totalList(RecordHomePageRequest request, String userId, String orgId, + DeptDataPermissionDTO clueDataPermission, DeptDataPermissionDTO customerDataPermission) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + // 解析当前用户数据权限 + List recordList = extFollowUpRecordMapper.selectTotalList(request, userId, orgId, clueDataPermission, customerDataPermission); + buildListData(recordList, orgId); + Map> optionMap = buildOptionMap(orgId, recordList); + return PageUtils.setPageInfoWithOption(page, recordList, optionMap); + } + + /** + * 处理选项数据 + * + * @param orgId 组织ID + * @param recordList 记录列表 + * + * @return 选项集合 + */ + public Map> buildOptionMap(String orgId, List recordList) { + // 处理自定义字段选项数据 + ModuleFormConfigDTO customerFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.FOLLOW_RECORD.getKey(), orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(recordList, FollowUpRecordListResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, moduleFieldValues); + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(recordList, + FollowUpRecordListResponse::getOwner, FollowUpRecordListResponse::getOwnerName); + optionMap.put(BusinessModuleField.OPPORTUNITY_OWNER.getBusinessKey(), ownerFieldOption); + // 联系人 + List contactFieldOption = moduleFormService.getBusinessFieldOption(recordList, + FollowUpRecordListResponse::getContactId, FollowUpRecordListResponse::getContactName); + optionMap.put(BusinessModuleField.OPPORTUNITY_CONTACT.getBusinessKey(), contactFieldOption); + return optionMap; + } + + + /** + * 删除跟进记录 + * + * @param id + */ + @OperationLog(module = LogModule.FOLLOW_UP_RECORD, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + FollowUpRecord followUpRecord = followUpRecordMapper.selectByPrimaryKey(id); + if (followUpRecord == null) { + throw new GenericException("record_not_found"); + } + deleteByIds(List.of(id)); + + getFollowTimeAndFollower(followUpRecord); + + // 设置操作对象 + OperationLogContext.setResourceName(Translator.get("delete_follow_up_record")); + } + + /** + * 删除跟进记录,更新客户/商机/线索的最新跟进时间和跟进人 + * + * @param followUpRecord + */ + private void getFollowTimeAndFollower(FollowUpRecord followUpRecord) { + switch (followUpRecord.getType()) { + case "CUSTOMER" -> updateCustomerOrOpportunity(followUpRecord); + case "CLUE" -> updateClue(followUpRecord); + default -> { + } + } + } + + /** + * 跟新线索的最新跟进时间和跟进人 + * + * @param followUpRecord + */ + private void updateClue(FollowUpRecord followUpRecord) { + if (StringUtils.isNotBlank(followUpRecord.getClueId())) { + FollowUpRecord record = extFollowUpRecordMapper.selectRecord(null, null, followUpRecord.getClueId(), followUpRecord.getOrganizationId(), "CLUE"); + Optional.ofNullable(record).ifPresent(r -> { + Clue clue = new Clue(); + clue.setId(r.getClueId()); + clue.setFollowTime(r.getFollowTime()); + clue.setFollower(r.getOwner()); + clueMapper.update(clue); + }); + } + } + + + /** + * 更新客户&商机 最近跟进时间&最近跟进人 + * + * @param followUpRecord + */ + private void updateCustomerOrOpportunity(FollowUpRecord followUpRecord) { + + if (StringUtils.isNotBlank(followUpRecord.getCustomerId())) { + FollowUpRecord record = extFollowUpRecordMapper.selectRecord(followUpRecord.getCustomerId(), null, null, followUpRecord.getOrganizationId(), "CUSTOMER"); + Optional.ofNullable(record).ifPresent(r -> { + Customer customer = new Customer(); + customer.setId(r.getCustomerId()); + customer.setFollowTime(r.getFollowTime()); + customer.setFollower(r.getOwner()); + customerMapper.update(customer); + }); + } + + if (StringUtils.isNotBlank(followUpRecord.getOpportunityId())) { + FollowUpRecord record = extFollowUpRecordMapper.selectRecord(null, followUpRecord.getOpportunityId(), null, followUpRecord.getOrganizationId(), "CUSTOMER"); + Optional.ofNullable(record).ifPresent(r -> { + Opportunity opportunity = new Opportunity(); + opportunity.setId(r.getOpportunityId()); + opportunity.setFollowTime(r.getFollowTime()); + opportunity.setFollower(r.getOwner()); + opportunityMapper.update(opportunity); + }); + } + } + + public void deleteByCustomerIds(List customerIds) { + if (CollectionUtils.isEmpty(customerIds)) { + return; + } + List ids = getByCustomerIds(customerIds) + .stream() + .map(FollowUpRecord::getId) + .toList(); + deleteByIds(ids); + } + + private void deleteByIds(List ids) { + if (ids.isEmpty()) { + return; + } + followUpRecordFieldService.deleteByResourceIds(ids); + followUpRecordMapper.deleteByIds(ids); + } + + private List getByCustomerIds(List customerIds) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.in(FollowUpRecord::getCustomerId, customerIds); + queryWrapper.eq(FollowUpRecord::getType, FollowUpPlanType.CUSTOMER.name()); + return followUpRecordMapper.selectListByLambda(queryWrapper); + } + + private List getByClueIds(List clueIds) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.in(FollowUpRecord::getClueId, clueIds); + queryWrapper.eq(FollowUpRecord::getType, FollowUpPlanType.CLUE.name()); + return followUpRecordMapper.selectListByLambda(queryWrapper); + } + + public void deleteByClueIds(List clueIds) { + if (CollectionUtils.isEmpty(clueIds)) { + return; + } + List ids = getByClueIds(clueIds) + .stream() + .map(FollowUpRecord::getId) + .toList(); + deleteByIds(ids); + } + + /** + * 获取跟进记录对应数据权限配置 + * + * @param currentUser 当前用户ID + * @param organizationId 组织ID + * + * @return 数据权限配置 + */ + public ResourceTabEnableDTO getTabEnableConfig(String currentUser, String organizationId) { + List rolePermissions = permissionCache.getRolePermissions(currentUser, organizationId); + // 由于统一的记录/计划页面会展示多个业务模块的数据,因此需要合并多个数据权限, 展示范围更大的数据权限配置 + ResourceTabEnableDTO clueTabConfig = PermissionUtils.getTabEnableConfig(currentUser, PermissionConstants.CLUE_MANAGEMENT_READ, rolePermissions); + ResourceTabEnableDTO customerTabConfig = PermissionUtils.getTabEnableConfig(currentUser, PermissionConstants.CUSTOMER_MANAGEMENT_READ, rolePermissions); + return clueTabConfig.or(customerTabConfig); + } + + + /** + * 拦截跟进记录的操作权限 + * + * @param id 记录ID + * @param orgId 组织ID + */ + public void checkRecordPermission(String id, String orgId) { + FollowUpRecordDetailResponse recordDetail = get(id, orgId); + boolean hasPermission; + if (Strings.CS.equals(recordDetail.getType(), ModuleKey.CLUE.name())) { + hasPermission = PermissionUtils.hasPermission(PermissionConstants.CLUE_MANAGEMENT_UPDATE); + } else if (StringUtils.isNotEmpty(recordDetail.getOpportunityId())) { + hasPermission = PermissionUtils.hasPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_UPDATE); + } else { + hasPermission = PermissionUtils.hasPermission(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE); + } + if (!hasPermission) { + throw new GenericException(Translator.get("no.operation.permission")); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/controller/CustomFormController.java b/backend/crm/src/main/java/cn/cordys/crm/form/controller/CustomFormController.java new file mode 100644 index 0000000..101024b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/controller/CustomFormController.java @@ -0,0 +1,99 @@ +package cn.cordys.crm.form.controller; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.form.dto.request.CustomFormAdminBatchRequest; +import cn.cordys.crm.form.dto.request.CustomFormAddRequest; +import cn.cordys.crm.form.dto.request.CustomFormUpdateRequest; +import cn.cordys.crm.form.dto.response.CustomFormGetResponse; +import cn.cordys.crm.form.dto.response.CustomFormListResponse; +import cn.cordys.crm.form.domain.CustomForm; +import cn.cordys.crm.form.service.CustomFormService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "自定义表单") +@RestController +@RequestMapping("/custom-form") +public class CustomFormController { + + @Resource + private CustomFormService customFormService; + + @GetMapping("/list") + @Operation(summary = "自定义表单列表") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public List list() { + return customFormService.list(SessionUtils.getUserId()); + } + + @GetMapping("/option") + @Operation(summary = "自定义表单选项(开启的)") + public List getOptions() { + return customFormService.getOptions(); + } + + @GetMapping("/get/{id}") + @Operation(summary = "自定义表单详情") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public CustomFormGetResponse get(@PathVariable String id) { + return customFormService.get(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/add") + @Operation(summary = "创建自定义表单") + @CsPermission(PermissionConstants.CUSTOM_FORM_ADD) + public CustomForm create(@Validated @RequestBody CustomFormAddRequest request) { + return customFormService.create(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @Operation(summary = "更新自定义表单") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public void update(@Validated @RequestBody CustomFormUpdateRequest request) { + customFormService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/delete/{id}") + @Operation(summary = "删除自定义表单") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public void delete(@PathVariable String id) { + customFormService.delete(id, SessionUtils.getUserId()); + } + + @GetMapping("/enable/{id}") + @Operation(summary = "启用自定义表单") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public void enable(@PathVariable String id) { + customFormService.updateEnable(id, SessionUtils.getUserId(), true); + } + + @GetMapping("/disable/{id}") + @Operation(summary = "禁用自定义表单") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public void disable(@PathVariable String id) { + customFormService.updateEnable(id, SessionUtils.getUserId(), false); + } + + @PostMapping("/admin/set") + @Operation(summary = "设置表单管理员") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public void setAdmins(@Validated @RequestBody CustomFormAdminBatchRequest request) { + customFormService.setAdmins(request, SessionUtils.getUserId()); + } + + @GetMapping("/admin/get/{customFormId}") + @Operation(summary = "查询表单管理员") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public List getAdmins(@PathVariable String customFormId) { + return customFormService.getAdmins(customFormId, SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/controller/CustomFormDataController.java b/backend/crm/src/main/java/cn/cordys/crm/form/controller/CustomFormDataController.java new file mode 100644 index 0000000..cd2d51f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/controller/CustomFormDataController.java @@ -0,0 +1,125 @@ +package cn.cordys.crm.form.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.form.domain.CustomFormData; +import cn.cordys.crm.form.dto.request.*; +import cn.cordys.crm.form.dto.response.CustomFormDataGetResponse; +import cn.cordys.crm.form.dto.response.CustomFormDataListResponse; +import cn.cordys.crm.form.service.CustomFormDataExportService; +import cn.cordys.crm.form.service.CustomFormDataService; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Tag(name = "自定义表单数据") +@RestController +@RequestMapping("/custom-form/data") +public class CustomFormDataController { + + @Resource + private CustomFormDataService customFormDataService; + @Resource + private CustomFormDataExportService customFormDataExportService; + + @PostMapping("/page") + @Operation(summary = "表单数据列表") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public PagerWithOption> page(@Validated @RequestBody CustomFormDataPageRequest request) { + ConditionFilterUtils.parseCondition(request, request.getCustomFormId()); + return customFormDataService.page(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), true); + } + + @GetMapping("/get/{id}") + @Operation(summary = "表单数据详情") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public CustomFormDataGetResponse get(@PathVariable String id) { + return customFormDataService.get(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/add") + @Operation(summary = "创建表单数据") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public CustomFormData add(@Validated @RequestBody CustomFormDataAddRequest request) { + return customFormDataService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @Operation(summary = "更新表单数据") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public void update(@Validated @RequestBody CustomFormDataUpdateRequest request) { + customFormDataService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/delete/{id}") + @Operation(summary = "删除表单数据") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public void delete(@PathVariable String id) { + customFormDataService.delete(id, SessionUtils.getUserId()); + } + + @PostMapping("/batch/update") + @Operation(summary = "批量更新表单数据") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public void batchUpdate(@Validated @RequestBody CustomFormDataBatchUpdateRequest request) { + customFormDataService.batchUpdate(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch/delete") + @Operation(summary = "批量删除表单数据") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public void batchDelete(@RequestBody List ids) { + customFormDataService.batchDelete(ids, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/export-all") + @Operation(summary = "导出全部表单数据") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public String exportAll(@Validated @RequestBody CustomFormDataExportRequest request) { + ConditionFilterUtils.parseCondition(request, request.getCustomFormId()); + return customFormDataExportService.exportAll(request, SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), LocaleContextHolder.getLocale()); + } + + @PostMapping("/export-select") + @Operation(summary = "导出选中表单数据") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public String exportSelect(@Validated @RequestBody ExportSelectRequest request) { + return customFormDataExportService.exportSelect(request, SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), LocaleContextHolder.getLocale()); + } + + @GetMapping("/template/download") + @Operation(summary = "下载导入模板") + public void downloadImportTpl(@RequestParam String customFormId, HttpServletResponse response) { + customFormDataService.downloadImportTpl(response, customFormId, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/import/pre-check") + @Operation(summary = "导入预检查") + public ImportResponse importPreCheck(@RequestParam String customFormId, + @RequestPart(value = "file") MultipartFile file) { + return customFormDataService.importPreCheck(file, customFormId, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/import") + @Operation(summary = "导入表单数据") + public ImportResponse realImport(@RequestParam String customFormId, + @RequestPart(value = "file") MultipartFile file) { + return customFormDataService.realImport(file, customFormId, + OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/controller/CustomFormRoleController.java b/backend/crm/src/main/java/cn/cordys/crm/form/controller/CustomFormRoleController.java new file mode 100644 index 0000000..c3f57f0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/controller/CustomFormRoleController.java @@ -0,0 +1,73 @@ +package cn.cordys.crm.form.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.DeptUserTreeNode; +import cn.cordys.common.dto.RoleUserTreeNode; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.form.dto.request.CustomFormRoleUserBatchRequest; +import cn.cordys.crm.form.dto.request.CustomFormRoleUserPageRequest; +import cn.cordys.crm.form.dto.response.CustomFormRoleListResponse; +import cn.cordys.crm.form.dto.response.CustomFormRoleUserListResponse; +import cn.cordys.crm.form.service.CustomFormRoleService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "自定义表单角色") +@RestController +@RequestMapping("/custom-form/role") +public class CustomFormRoleController { + + @Resource + private CustomFormRoleService customFormRoleService; + + @GetMapping("/list/{customFormId}") + @Operation(summary = "获取表单角色列表") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public List listByFormId(@PathVariable String customFormId) { + return customFormRoleService.listByFormId(customFormId, SessionUtils.getUserId()); + } + + @PostMapping("/users") + @Operation(summary = "获取角色用户列表") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public Pager> listUsersByRole(@Validated @RequestBody CustomFormRoleUserPageRequest request) { + return customFormRoleService.listUsersByRole(request, SessionUtils.getUserId(), + OrganizationContext.getOrganizationId()); + } + + @GetMapping("/user/dept/tree") + @Operation(summary = "获取部门用户树") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public List getDeptUserTree() { + return customFormRoleService.getDeptUserTree(OrganizationContext.getOrganizationId()); + } + + @GetMapping("/user/role/tree") + @Operation(summary = "获取角色用户树") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public List getRoleUserTree() { + return customFormRoleService.getRoleUserTree(OrganizationContext.getOrganizationId()); + } + + @PostMapping("/user/add") + @Operation(summary = "角色添加用户") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public void addUsers(@Validated @RequestBody CustomFormRoleUserBatchRequest request) { + customFormRoleService.addUsers(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/user/remove") + @Operation(summary = "角色移除用户") + @CsPermission(PermissionConstants.CUSTOM_FORM_READ) + public void removeUsers(@RequestBody CustomFormRoleUserBatchRequest request) { + customFormRoleService.removeUsers(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomForm.java b/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomForm.java new file mode 100644 index 0000000..b706163 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomForm.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.form.domain; + + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@Table(name = "custom_form") +public class CustomForm extends BaseModel { + + @Schema(description = "名称") + private String name; + + @Schema(description = "是否启用") + private Boolean enable; + + @Schema(description = "组织id") + private String organizationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormAdmin.java b/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormAdmin.java new file mode 100644 index 0000000..e91e179 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormAdmin.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.form.domain; + +import jakarta.persistence.Table; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +@Data +@Table(name = "custom_form_admin") +public class CustomFormAdmin implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String id; + private String customFormId; + private String userId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormData.java b/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormData.java new file mode 100644 index 0000000..3049601 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormData.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.form.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "custom_form_data") +public class CustomFormData extends BaseModel { + + @Schema(description = "自定义表单ID") + private String customFormId; + + @Schema(description = "名称") + private String name; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "组织id") + private String organizationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormDataField.java b/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormDataField.java new file mode 100644 index 0000000..c842461 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormDataField.java @@ -0,0 +1,10 @@ +package cn.cordys.crm.form.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "custom_form_data_field") +public class CustomFormDataField extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormDataFieldBlob.java b/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormDataFieldBlob.java new file mode 100644 index 0000000..06f4fe1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormDataFieldBlob.java @@ -0,0 +1,10 @@ +package cn.cordys.crm.form.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "custom_form_data_field_blob") +public class CustomFormDataFieldBlob extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormRole.java b/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormRole.java new file mode 100644 index 0000000..368ac23 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormRole.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.form.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "custom_form_role") +public class CustomFormRole extends BaseModel { + + @Schema(description = "名称") + private String name; + + @Schema(description = "自定义表单ID") + private String customFormId; + + @Schema(description = "内置角色key") + private String internalKey; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormRoleKey.java b/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormRoleKey.java new file mode 100644 index 0000000..416c1e7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormRoleKey.java @@ -0,0 +1,24 @@ +package cn.cordys.crm.form.domain; + +import cn.cordys.common.util.Translator; +import lombok.Getter; + +@Getter +public enum CustomFormRoleKey { + + MANAGE_ALL("MANAGE_ALL", "custom.form.role.manage_all"), + VIEW_ALL("VIEW_ALL", "custom.form.role.view_all"), + MANAGE_OWN("MANAGE_OWN", "custom.form.role.manage_own"); + + private final String key; + private final String i18nKey; + + CustomFormRoleKey(String key, String i18nKey) { + this.key = key; + this.i18nKey = i18nKey; + } + + public String getName() { + return Translator.get(i18nKey); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormRoleUser.java b/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormRoleUser.java new file mode 100644 index 0000000..0e4c016 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/domain/CustomFormRoleUser.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.form.domain; + +import cn.cordys.common.domain.BaseModel; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "custom_form_role_user") +public class CustomFormRoleUser extends BaseModel { + private String roleId; + private String userId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormAddRequest.java new file mode 100644 index 0000000..5c3e1d9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormAddRequest.java @@ -0,0 +1,28 @@ +package cn.cordys.crm.form.dto.request; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.form.FormProp; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class CustomFormAddRequest { + + @NotBlank + @Size(max = 255) + @Schema(description = "名称") + private String name; + + @Schema(description = "是否启用") + private Boolean enable; + + @Schema(description = "保存字段集合") + private List fields; + + @Schema(description = "表单属性") + private FormProp formProp; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormAdminBatchRequest.java b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormAdminBatchRequest.java new file mode 100644 index 0000000..348b905 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormAdminBatchRequest.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.form.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class CustomFormAdminBatchRequest { + + @NotBlank + @Schema(description = "自定义表单ID") + private String customFormId; + + @NotEmpty + @Schema(description = "用户ID列表") + private List userIds; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormDataAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormDataAddRequest.java new file mode 100644 index 0000000..5573fdc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormDataAddRequest.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.form.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class CustomFormDataAddRequest { + + @NotBlank + @Schema(description = "自定义表单ID") + private String customFormId; + + @NotBlank + @Size(max = 255) + @Schema(description = "名称") + private String name; + + @Size(max = 32) + @Schema(description = "负责人") + private String owner; + + @Schema(description = "模块字段值") + private List moduleFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormDataBatchUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormDataBatchUpdateRequest.java new file mode 100644 index 0000000..dac476e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormDataBatchUpdateRequest.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.form.dto.request; + +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class CustomFormDataBatchUpdateRequest extends ResourceBatchEditRequest { + @NotBlank + @Schema(description = "自定义表单ID") + private String customFormId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormDataExportRequest.java b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormDataExportRequest.java new file mode 100644 index 0000000..56e1d1f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormDataExportRequest.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.form.dto.request; + +import cn.cordys.common.dto.ExportHeadDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +public class CustomFormDataExportRequest extends CustomFormDataPageRequest { + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "表头信息") + @NotEmpty(message = "{export_head_list_is_empty}") + private List headList; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormDataPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormDataPageRequest.java new file mode 100644 index 0000000..47a471b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormDataPageRequest.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.form.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class CustomFormDataPageRequest extends BasePageRequest { + + @NotBlank + @Schema(description = "自定义表单ID") + private String customFormId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormDataUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormDataUpdateRequest.java new file mode 100644 index 0000000..c23ce15 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormDataUpdateRequest.java @@ -0,0 +1,33 @@ +package cn.cordys.crm.form.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class CustomFormDataUpdateRequest { + + @NotBlank + @Schema(description = "ID") + private String id; + + @NotBlank + @Schema(description = "自定义表单ID") + private String customFormId; + + @NotBlank + @Size(max = 255) + @Schema(description = "名称") + private String name; + + @Size(max = 32) + @Schema(description = "负责人") + private String owner; + + @Schema(description = "模块字段值") + private List moduleFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormRoleUserBatchRequest.java b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormRoleUserBatchRequest.java new file mode 100644 index 0000000..0706633 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormRoleUserBatchRequest.java @@ -0,0 +1,24 @@ +package cn.cordys.crm.form.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.List; + +@Data +public class CustomFormRoleUserBatchRequest { + + @NotBlank + @Schema(description = "表单角色ID") + private String customFormRoleId; + + @Schema(description = "用户ID列表") + private List userIds; + + @Schema(description = "部门ID列表") + private List deptIds; + + @Schema(description = "系统角色ID列表") + private List roleIds; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormRoleUserPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormRoleUserPageRequest.java new file mode 100644 index 0000000..e50f88d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormRoleUserPageRequest.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.form.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class CustomFormRoleUserPageRequest extends BasePageRequest { + + @NotBlank + @Schema(description = "自定义表单角色ID") + private String customFormRoleId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormUpdateRequest.java new file mode 100644 index 0000000..5ac7606 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/dto/request/CustomFormUpdateRequest.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.form.dto.request; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.form.FormProp; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class CustomFormUpdateRequest { + + @NotBlank + @Schema(description = "ID") + private String id; + + @NotBlank + @Size(max = 255) + @Schema(description = "名称") + private String name; + + @Schema(description = "是否启用") + private Boolean enable; + + @Schema(description = "保存字段集合") + private List fields; + + @Schema(description = "表单属性") + private FormProp formProp; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormDataGetResponse.java b/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormDataGetResponse.java new file mode 100644 index 0000000..7e32d19 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormDataGetResponse.java @@ -0,0 +1,59 @@ +package cn.cordys.crm.form.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class CustomFormDataGetResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "自定义表单ID") + private String customFormId; + + @Schema(description = "名称") + private String name; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "选项集合") + private Map> optionMap; + + @Schema(description = "附件集合") + private Map> attachmentMap; + + @Schema(description = "当前用户是否是管理员") + private Boolean isAdmin; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormDataListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormDataListResponse.java new file mode 100644 index 0000000..ce702e2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormDataListResponse.java @@ -0,0 +1,50 @@ +package cn.cordys.crm.form.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class CustomFormDataListResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "自定义表单ID") + private String customFormId; + + @Schema(description = "名称") + private String name; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "自定义字段集合") + private List moduleFields; + + @Schema(description = "当前用户是否是管理员") + private Boolean isAdmin; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormGetResponse.java b/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormGetResponse.java new file mode 100644 index 0000000..e0735fb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormGetResponse.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.form.dto.response; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.form.domain.CustomForm; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.form.FormProp; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class CustomFormGetResponse extends CustomForm { + + @Schema(description = "当前用户是否是管理员") + private Boolean isAdmin; + + @Schema(description = "字段集合及其属性") + private List fields; + + @Schema(description = "表单属性") + private FormProp formProp; + + @Schema(description = "创建人") + private OptionDTO creator; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormListResponse.java new file mode 100644 index 0000000..a5df21d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormListResponse.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.form.dto.response; + +import cn.cordys.crm.form.domain.CustomForm; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class CustomFormListResponse extends CustomForm { + @Schema(description = "当前用户是否是管理员") + private Boolean isAdmin; + @Schema(description = "当前用户是否有权限创建表单数据") + private Boolean hasCreateDataPermission; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormRoleListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormRoleListResponse.java new file mode 100644 index 0000000..d10ce02 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormRoleListResponse.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.form.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class CustomFormRoleListResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "名称") + private String name; + + @Schema(description = "自定义表单ID") + private String customFormId; + + @Schema(description = "内置角色key") + private String internalKey; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormRoleUserListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormRoleUserListResponse.java new file mode 100644 index 0000000..acea22e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/dto/response/CustomFormRoleUserListResponse.java @@ -0,0 +1,35 @@ +package cn.cordys.crm.form.dto.response; + +import cn.cordys.crm.system.dto.convert.UserRoleConvert; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class CustomFormRoleUserListResponse { + + @Schema(description = "自定义表单角色用户关联ID") + private String id; + + @Schema(description = "用户ID") + private String userId; + + @Schema(description = "用户名") + private String username; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "部门ID") + private String departmentId; + + @Schema(description = "部门名称") + private String departmentName; + + @Schema(description = "职位") + private String position; + + @Schema(description = "角色列表") + private List roles; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormDataMapper.java b/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormDataMapper.java new file mode 100644 index 0000000..8d2a373 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormDataMapper.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.form.mapper; + +import cn.cordys.common.dto.BatchUpdateDbParam; +import cn.cordys.crm.form.dto.request.CustomFormDataPageRequest; +import cn.cordys.crm.form.dto.response.CustomFormDataListResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtCustomFormDataMapper { + + List list(@Param("request") CustomFormDataPageRequest request, + @Param("orgId") String orgId, + @Param("userId") String userId, + @Param("manageOwn") boolean manageOwn); + + List listForExport(@Param("request") CustomFormDataPageRequest request, + @Param("orgId") String orgId, + @Param("userId") String userId, + @Param("manageOwn") boolean manageOwn, + @Param("limit") int limit, + @Param("offset") int offset); + + void batchUpdate(@Param("request") BatchUpdateDbParam batchUpdateDbParam); + + void deleteFormDataByCustomFormId(@Param("formId") String formId); + + List getListByIds(@Param("ids") List ids); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormDataMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormDataMapper.xml new file mode 100644 index 0000000..0eeff5e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormDataMapper.xml @@ -0,0 +1,323 @@ + + + + + + + + + + + + + + order by + + + d.${sortName} ${sortType}, d.id ASC + + + CASE + WHEN module_table.type = 'INPUT_NUMBER' OR module_table.type = 'FORMULA' + THEN sort_table.field_value+0 + END ${sortType}, + sort_table.field_value ${sortType}, + d.id ${sortType} + + + + + order by d.create_time desc, d.id asc + + + + + + + + + + + + + + + + + + + + left join custom_form_data_field_blob ${tablePrefix}_${i} + on d.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + left join custom_form_data_field ${tablePrefix}_${i} + on d.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + + + + + + + + + + + + left join custom_form_data_field sort_table + on d.id = sort_table.resource_id and sort_table.field_id = #{sortName} + left join sys_module_field module_table on sort_table.field_id = module_table.id + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE custom_form_data + + + owner = #{request.fieldValue}, + + + name = #{request.fieldValue}, + + update_time = #{request.updateTime}, + update_user = #{request.updateUser} + + WHERE id IN + + #{id} + + + + + DELETE d, f, b + FROM custom_form_data d + LEFT JOIN custom_form_data_field f ON f.resource_id = d.id + LEFT JOIN custom_form_data_field_blob b ON b.resource_id = d.id + WHERE d.custom_form_id = #{formId} + + + + + diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormMapper.java b/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormMapper.java new file mode 100644 index 0000000..36c90fd --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormMapper.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.form.mapper; + +import cn.cordys.crm.form.domain.CustomForm; +import org.apache.ibatis.annotations.Param; + +/** + * @Author: jianxing + * @CreateTime: 2026-06-05 16:22 + */ +public interface ExtCustomFormMapper { + boolean checkAddExist(@Param("customForm") CustomForm customForm); + + boolean checkUpdateExist(@Param("customForm") CustomForm customForm); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormMapper.xml new file mode 100644 index 0000000..726985d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormMapper.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormRoleUserMapper.java b/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormRoleUserMapper.java new file mode 100644 index 0000000..c69e95b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormRoleUserMapper.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.form.mapper; + +import cn.cordys.crm.form.dto.request.CustomFormRoleUserPageRequest; +import cn.cordys.crm.form.dto.response.CustomFormRoleUserListResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtCustomFormRoleUserMapper { + + List listByRoleId(@Param("orgId") String orgId, + @Param("request") CustomFormRoleUserPageRequest request); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormRoleUserMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormRoleUserMapper.xml new file mode 100644 index 0000000..f25a77b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/mapper/ExtCustomFormRoleUserMapper.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + order by + + + cfru.id ${sortType} + + + cfru.user_id ${sortType} + + + su.name ${sortType} + + + cfru.create_time ${sortType} + + + sd.name ${sortType} + + + sou.position ${sortType} + + + cfru.create_time ${sortType} + + + , cfru.id asc + + + order by cfru.create_time desc, cfru.id asc + + + + diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormDataExportService.java b/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormDataExportService.java new file mode 100644 index 0000000..218a51e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormDataExportService.java @@ -0,0 +1,201 @@ +package cn.cordys.crm.form.service; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.ExportHeadDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.service.BaseExportService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.SubListUtils; +import cn.cordys.common.util.TimeUtils; +import cn.cordys.crm.form.domain.CustomFormRoleKey; +import cn.cordys.crm.form.dto.request.CustomFormDataExportRequest; +import cn.cordys.crm.form.dto.response.CustomFormDataListResponse; +import cn.cordys.crm.form.mapper.ExtCustomFormDataMapper; +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.domain.ExportTask; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.service.ExportTaskService; +import cn.cordys.registry.ExportThreadRegistry; +import cn.idev.excel.EasyExcel; +import cn.idev.excel.ExcelWriter; +import cn.idev.excel.support.ExcelTypeEnum; +import cn.idev.excel.write.metadata.WriteSheet; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.File; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class CustomFormDataExportService extends BaseExportService { + + @Resource + private CustomFormDataService customFormDataService; + @Resource + private ExtCustomFormDataMapper extCustomFormDataMapper; + @Resource + private ExportTaskService exportTaskService; + + public String exportAll(CustomFormDataExportRequest request, String userId, String orgId, Locale locale) { + checkFileName(request.getFileName()); + exportTaskService.checkUserTaskLimit(userId, ExportConstants.ExportType.CUSTOM_FORM_DATA.name()); + + // 权限检查:获取数据权限范围(导出时跳过表单启用状态检查) + CustomFormRoleKey dataScope = customFormDataService.getDataScope(request.getCustomFormId(), userId, false); + boolean manageOwn = dataScope == CustomFormRoleKey.MANAGE_OWN; + + String fileId = IDGenerator.nextStr(); + ExportTask exportTask = exportTaskService.saveTask(orgId, fileId, userId, + ExportConstants.ExportType.CUSTOM_FORM_DATA.toString(), request.getFileName()); + + runExport(orgId, userId, LogModule.CUSTOM_FORM_DATA, locale, exportTask, request.getFileName(), + () -> exportData(fileId, exportTask, userId, request, orgId, manageOwn)); + + return exportTask.getId(); + } + + public String exportSelect(ExportSelectRequest request, String userId, String orgId, Locale locale) { + checkFileName(request.getFileName()); + exportTaskService.checkUserTaskLimit(userId, ExportConstants.ExportType.CUSTOM_FORM_DATA.name()); + + String fileId = IDGenerator.nextStr(); + ExportTask exportTask = exportTaskService.saveTask(orgId, fileId, userId, + ExportConstants.ExportType.CUSTOM_FORM_DATA.toString(), request.getFileName()); + + runExport(orgId, userId, LogModule.CUSTOM_FORM_DATA, locale, exportTask, request.getFileName(), + () -> exportSelectData(exportTask, request, orgId, fileId, userId)); + + return exportTask.getId(); + } + + private void exportData(String fileId, ExportTask exportTask, String userId, + CustomFormDataExportRequest request, String orgId, + boolean manageOwn) throws InterruptedException { + List> headList = request.getHeadList().stream() + .map(head -> Collections.singletonList(head.getTitle())) + .toList(); + + batchHandleData(fileId, headList, exportTask, request.getFileName(), request, + t -> getExportData(t, orgId, userId, manageOwn, exportTask.getId())); + } + + private void exportSelectData(ExportTask exportTask, ExportSelectRequest request, + String orgId, String fileId, String userId) { + List> headList = request.getHeadList().stream() + .map(head -> Collections.singletonList(head.getTitle())) + .toList(); + + File file = prepareExportFile(fileId, request.getFileName(), exportTask.getOrganizationId()); + try (ExcelWriter writer = EasyExcel.write(file) + .head(headList) + .excelType(ExcelTypeEnum.XLSX) + .build()) { + WriteSheet sheet = EasyExcel.writerSheet("导出数据").build(); + + SubListUtils.dealForSubList(request.getIds(), SubListUtils.DEFAULT_EXPORT_BATCH_SIZE, (subIds) -> { + List> data = new ArrayList<>(); + try { + data = getExportDataBySelect(request.getHeadList(), subIds, orgId, exportTask.getId(), userId); + } catch (InterruptedException e) { + log.error("任务停止中断", e); + exportTaskService.update(exportTask.getId(), ExportConstants.ExportStatus.STOP.toString(), exportTask.getCreateUser()); + } + writer.write(data, sheet); + }); + } + + exportTaskService.update(exportTask.getId(), ExportConstants.ExportStatus.SUCCESS.toString(), exportTask.getCreateUser()); + } + + private List> getExportData(CustomFormDataExportRequest request, String orgId, + String userId, boolean manageOwn, String taskId) + throws InterruptedException { + int limit = request.getPageSize(); + int offset = (request.getCurrent() - 1) * request.getPageSize(); + List rawList = extCustomFormDataMapper.listForExport(request, orgId, userId, manageOwn, limit, offset); + return buildExportResult(request.getHeadList(), rawList, request.getCustomFormId(), orgId, taskId, userId); + } + + private List> getExportDataBySelect(List headList, List ids, + String orgId, String taskId, String userId) throws InterruptedException { + List rawList = extCustomFormDataMapper.getListByIds(ids); + if (rawList == null || rawList.isEmpty()) { + return Collections.emptyList(); + } + String formId = rawList.getFirst().getCustomFormId(); + + // 权限过滤:根据数据权限过滤选中数据(导出时跳过表单启用状态检查) + CustomFormRoleKey dataScope = customFormDataService.getDataScope(formId, userId, false); + if (dataScope == CustomFormRoleKey.MANAGE_OWN) { + rawList = rawList.stream() + .filter(item -> StringUtils.equals(item.getOwner(), userId)) + .collect(Collectors.toList()); + } + if (rawList.isEmpty()) { + return Collections.emptyList(); + } + + return buildExportResult(headList, rawList, formId, orgId, taskId, userId); + } + + private List> buildExportResult(List headList, + List rawList, + String formId, String orgId, String taskId, + String userId) throws InterruptedException { + // 构建含数据源显示字段的数据 + CustomFormDataFieldService.setFormKey(formId); + List dataList; + try { + dataList = customFormDataService.buildList(rawList, formId, orgId); + } finally { + CustomFormDataFieldService.clearFormKey(); + } + + // 字段配置 + Map fieldConfigMap = getFieldConfigMap(formId, orgId); + + List> result = new ArrayList<>(dataList.size()); + for (CustomFormDataListResponse response : dataList) { + if (ExportThreadRegistry.isInterrupted(taskId)) { + throw new InterruptedException("线程已被中断,主动退出"); + } + result.add(buildData(headList, response, fieldConfigMap)); + } + + return result; + } + + private List buildData(List headList, CustomFormDataListResponse data, + Map fieldConfigMap) { + List dataList = new ArrayList<>(); + // 系统字段 + LinkedHashMap systemFieldMap = getSystemFieldMap(data); + // 自定义字段 + AtomicReference> moduleFieldMap = new AtomicReference<>(new LinkedHashMap<>()); + Optional.ofNullable(data.getModuleFields()).ifPresent(moduleFields -> + moduleFieldMap.set(moduleFields.stream() + .collect(Collectors.toMap(BaseModuleFieldValue::getFieldId, BaseModuleFieldValue::getFieldValue)))); + // 处理数据转换 + transModuleFieldValue(headList, systemFieldMap, moduleFieldMap.get(), dataList, fieldConfigMap); + return dataList; + } + + private LinkedHashMap getSystemFieldMap(CustomFormDataListResponse data) { + LinkedHashMap systemFieldMap = new LinkedHashMap<>(); + systemFieldMap.put("name", data.getName()); + systemFieldMap.put("owner", data.getOwnerName()); + systemFieldMap.put("createUser", data.getCreateUserName()); + systemFieldMap.put("createTime", TimeUtils.getDateTimeStr(data.getCreateTime())); + systemFieldMap.put("updateUser", data.getUpdateUserName()); + systemFieldMap.put("updateTime", TimeUtils.getDateTimeStr(data.getUpdateTime())); + return systemFieldMap; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormDataFieldService.java b/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormDataFieldService.java new file mode 100644 index 0000000..fea0899 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormDataFieldService.java @@ -0,0 +1,48 @@ +package cn.cordys.crm.form.service; + +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.crm.form.domain.CustomFormDataField; +import cn.cordys.crm.form.domain.CustomFormDataFieldBlob; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(rollbackFor = Exception.class) +public class CustomFormDataFieldService extends BaseResourceFieldService { + + private static final ThreadLocal FORM_KEY_HOLDER = new ThreadLocal<>(); + + @Resource + private BaseMapper customFormDataFieldMapper; + @Resource + private BaseMapper customFormDataFieldBlobMapper; + + public static void setFormKey(String formKey) { + FORM_KEY_HOLDER.set(formKey); + } + + public static void clearFormKey() { + FORM_KEY_HOLDER.remove(); + } + + @Override + protected String getFormKey() { + String formKey = FORM_KEY_HOLDER.get(); + if (formKey == null) { + throw new IllegalStateException("formKey 没有设置,请先调用 setFormKey 方法设置 formKey"); + } + return formKey; + } + + @Override + protected BaseMapper getResourceFieldMapper() { + return customFormDataFieldMapper; + } + + @Override + protected BaseMapper getResourceFieldBlobMapper() { + return customFormDataFieldBlobMapper; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormDataLogService.java b/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormDataLogService.java new file mode 100644 index 0000000..f0bd400 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormDataLogService.java @@ -0,0 +1,86 @@ +package cn.cordys.crm.form.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.form.domain.CustomFormData; +import cn.cordys.crm.system.service.BaseModuleLogService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@Transactional(rollbackFor = Exception.class) +public class CustomFormDataLogService extends BaseModuleLogService { + + @Override + public List handleLogField(List differences, String orgId) { + String jsonValue = StringUtils.isNotBlank(oldValue) ? oldValue : newValue; + if (StringUtils.isBlank(jsonValue)) { + return differences; + } + + CustomFormData customFormData; + try { + customFormData = JSON.parseObject(jsonValue, CustomFormData.class); + } catch (Exception e) { + log.warn("解析自定义表单数据日志失败, 尝试修复特殊字符后重新解析: {}", e.getMessage()); + // 对含有未转义特殊字符的JSON进行修复 + String fixedJson = escapeSpecialChars(jsonValue); + try { + customFormData = JSON.parseObject(fixedJson, CustomFormData.class); + } catch (Exception ex) { + log.error("修复后仍然无法解析自定义表单数据日志: {}", ex.getMessage()); + return differences; + } + } + + final String formId = customFormData.getCustomFormId(); + differences.removeIf(differ -> Strings.CS.equalsAny(differ.getColumn(), "customFormId")); + differences = super.handleModuleLogField(differences, orgId, formId); + for (JsonDifferenceDTO differ : differences) { + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.CUSTOM_FORM_DATA_OWNER.getBusinessKey())) { + setUserFieldName(differ); + } + } + return differences; + } + + /** + * 修复JSON字符串中未转义的特殊字符 + * 将未转义的控制字符(如换行符、制表符等)转换为转义形式 + * + * @param json 原始JSON字符串 + * @return 修复后的JSON字符串 + */ + private String escapeSpecialChars(String json) { + if (json == null) { + return null; + } + StringBuilder sb = new StringBuilder(json.length()); + for (int i = 0; i < json.length(); i++) { + char c = json.charAt(i); + switch (c) { + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + case '\b' -> sb.append("\\b"); + case '\f' -> sb.append("\\f"); + default -> { + if (c < 0x20) { + // 其他控制字符使用Unicode转义 + sb.append("\\u").append(String.format("%04x", (int) c)); + } else { + sb.append(c); + } + } + } + } + return sb.toString(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormDataService.java b/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormDataService.java new file mode 100644 index 0000000..03803f9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormDataService.java @@ -0,0 +1,586 @@ +package cn.cordys.crm.form.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.domain.BaseResourceSubField; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.response.result.CrmHttpResultCode; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.form.domain.*; +import cn.cordys.crm.form.dto.request.CustomFormDataAddRequest; +import cn.cordys.crm.form.dto.request.CustomFormDataBatchUpdateRequest; +import cn.cordys.crm.form.dto.request.CustomFormDataPageRequest; +import cn.cordys.crm.form.dto.request.CustomFormDataUpdateRequest; +import cn.cordys.crm.form.dto.response.CustomFormDataGetResponse; +import cn.cordys.crm.form.dto.response.CustomFormDataListResponse; +import cn.cordys.crm.form.mapper.ExtCustomFormDataMapper; +import cn.cordys.crm.system.domain.ModuleForm; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.excel.CustomImportAfterDoConsumer; +import cn.cordys.crm.system.excel.handler.CustomHeadColWidthStyleStrategy; +import cn.cordys.crm.system.excel.handler.CustomTemplateWriteHandler; +import cn.cordys.crm.system.excel.listener.CustomFieldCheckEventListener; +import cn.cordys.crm.system.excel.listener.CustomFieldImportEventListener; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.excel.utils.EasyExcelExporter; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import cn.idev.excel.FastExcelFactory; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class CustomFormDataService { + + @Resource + private BaseMapper customFormDataMapper; + @Resource + private ExtCustomFormDataMapper extCustomFormDataMapper; + @Resource + private BaseMapper customFormRoleMapper; + @Resource + private BaseMapper customFormRoleUserMapper; + @Resource + private CustomFormDataFieldService customFormDataFieldService; + @Resource + private CustomFormService customFormService; + @Resource + private BaseService baseService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private BaseMapper moduleFormMapper; + @Resource + private LogService logService; + @Resource + private BaseMapper customFormMapper; + @Resource + private BaseMapper customFormDataFieldMapper; + @Resource + private BaseMapper customFormDataFieldBlobMapper; + + public PagerWithOption> page(CustomFormDataPageRequest request, String userId, String orgId, boolean checkDataPermission) { + String formId = request.getCustomFormId(); + boolean manageOwn = false; + CustomFormRoleKey dataScope; + if (checkDataPermission) { + dataScope = getDataScope(formId, userId); + manageOwn = dataScope == CustomFormRoleKey.MANAGE_OWN; + } else { + dataScope = CustomFormRoleKey.VIEW_ALL; + } + + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extCustomFormDataMapper.list(request, orgId, userId, manageOwn); + CustomFormDataFieldService.setFormKey(formId); + try { + list = buildList(list, formId, orgId); + Map> optionMap = buildOptionMap(formId, orgId, list); + list.forEach(item -> item.setIsAdmin(isAdminUser(dataScope, userId, item.getOwner()))); + return PageUtils.setPageInfoWithOption(page, list, optionMap); + } finally { + CustomFormDataFieldService.clearFormKey(); + } + } + + private boolean isAdminUser(CustomFormRoleKey dataScope, String userId, String owner) { + return dataScope == CustomFormRoleKey.MANAGE_ALL || + (dataScope == CustomFormRoleKey.MANAGE_OWN && StringUtils.equals(owner, userId)); + } + + public List buildList(List list, String formId, String orgId) { + if (CollectionUtils.isEmpty(list)) { + return list; + } + + List dataIds = list.stream().map(CustomFormDataListResponse::getId).toList(); + + Map> fieldMap = customFormDataFieldService.getResourceFieldMap(dataIds, true); + Map> resolvefieldValueMap = customFormDataFieldService.setBusinessRefFieldValue(list, moduleFormService.getFlattenFormFields(formId, orgId), fieldMap); + + list.forEach(resp -> { + resp.setModuleFields(resolvefieldValueMap.get(resp.getId())); + }); + + return baseService.setCreateUpdateOwnerUserName(list); + } + + + private Map> buildOptionMap(String formId, String orgId, List list) { + ModuleForm moduleForm = moduleFormMapper.selectByPrimaryKey(formId); + if (moduleForm == null || CollectionUtils.isEmpty(list)) { + return Collections.emptyMap(); + } + + ModuleFormConfigDTO formConfig = moduleFormService.getBusinessFormConfig(moduleForm.getFormKey(), orgId); + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, CustomFormDataListResponse::getModuleFields); + Map> optionMap = moduleFormService.getOptionMap(formConfig, moduleFieldValues); + + List ownerFieldOption = moduleFormService.getBusinessFieldOption(list, + CustomFormDataListResponse::getOwner, CustomFormDataListResponse::getOwnerName); + optionMap.put(BusinessModuleField.CUSTOM_FORM_DATA_OWNER.getBusinessKey(), ownerFieldOption); + + return optionMap; + } + + public CustomFormDataGetResponse get(String id, String userId, String orgId) { + CustomFormData data = customFormDataMapper.selectByPrimaryKey(id); + if (data == null) { + throw new GenericException(CrmHttpResultCode.NOT_FOUND); + } + CustomFormRoleKey dataScope = getDataScope(data.getCustomFormId(), userId); + if (dataScope == CustomFormRoleKey.MANAGE_OWN && !StringUtils.equals(data.getCreateUser(), userId)) { + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + + CustomFormDataGetResponse resp = BeanUtils.copyBean(new CustomFormDataGetResponse(), data); + resp.setIsAdmin(isAdminUser(dataScope, userId, data.getOwner())); + + Map userNameMap = baseService.getUserNameMap( + List.of(data.getOwner(), data.getCreateUser(), data.getUpdateUser()) + ); + resp.setOwnerName(userNameMap.get(data.getOwner())); + resp.setCreateUserName(userNameMap.get(data.getCreateUser())); + resp.setUpdateUserName(userNameMap.get(data.getUpdateUser())); + + CustomFormDataFieldService.setFormKey(data.getCustomFormId()); + try { + ModuleFormConfigDTO formConfig = moduleFormCacheService.getBusinessFormConfig(data.getCustomFormId(), orgId); + List moduleFields = customFormDataFieldService.getModuleFieldValuesByResourceId(id); + Map> optionMap = moduleFormService.getOptionMap(formConfig, moduleFields); + optionMap.put(BusinessModuleField.CUSTOM_FORM_DATA_OWNER.getBusinessKey(), + moduleFormService.getBusinessFieldOption(List.of(resp), + CustomFormDataGetResponse::getOwner, CustomFormDataGetResponse::getOwnerName)); + moduleFormService.processBusinessFieldValues(resp, moduleFields, formConfig); + resp.setAttachmentMap(moduleFormService.getAttachmentMap(formConfig, moduleFields)); + resp.setOptionMap(optionMap); + resp.setModuleFields(moduleFields); + } finally { + CustomFormDataFieldService.clearFormKey(); + } + + return resp; + } + + /** + * 获取详情(⚠️反射调用; 勿修改入参, 返回, 方法名!) + * + * @param id 订单ID + * + * @return 详情 + */ + public CustomFormDataGetResponse getSimple(String id) { + CustomFormData customFormData = customFormDataMapper.selectByPrimaryKey(id); + if (customFormData == null) { + return null; + } + CustomFormDataGetResponse customFormDataGetResponse = BeanUtils.copyBean(new CustomFormDataGetResponse(), customFormData); + // 获取模块字段 + List customFormDataFields = customFormDataFieldService.getModuleFieldValuesByResourceId(id); + customFormDataGetResponse.setModuleFields(customFormDataFields); + return customFormDataGetResponse; + } + + /** + * 批量获取自定义数据详情 (用于数据源批量查询优化) + * @param ids ID集合 + * @return 详情列表 + */ + public List batchGetSimpleByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return Collections.emptyList(); + } + // 批量查询资源基本信息 + List customFormData = customFormDataMapper.selectByIds(ids); + if (CollectionUtils.isEmpty(customFormData)) { + return Collections.emptyList(); + } + // 批量查询自定义字段值 + Map> fieldValueMap = customFormDataFieldService.getResourceFieldMap(ids, true); + + // 组装结果 + return customFormData.stream().map(clue -> { + CustomFormDataGetResponse response = BeanUtils.copyBean(new CustomFormDataGetResponse(), clue); + response.setModuleFields(fieldValueMap.get(clue.getId())); + return response; + }).toList(); + } + + @OperationLog(module = LogModule.CUSTOM_FORM_DATA, type = LogType.ADD) + public CustomFormData add(CustomFormDataAddRequest request, String userId, String orgId) { + CustomFormData data = new CustomFormData(); + data.setId(IDGenerator.nextStr()); + data.setCustomFormId(request.getCustomFormId()); + data.setName(request.getName()); + data.setOwner(StringUtils.isNotBlank(request.getOwner()) ? request.getOwner() : userId); + data.setOrganizationId(orgId); + data.setCreateTime(System.currentTimeMillis()); + data.setUpdateTime(System.currentTimeMillis()); + data.setCreateUser(userId); + data.setUpdateUser(userId); + + CustomFormDataFieldService.setFormKey(request.getCustomFormId()); + try { + customFormDataFieldService.saveModuleField(data, orgId, userId, request.getModuleFields(), false); + } finally { + CustomFormDataFieldService.clearFormKey(); + } + customFormDataMapper.insert(data); + + baseService.handleAddLogWithResourceName(data, request.getModuleFields()); + + return data; + } + + @OperationLog(module = LogModule.CUSTOM_FORM_DATA, type = LogType.UPDATE, resourceId = "{#request.id}") + public void update(CustomFormDataUpdateRequest request, String userId, String orgId) { + CustomFormData originData = customFormDataMapper.selectByPrimaryKey(request.getId()); + if (originData == null) { + throw new GenericException(CrmHttpResultCode.NOT_FOUND); + } + + CustomFormRoleKey dataScope = getDataScope(originData.getCustomFormId(), userId); + checkWritePermission(dataScope, originData.getCreateUser(), userId); + + CustomFormData updateData = new CustomFormData(); + updateData.setId(request.getId()); + updateData.setName(request.getName()); + updateData.setOwner(request.getOwner()); + updateData.setUpdateTime(System.currentTimeMillis()); + updateData.setUpdateUser(userId); + customFormDataMapper.update(updateData); + + + CustomFormDataFieldService.setFormKey(originData.getCustomFormId()); + try { + if (request.getModuleFields() != null) { + List originFields = customFormDataFieldService.getModuleFieldValuesByResourceId(request.getId()); + // 过滤掉引用字段(显示字段),这些字段不需要参与日志对比 + List logOriginFields = filterRefFields(originFields); + List logModifiedFields = filterRefFields(request.getModuleFields()); + baseService.handleUpdateLog(originData, updateData, logOriginFields, logModifiedFields, originData.getId(), originData.getName()); + customFormDataFieldService.deleteByResourceId(request.getId()); + customFormDataFieldService.saveModuleField(updateData, orgId, userId, request.getModuleFields(), true); + } else { + baseService.handleUpdateLog(originData, updateData, null, null, originData.getId(), originData.getName()); + } + } finally { + CustomFormDataFieldService.clearFormKey(); + } + } + + @OperationLog(module = LogModule.CUSTOM_FORM_DATA, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id, String userId) { + CustomFormData data = customFormDataMapper.selectByPrimaryKey(id); + if (data == null) { + throw new GenericException(CrmHttpResultCode.NOT_FOUND); + } + + CustomFormRoleKey dataScope = getDataScope(data.getCustomFormId(), userId); + checkWritePermission(dataScope, data.getCreateUser(), userId); + + customFormDataFieldService.deleteByResourceId(id); + customFormDataMapper.deleteByPrimaryKey(id); + + // 设置操作对象 + OperationLogContext.setResourceName(data.getName()); + } + + public void batchUpdate(CustomFormDataBatchUpdateRequest request, String userId, String orgId) { + List dataList = customFormDataMapper.selectByIds(request.getIds()); + if (CollectionUtils.isEmpty(dataList)) { + return; + } + checkBatchPermission(userId, dataList, request.getCustomFormId()); + CustomFormDataFieldService.setFormKey(request.getCustomFormId()); + try { + BaseField field = customFormDataFieldService.getAndCheckField(request.getFieldId(), orgId); + customFormDataFieldService.batchUpdate(request, field, dataList, CustomFormData.class, LogModule.CUSTOM_FORM_DATA, extCustomFormDataMapper::batchUpdate, userId, orgId); + } finally { + CustomFormDataFieldService.clearFormKey(); + } + } + + private void checkBatchPermission(String userId, List dataList, String formId) { + CustomFormRoleKey dataScope = getDataScope(formId, userId); + if (dataScope == CustomFormRoleKey.VIEW_ALL) { + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + for (CustomFormData customFormData : dataList) { + if (!Strings.CI.equals(customFormData.getCustomFormId(), formId)) { + // 数据所属表单不一致,禁止批量操作 + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + if (dataScope == CustomFormRoleKey.MANAGE_OWN && !StringUtils.equals(customFormData.getOwner(), userId)) { + // 仅能操作自己负责的数据,且数据负责人不为当前用户,禁止操作 + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + } + } + + public void batchDelete(List ids, String userId, String orgId) { + List dataList = customFormDataMapper.selectByIds(ids); + if (CollectionUtils.isEmpty(dataList)) { + return; + } + + String formId = dataList.getFirst().getCustomFormId(); + checkBatchPermission(userId, dataList, formId); + + List deletableIds = dataList.stream() + .map(CustomFormData::getId) + .toList(); + + customFormDataFieldService.deleteByResourceIds(deletableIds); + customFormDataMapper.deleteByIds(deletableIds); + + List logs = dataList.stream() + .map(data -> + new LogDTO(orgId, data.getId(), userId, LogType.DELETE, LogModule.CUSTOM_FORM_DATA, data.getName()) + ) + .toList(); + logService.batchAdd(logs); + } + + private void checkWritePermission(CustomFormRoleKey dataScope, String dataCreateUser, String currentUserId) { + if (dataScope == CustomFormRoleKey.VIEW_ALL) { + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + if (dataScope == CustomFormRoleKey.MANAGE_OWN && !StringUtils.equals(dataCreateUser, currentUserId)) { + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + } + + CustomFormRoleKey getDataScope(String formId, String userId) { + return getDataScope(formId, userId, true); + } + + CustomFormRoleKey getDataScope(String formId, String userId, boolean checkEnable) { + CustomForm customForm = customFormMapper.selectByPrimaryKey(formId); + if (customFormService.isFormAdminUser(formId, userId)) { + // 管理员管理所有数据 + return CustomFormRoleKey.MANAGE_ALL; + } + + if (checkEnable && BooleanUtils.isFalse(customForm.getEnable())) { + // 表单未启用,非管理员没有权限查看 + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + + // check role membership + LambdaQueryWrapper roleWrapper = new LambdaQueryWrapper<>(); + roleWrapper.eq(CustomFormRole::getCustomFormId, formId); + List roles = customFormRoleMapper.selectListByLambda(roleWrapper); + if (CollectionUtils.isEmpty(roles)) { + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + + Map roleKeyMap = roles.stream() + .collect(Collectors.toMap(CustomFormRole::getId, r -> { + for (CustomFormRoleKey key : CustomFormRoleKey.values()) { + if (key.getKey().equals(r.getInternalKey())) { + return key; + } + } + return null; + })); + List roleIds = roles.stream().map(CustomFormRole::getId).toList(); + + LambdaQueryWrapper ruWrapper = new LambdaQueryWrapper<>(); + ruWrapper.in(CustomFormRoleUser::getRoleId, roleIds).eq(CustomFormRoleUser::getUserId, userId); + List roleUsers = customFormRoleUserMapper.selectListByLambda(ruWrapper); + + if (CollectionUtils.isEmpty(roleUsers)) { + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + + Set userRoleKeys = roleUsers.stream() + .map(ru -> roleKeyMap.get(ru.getRoleId())) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + if (userRoleKeys.contains(CustomFormRoleKey.MANAGE_ALL)) { + return CustomFormRoleKey.MANAGE_ALL; + } + if (userRoleKeys.contains(CustomFormRoleKey.VIEW_ALL)) { + return CustomFormRoleKey.VIEW_ALL; + } + if (userRoleKeys.contains(CustomFormRoleKey.MANAGE_OWN)) { + return CustomFormRoleKey.MANAGE_OWN; + } + + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + + public String getNameStrByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return StringUtils.EMPTY; + } + List customFormDataList = customFormDataMapper.selectByIds(ids); + if (CollectionUtils.isNotEmpty(customFormDataList)) { + List names = customFormDataList.stream().map(CustomFormData::getName).toList(); + return String.join(",", names); + } + return StringUtils.EMPTY; + } + + public List selectByNames(List names) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.in(CustomFormData::getName, names); + return customFormDataMapper.selectListByLambda(lambdaQueryWrapper); + } + + public String getNameById(String id) { + CustomFormData customFormData = customFormDataMapper.selectByPrimaryKey(id); + return Optional.ofNullable(customFormData).map(CustomFormData::getName).orElse(null); + } + + /** + * 下载导入模板 + * + * @param response 响应 + * @param customFormId 自定义表单ID + * @param orgId 组织ID + */ + public void downloadImportTpl(HttpServletResponse response, String customFormId, String orgId) { + CustomForm customForm = customFormMapper.selectByPrimaryKey(customFormId); + String formName = customForm != null ? customForm.getName() : StringUtils.EMPTY; + new EasyExcelExporter() + .exportMultiSheetTplWithSharedHandler(response, moduleFormService.getCustomImportHeadsNoRef(customFormId, orgId), + Translator.getWithArgs("custom_form_data.import_tpl.name", formName), Translator.get("sheet.data"), Translator.get("sheet.comment"), + new CustomTemplateWriteHandler(moduleFormService.getAllCustomImportFields(customFormId, orgId)), + new CustomHeadColWidthStyleStrategy()); + } + + /** + * 导入预检查 + * + * @param file 导入文件 + * @param customFormId 自定义表单ID + * @param orgId 组织ID + * + * @return 导入检查信息 + */ + public ImportResponse importPreCheck(MultipartFile file, String customFormId, String orgId) { + if (file == null) { + throw new GenericException(Translator.get("file_cannot_be_null")); + } + return checkImportExcel(file, customFormId, orgId); + } + + /** + * 自定义表单数据导入 + * + * @param file 导入文件 + * @param customFormId 自定义表单ID + * @param orgId 组织ID + * @param userId 用户ID + * + * @return 导入结果 + */ + public ImportResponse realImport(MultipartFile file, String customFormId, String orgId, String userId) { + try { + CustomFormDataFieldService.setFormKey(customFormId); + List fields = moduleFormService.getAllFields(customFormId, orgId); + CustomImportAfterDoConsumer afterDo = (dataList, fieldList, fieldBlobList) -> { + var logs = new ArrayList(); + dataList.forEach(data -> { + data.setCustomFormId(customFormId); + data.setOrganizationId(orgId); + if (StringUtils.isBlank(data.getOwner())) { + data.setOwner(userId); + } + logs.add(new LogDTO(orgId, data.getId(), userId, LogType.ADD, LogModule.CUSTOM_FORM_DATA, data.getName())); + }); + customFormDataMapper.batchInsert(dataList); + customFormDataFieldMapper.batchInsert(fieldList.stream() + .map(field -> BeanUtils.copyBean(new CustomFormDataField(), field)).toList()); + customFormDataFieldBlobMapper.batchInsert(fieldBlobList.stream() + .map(field -> BeanUtils.copyBean(new CustomFormDataFieldBlob(), field)).toList()); + logService.batchAdd(logs); + }; + CustomFieldImportEventListener eventListener = new CustomFieldImportEventListener<>( + fields, CustomFormData.class, orgId, userId, "custom_form_data_field", afterDo, 2000, null, null); + FastExcelFactory.read(file.getInputStream(), eventListener) + .headRowNumber(1).ignoreEmptyRow(true).sheet().doRead(); + return ImportResponse.builder().errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccessCount()).failCount(eventListener.getErrList().size()).build(); + } catch (Exception e) { + log.error("custom form data import error: {}", e.getMessage()); + throw new GenericException(e.getMessage()); + } finally { + CustomFormDataFieldService.clearFormKey(); + } + } + + /** + * 检查导入文件 + * + * @param file 文件 + * @param customFormId 自定义表单ID + * @param orgId 组织ID + * + * @return 检查结果 + */ + private ImportResponse checkImportExcel(MultipartFile file, String customFormId, String orgId) { + try { + CustomFormDataFieldService.setFormKey(customFormId); + List fields = moduleFormService.getAllCustomImportFields(customFormId, orgId); + CustomFieldCheckEventListener eventListener = new CustomFieldCheckEventListener(fields, "custom_form_data", "custom_form_data_field", orgId); + FastExcelFactory.read(file.getInputStream(), eventListener) + .headRowNumber(1).ignoreEmptyRow(true).sheet().doRead(); + return ImportResponse.builder().errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccess()).failCount(eventListener.getErrList().size()).build(); + } catch (Exception e) { + log.error("custom form data import pre-check error: {}", e.getMessage()); + throw new GenericException(e.getMessage()); + } finally { + CustomFormDataFieldService.clearFormKey(); + } + } + + /** + * 过滤掉引用字段(显示字段),这些字段不需要参与日志对比 + */ + private List filterRefFields(List fields) { + if (CollectionUtils.isEmpty(fields)) { + return fields; + } + return fields.stream() + .filter(f -> !f.getFieldId().contains(BaseResourceFieldService.REF_UNDERLINE)) + .toList(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormLogService.java b/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormLogService.java new file mode 100644 index 0000000..800cfd5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormLogService.java @@ -0,0 +1,162 @@ +package cn.cordys.crm.form.service; + +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.form.domain.CustomFormRole; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.crm.system.service.BaseModuleLogService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class CustomFormLogService extends BaseModuleLogService { + + @Resource + private BaseMapper customFormRoleMapper; + @Resource + private ExtUserMapper extUserMapper; + + @Override + public List handleLogField(List differences, String orgId) { + differences.forEach(differ -> { + if (Strings.CS.equals(differ.getColumn(), "enable")) { + differ.setColumnName(Translator.get("log.enable")); + if (differ.getOldValue() != null) { + differ.setOldValueName(Translator.get(Boolean.parseBoolean(differ.getOldValue().toString()) ? "log.enable.true" : "log.enable.false")); + } + if (differ.getNewValue() != null) { + differ.setNewValueName(Translator.get(Boolean.parseBoolean(differ.getNewValue().toString()) ? "log.enable.true" : "log.enable.false")); + } + } else if (Strings.CS.equals(differ.getColumn(), "adminUserIds")) { + handleAdminUserIdsLogDetail(differ); + } else if (Strings.CS.equals(differ.getColumn(), "roleUsers")) { + handleRoleUsersLogDetail(differ); + } else if (Strings.CS.equals(differ.getColumn(), "name")) { + differ.setColumnName(Translator.get("log.form." + differ.getColumn())); + differ.setOldValueName(differ.getOldValue()); + differ.setNewValueName(differ.getNewValue()); + } else if (Strings.CS.equals(differ.getColumn(), "fields")) { + differ.setColumnName(Translator.get("log." + differ.getColumn())); + handleFieldsLogDetail(differ); + } else if (Strings.CS.equals("formProp", differ.getColumn())) { + differ.setColumnName(Translator.get("log.form.prop")); + handleFormPropLogDetail(differ); + } else { + translatorDifferInfo(differ); + } + }); + differences.removeIf(differ -> differ.getOldValueName() == null && differ.getNewValueName() == null); + return differences; + } + + @SuppressWarnings("unchecked") + private void handleAdminUserIdsLogDetail(JsonDifferenceDTO differ) { + differ.setColumnName(Translator.get("log.adminUserIds")); + Map userNameMap = getUserNameMapFromValues(differ.getOldValue(), differ.getNewValue()); + + if (differ.getOldValue() instanceof List oldUserIds) { + differ.setOldValueName(formatUserNames(oldUserIds, userNameMap)); + } + if (differ.getNewValue() instanceof List newUserIds) { + differ.setNewValueName(formatUserNames(newUserIds, userNameMap)); + } + } + + @SuppressWarnings("unchecked") + private void handleRoleUsersLogDetail(JsonDifferenceDTO differ) { + differ.setColumnName(Translator.get("log.roleUsers")); + + Map> oldRoleUsers = new HashMap<>(); + Map> newRoleUsers = new HashMap<>(); + Set allUserIds = new HashSet<>(); + Set allRoleIds = new HashSet<>(); + + if (differ.getOldValue() instanceof Map oldMap) { + oldMap.forEach((roleId, userIds) -> { + String roleIdStr = String.valueOf(roleId); + List uids = (List) userIds; + oldRoleUsers.put(roleIdStr, uids); + allUserIds.addAll(uids); + allRoleIds.add(roleIdStr); + }); + } + if (differ.getNewValue() instanceof Map newMap) { + newMap.forEach((roleId, userIds) -> { + String roleIdStr = String.valueOf(roleId); + List uids = (List) userIds; + newRoleUsers.put(roleIdStr, uids); + allUserIds.addAll(uids); + allRoleIds.add(roleIdStr); + }); + } + + Map userNameMap = getUserNameMapByIds(new ArrayList<>(allUserIds)); + Map roleNameMap = getRoleNameMap(allRoleIds); + + if (!oldRoleUsers.isEmpty()) { + differ.setOldValueName(formatRoleUsersText(oldRoleUsers, roleNameMap, userNameMap)); + } + if (!newRoleUsers.isEmpty()) { + differ.setNewValueName(formatRoleUsersText(newRoleUsers, roleNameMap, userNameMap)); + } + } + + private String formatUserNames(List userIds, Map userNameMap) { + return userIds.stream() + .map(id -> userNameMap.getOrDefault(String.valueOf(id), String.valueOf(id))) + .collect(Collectors.joining(", ")); + } + + private String formatRoleUsersText(Map> roleUsers, + Map roleNameMap, + Map userNameMap) { + return roleUsers.entrySet().stream() + .map(entry -> { + String roleName = roleNameMap.getOrDefault(entry.getKey(), entry.getKey()); + String userNames = entry.getValue().stream() + .map(uid -> userNameMap.getOrDefault(uid, uid)) + .collect(Collectors.joining(", ")); + return roleName + ": " + userNames; + }) + .collect(Collectors.joining("\n")); + } + + private Map getUserNameMapFromValues(Object oldValue, Object newValue) { + List userIds = new ArrayList<>(); + if (oldValue instanceof List oldList) { + oldList.forEach(id -> userIds.add(String.valueOf(id))); + } + if (newValue instanceof List newList) { + newList.forEach(id -> userIds.add(String.valueOf(id))); + } + return getUserNameMapByIds(userIds); + } + + private Map getUserNameMapByIds(List userIds) { + if (userIds.isEmpty()) { + return Collections.emptyMap(); + } + return extUserMapper.selectUserOptionByIds(userIds).stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName, (a, b) -> a)); + } + + private Map getRoleNameMap(Set roleIds) { + if (roleIds.isEmpty()) { + return Collections.emptyMap(); + } + List roles = customFormRoleMapper.selectByIds(new ArrayList<>(roleIds)); + return roles.stream().collect(Collectors.toMap( + CustomFormRole::getId, + role -> Translator.get(role.getName(), role.getName()), + (a, b) -> a + )); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormRoleService.java b/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormRoleService.java new file mode 100644 index 0000000..c00e0bb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormRoleService.java @@ -0,0 +1,247 @@ +package cn.cordys.crm.form.service; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.DeptUserTreeNode; +import cn.cordys.common.dto.RoleUserTreeNode; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.response.result.CrmHttpResultCode; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.SubListUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.form.domain.CustomForm; +import cn.cordys.crm.form.domain.CustomFormAdmin; +import cn.cordys.crm.form.domain.CustomFormRole; +import cn.cordys.crm.form.domain.CustomFormRoleUser; +import cn.cordys.crm.form.dto.request.CustomFormRoleUserBatchRequest; +import cn.cordys.crm.form.dto.request.CustomFormRoleUserPageRequest; +import cn.cordys.crm.form.dto.response.CustomFormRoleListResponse; +import cn.cordys.crm.form.dto.response.CustomFormRoleUserListResponse; +import cn.cordys.crm.form.mapper.ExtCustomFormRoleUserMapper; +import cn.cordys.crm.system.dto.convert.UserRoleConvert; +import cn.cordys.crm.system.dto.response.RoleListResponse; +import cn.cordys.crm.system.mapper.ExtDepartmentMapper; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.crm.system.mapper.ExtUserRoleMapper; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.RoleService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.ListUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class CustomFormRoleService { + + @Resource + private BaseMapper customFormRoleMapper; + @Resource + private BaseMapper customFormRoleUserMapper; + @Resource + private BaseMapper customFormAdminMapper; + @Resource + private ExtDepartmentMapper extDepartmentMapper; + @Resource + private ExtUserRoleMapper extUserRoleMapper; + @Resource + private ExtCustomFormRoleUserMapper extCustomFormRoleUserMapper; + @Resource + private ExtUserMapper extUserMapper; + @Resource + private RoleService roleService; + @Resource + private DepartmentService departmentService; + @Resource + private BaseMapper customFormMapper; + @Resource + private LogService logService; + + public List listByFormId(String customFormId, String userId) { + checkFormAdmin(customFormId, userId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CustomFormRole::getCustomFormId, customFormId); + List roles = customFormRoleMapper.selectListByLambda(wrapper); + + return roles.stream().map(role -> { + CustomFormRoleListResponse resp = new CustomFormRoleListResponse(); + resp.setId(role.getId()); + resp.setName(role.getName()); + resp.setCustomFormId(role.getCustomFormId()); + resp.setInternalKey(role.getInternalKey()); + return resp; + }).toList(); + } + + public Pager> listUsersByRole(CustomFormRoleUserPageRequest request, + String userId, String orgId) { + CustomFormRole role = customFormRoleMapper.selectByPrimaryKey(request.getCustomFormRoleId()); + if (role == null) { + throw new GenericException(Translator.get("custom.form.role.not.exist")); + } + checkFormAdmin(role.getCustomFormId(), userId); + + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List roleUsers = extCustomFormRoleUserMapper.listByRoleId(orgId, request); + fillRoles(roleUsers, orgId); + return PageUtils.setPageInfo(page, roleUsers); + } + + public List getDeptUserTree(String orgId) { + List treeNodes = extDepartmentMapper.selectDeptUserTreeNode(orgId); + List userNodes = extUserRoleMapper.selectUserDeptForOrg(orgId); + userNodes = departmentService.sortByCommander(orgId, userNodes); + userNodes.addAll(treeNodes); + return BaseTreeNode.buildTree(userNodes); + } + + public List getRoleUserTree(String orgId) { + List roles = roleService.list(orgId); + List treeNodes = roles.stream().map(role -> { + RoleUserTreeNode roleNode = new RoleUserTreeNode(); + roleNode.setNodeType("ROLE"); + roleNode.setInternal(BooleanUtils.isTrue(role.getInternal())); + roleNode.setId(role.getId()); + roleNode.setName(role.getName()); + return roleNode; + }).collect(Collectors.toList()); + + List userNodes = extUserRoleMapper.selectUserRoleForOrg(orgId); + treeNodes.addAll(userNodes); + return BaseTreeNode.buildTree(treeNodes); + } + + private void fillRoles(List roleUsers, String orgId) { + if (CollectionUtils.isEmpty(roleUsers)) { + return; + } + List userIds = roleUsers.stream().map(CustomFormRoleUserListResponse::getUserId).toList(); + List userRoles = extUserMapper.getUserRole(userIds, orgId); + userRoles.forEach(role -> role.setName(roleService.translateInternalRole(role.getName()))); + Map> userRoleMap = userRoles.stream() + .collect(Collectors.groupingBy(UserRoleConvert::getUserId)); + roleUsers.forEach(roleUser -> roleUser.setRoles(userRoleMap.getOrDefault(roleUser.getUserId(), List.of()))); + } + + public void addUsers(CustomFormRoleUserBatchRequest request, String userId, String orgId) { + CustomFormRole role = customFormRoleMapper.selectByPrimaryKey(request.getCustomFormRoleId()); + if (role == null) { + throw new GenericException(Translator.get("custom.form.role.not.exist")); + } + checkFormAdmin(role.getCustomFormId(), userId); + + List resolvedUserIds = resolveUserIds(request); + if (CollectionUtils.isEmpty(resolvedUserIds)) { + return; + } + List userIds = new ArrayList<>(); + SubListUtils.dealForSubList(resolvedUserIds, SubListUtils.DEFAULT_QUERY_BATCH_SIZE, + subUserIds -> userIds.addAll(extUserMapper.filterEnabledUserIds(subUserIds, orgId))); + if (CollectionUtils.isEmpty(userIds)) { + return; + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CustomFormRoleUser::getRoleId, request.getCustomFormRoleId()); + List currentRoleUserIds = customFormRoleUserMapper.selectListByLambda(wrapper) + .stream() + .map(CustomFormRoleUser::getUserId) + .toList(); + + long now = System.currentTimeMillis(); + List insertUserIds = ListUtils.subtract(userIds, currentRoleUserIds); + List toInsert = insertUserIds + .stream() + .map(uid -> { + CustomFormRoleUser roleUser = new CustomFormRoleUser(); + roleUser.setId(IDGenerator.nextStr()); + roleUser.setRoleId(request.getCustomFormRoleId()); + roleUser.setUserId(uid); + roleUser.setCreateTime(now); + roleUser.setUpdateTime(now); + roleUser.setCreateUser(userId); + roleUser.setUpdateUser(userId); + return roleUser; + }).toList(); + + if (CollectionUtils.isNotEmpty(toInsert)) { + customFormRoleUserMapper.batchInsert(toInsert); + } + + CustomForm customForm = customFormMapper.selectByPrimaryKey(role.getCustomFormId()); + List userNames = extUserMapper.selectUserNameByIds(insertUserIds); + LogDTO logDTO = new LogDTO(orgId, role.getCustomFormId(), userId, LogType.ADD_USER, LogModule.CUSTOM_FORM, customForm.getName()); + String detail = "[" + role.getName() + "]: " + userNames.stream().collect(Collectors.joining(", ")); + logDTO.setDetail(detail); + logService.add(logDTO); + } + + public void removeUsers(CustomFormRoleUserBatchRequest request, String userId, String orgId) { + CustomFormRole role = customFormRoleMapper.selectByPrimaryKey(request.getCustomFormRoleId()); + if (role == null) { + throw new GenericException(Translator.get("custom.form.role.not.exist")); + } + checkFormAdmin(role.getCustomFormId(), userId); + + if (CollectionUtils.isEmpty(request.getUserIds())) { + return; + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CustomFormRoleUser::getRoleId, request.getCustomFormRoleId()).in(CustomFormRoleUser::getId, request.getUserIds()); + List roleUserIds = customFormRoleUserMapper.selectByIds(request.getUserIds()) + .stream() + .map(CustomFormRoleUser::getUserId).toList(); + customFormRoleUserMapper.deleteByLambda(wrapper); + + CustomForm customForm = customFormMapper.selectByPrimaryKey(role.getCustomFormId()); + + List userNames = extUserMapper.selectUserNameByIds(roleUserIds); + LogDTO logDTO = new LogDTO(orgId, role.getCustomFormId(), userId, LogType.REMOVE_USER, LogModule.CUSTOM_FORM, customForm.getName()); + String detail = "[" + role.getName() + "]: " + userNames.stream().collect(Collectors.joining(", ")); + logDTO.setDetail(detail); + logService.add(logDTO); + } + + private List resolveUserIds(CustomFormRoleUserBatchRequest request) { + Set userSet = new HashSet<>(); + if (CollectionUtils.isNotEmpty(request.getRoleIds())) { + userSet.addAll(extUserRoleMapper.getUserIdsByRoleIds(request.getRoleIds())); + } + if (CollectionUtils.isNotEmpty(request.getDeptIds())) { + userSet.addAll(extDepartmentMapper.getUserIdsByDeptIds(request.getDeptIds())); + } + if (CollectionUtils.isNotEmpty(request.getUserIds())) { + userSet.addAll(request.getUserIds()); + } + return new ArrayList<>(userSet); + } + + private void checkFormAdmin(String formId, String userId) { + if (Objects.equals(InternalUser.ADMIN.getValue(), userId)) { + return; + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CustomFormAdmin::getCustomFormId, formId).eq(CustomFormAdmin::getUserId, userId); + if (customFormAdminMapper.selectListByLambda(wrapper).isEmpty()) { + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormService.java b/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormService.java new file mode 100644 index 0000000..901b272 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/form/service/CustomFormService.java @@ -0,0 +1,539 @@ +package cn.cordys.crm.form.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.response.result.CrmHttpResultCode; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.form.domain.*; +import cn.cordys.crm.form.dto.request.CustomFormAddRequest; +import cn.cordys.crm.form.dto.request.CustomFormAdminBatchRequest; +import cn.cordys.crm.form.dto.request.CustomFormUpdateRequest; +import cn.cordys.crm.form.dto.response.CustomFormGetResponse; +import cn.cordys.crm.form.dto.response.CustomFormListResponse; +import cn.cordys.crm.form.mapper.ExtCustomFormDataMapper; +import cn.cordys.crm.form.mapper.ExtCustomFormMapper; +import cn.cordys.crm.system.domain.ModuleForm; +import cn.cordys.crm.system.domain.ModuleFormBlob; +import cn.cordys.crm.system.dto.form.FormProp; +import cn.cordys.crm.system.dto.request.ModuleFormSaveRequest; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.dto.response.ModuleFormConfigLogDTO; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class CustomFormService { + + @Resource + private BaseMapper customFormMapper; + @Resource + private ExtCustomFormMapper extCustomFormMapper; + @Resource + private BaseMapper customFormAdminMapper; + @Resource + private BaseMapper customFormRoleMapper; + @Resource + private BaseMapper customFormRoleUserMapper; + @Resource + private BaseMapper moduleFormMapper; + @Resource + private BaseMapper moduleFormBlobMapper; + @Resource + private ModuleFormService moduleFormService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ExtCustomFormDataMapper extCustomFormDataMapper; + @Resource + private ExtUserMapper extUserMapper; + + @Value("classpath:form/form.json") + private org.springframework.core.io.Resource formResource; + + public List getOptions() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CustomForm::getEnable, true); + return customFormMapper.selectListByLambda(wrapper).stream() + .map(form -> new OptionDTO(form.getId(), form.getName())) + .toList(); + } + + public List list(String userId) { + List customForms; + Set adminFormIds; + Set hasCreatePermissionFormIds; + if (Strings.CS.equals(InternalUser.ADMIN.getValue(), userId)) { + customForms = customFormMapper.selectAll(null); + adminFormIds = customForms.stream().map(CustomForm::getId).collect(Collectors.toSet()); + hasCreatePermissionFormIds = adminFormIds; + } else { + adminFormIds = getAdminFormIds(userId); + List formRoles = getFormRoles(userId); + // formRoles 按 customFormId 分组,后续可以展示角色信息 + Map> roleMap = formRoles.stream() + .collect(Collectors.groupingBy(CustomFormRole::getCustomFormId)); + Set memberFormIds = roleMap.keySet(); + + if (adminFormIds.isEmpty() && memberFormIds.isEmpty()) { + return Collections.emptyList(); + } + + Set accessibleFormIds = new HashSet<>(); + accessibleFormIds.addAll(adminFormIds); + accessibleFormIds.addAll(memberFormIds); + + customForms = customFormMapper.selectByIds(accessibleFormIds.stream().toList()); + + hasCreatePermissionFormIds = getCreatePermissionFormIds(customForms, roleMap); + + // 非管理员只能看到启用的表单 + customForms = customForms.stream() + .filter(form -> adminFormIds.contains(form.getId()) || BooleanUtils.isTrue(form.getEnable())) + .toList(); + } + Set finalHasCreatePermissionFormIds = hasCreatePermissionFormIds; + return customForms.stream() + .map(form -> { + CustomFormListResponse resp = new CustomFormListResponse(); + resp.setId(form.getId()); + resp.setName(form.getName()); + resp.setEnable(form.getEnable()); + resp.setOrganizationId(form.getOrganizationId()); + // 标记是否是管理员 + resp.setIsAdmin(adminFormIds.contains(form.getId())); + resp.setHasCreateDataPermission(finalHasCreatePermissionFormIds.contains(form.getId())); + return resp; + }).toList(); + } + + private Set getCreatePermissionFormIds(List customForms, Map> roleMap) { + Set hasCreatePermissionFormIds = new HashSet<>(); + for (CustomForm form : customForms) { + // 如果角色中有 CustomFormRoleKey 中的 MANAGE_ALL 或者 MANAGE_OWN,则有创建权限 + List roles = roleMap.get(form.getId()); + boolean hasCreatePermission = roles != null && roles.stream() + .anyMatch(role -> CustomFormRoleKey.MANAGE_ALL.getKey().equals(role.getInternalKey()) + || CustomFormRoleKey.MANAGE_OWN.getKey().equals(role.getInternalKey())); + if (hasCreatePermission) { + hasCreatePermissionFormIds.add(form.getId()); + } + } + return hasCreatePermissionFormIds; + } + + public CustomFormGetResponse get(String id, String userId, String orgId) { + checkFormAccess(id, userId); + + CustomForm form = customFormMapper.selectByPrimaryKey(id); + ModuleForm moduleForm = moduleFormMapper.selectByPrimaryKey(id); + if (form == null) { + throw new GenericException(CrmHttpResultCode.NOT_FOUND); + } + + CustomFormGetResponse resp = BeanUtils.copyBean(new CustomFormGetResponse(), form); + resp.setCreator(getUserOption(form.getCreateUser())); + + if (moduleForm != null) { + ModuleFormConfigDTO businessFormConfig = moduleFormService.getBusinessFormConfig(moduleForm.getFormKey(), orgId); + resp.setFields(businessFormConfig.getFields()); + resp.setFormProp(businessFormConfig.getFormProp()); + } + + resp.setIsAdmin(isFormAdminUser(form.getId(), userId)); + return resp; + } + + private OptionDTO getUserOption(String userId) { + if (StringUtils.isBlank(userId)) { + return null; + } + return extUserMapper.selectUserOptionByIds(List.of(userId)).stream() + .findFirst() + .orElseGet(() -> new OptionDTO(userId, null)); + } + + public List getAdmins(String formId, String userId) { + checkFormAccess(formId, userId); + return getAdminOptions(formId); + } + + private List getAdminOptions(String formId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CustomFormAdmin::getCustomFormId, formId); + List admins = customFormAdminMapper.selectListByLambda(wrapper); + if (CollectionUtils.isEmpty(admins)) { + return Collections.emptyList(); + } + + List userIds = admins.stream() + .map(CustomFormAdmin::getUserId) + .distinct() + .toList(); + Map userNameMap = new HashMap<>(userIds.size()); + extUserMapper.selectUserOptionByIds(userIds) + .forEach(option -> userNameMap.putIfAbsent(option.getId(), option.getName())); + return userIds.stream() + .map(uid -> new OptionDTO(uid, userNameMap.get(uid))) + .toList(); + } + + @OperationLog(module = LogModule.CUSTOM_FORM, type = LogType.ADD, resourceName = "{#request.name}") + public CustomForm create(CustomFormAddRequest request, String userId, String orgId) { + checkNameUnique(request.getName(), null); + String formId = IDGenerator.nextStr(); + + // 保存 custom_form + CustomForm form = new CustomForm(); + form.setId(formId); + form.setName(request.getName()); + form.setEnable(!BooleanUtils.isFalse(request.getEnable())); + form.setOrganizationId(orgId); + form.setCreateTime(System.currentTimeMillis()); + form.setUpdateTime(System.currentTimeMillis()); + form.setCreateUser(userId); + form.setUpdateUser(userId); + + checkAddExist(form); + customFormMapper.insert(form); + + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(formId) + .modifiedValue(form) + .build() + ); + + // 保存 sys_module_form 使用相同的 ID, formKey 也使用 formId + ModuleForm moduleForm = new ModuleForm(); + moduleForm.setId(formId); + moduleForm.setFormKey(formId); + moduleForm.setOrganizationId(orgId); + moduleForm.setCreateTime(System.currentTimeMillis()); + moduleForm.setUpdateTime(System.currentTimeMillis()); + moduleForm.setCreateUser(userId); + moduleForm.setUpdateUser(userId); + moduleFormMapper.insert(moduleForm); + + // 保存表单配置 + ModuleFormBlob formBlob = new ModuleFormBlob(); + formBlob.setId(formId); + FormProp formProp = getFormPropForCreate(request.getFormProp()); + formBlob.setProp(JSON.toJSONString(formProp)); + moduleFormBlobMapper.insert(formBlob); + + // 校验字段 + moduleFormService.preCheckForFieldSave(formId, request.getFields()); + + // 保存字段 + moduleFormService.saveFields(request.getFields(), form.getId(), userId); + + // 创建内置角色 + createBuiltinRoles(formId, userId); + + // 将当前用户设置为管理员 + createFormAdmin(userId, formId); + + return form; + } + + private void createFormAdmin(String userId, String formId) { + CustomFormAdmin admin = new CustomFormAdmin(); + admin.setId(IDGenerator.nextStr()); + admin.setCustomFormId(formId); + admin.setUserId(userId); + customFormAdminMapper.insert(admin); + } + + private FormProp getFormPropForCreate(FormProp createFormProp) { + if (createFormProp == null) { + try { + return JSON.parseObject(formResource.getInputStream(), FormProp.class); + } catch (IOException e) { + log.error(e.getMessage(), e); + return new FormProp(); + } + } + return createFormProp; + } + + @OperationLog(module = LogModule.CUSTOM_FORM, type = LogType.UPDATE, resourceId = "{#request.id}") + public void update(CustomFormUpdateRequest request, String userId, String orgId) { + checkFormAdmin(request.getId(), userId); + CustomForm originForm = customFormMapper.selectByPrimaryKey(request.getId()); + ModuleForm originModuleForm = moduleFormMapper.selectByPrimaryKey(request.getId()); + if (originForm == null || originModuleForm == null) { + throw new GenericException(Translator.get("custom.form.not.exist")); + } + + CustomForm updateForm = new CustomForm(); + updateForm.setId(request.getId()); + updateForm.setName(request.getName()); + updateForm.setEnable(request.getEnable()); + + checkUpdateExist(updateForm); + customFormMapper.update(updateForm); + + ModuleFormSaveRequest moduleFormRequest = new ModuleFormSaveRequest(); + moduleFormRequest.setFormKey(originModuleForm.getFormKey()); + moduleFormRequest.setFormProp(request.getFormProp()); + moduleFormRequest.setFields(request.getFields()); + + // 获取表单日志信息 + LogContextInfo formChangeLogContext = moduleFormService.getModuleFormChangeLogContext(originModuleForm.getFormKey(), orgId, moduleFormRequest); + formChangeLogContext.setResourceName(request.getName()); + // 追加自定义表单名称 + if (formChangeLogContext.getOriginalValue() instanceof ModuleFormConfigLogDTO originalLog) { + originalLog.setName(originForm.getName()); + } + if (formChangeLogContext.getModifiedValue() instanceof ModuleFormConfigLogDTO modifiedLog) { + modifiedLog.setName(request.getName()); + } + + // 保存表单配置 + moduleFormCacheService.saveWithoutLog(moduleFormRequest, userId, orgId); + + // 设置日志上下文 + OperationLogContext.setContext(formChangeLogContext); + } + + @OperationLog(module = LogModule.CUSTOM_FORM, type = LogType.UPDATE, resourceId = "{#id}") + public void updateEnable(String id, String userId, boolean enable) { + checkFormAdmin(id, userId); + + CustomForm form = customFormMapper.selectByPrimaryKey(id); + if (form == null) { + throw new GenericException(Translator.get("custom.form.not.exist")); + } + + CustomForm updateForm = new CustomForm(); + updateForm.setId(id); + updateForm.setEnable(enable); + customFormMapper.update(updateForm); + updateForm.setName(form.getName()); + + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceName(form.getName()) + .originalValue(form) + .modifiedValue(updateForm) + .build() + ); + } + + @OperationLog(module = LogModule.CUSTOM_FORM, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id, String userId) { + checkFormAdmin(id, userId); + + CustomForm form = customFormMapper.selectByPrimaryKey(id); + if (form == null) { + throw new GenericException(Translator.get("custom.form.not.exist")); + } + + // 设置操作对象 + OperationLogContext.setResourceName(form.getName()); + + // 删除表单管理员 + deleteCustomFormAdminByFormId(id); + + // 删除表单角色和成员 + deleteCustomFormRoleAndUser(id); + + // 删除表单数据(多表联删,一次扫描) + extCustomFormDataMapper.deleteFormDataByCustomFormId(id); + + // 删除表单 + customFormMapper.deleteByIds(List.of(id)); + } + + private void deleteCustomFormRoleAndUser(String formId) { + LambdaQueryWrapper roleWrapper = new LambdaQueryWrapper<>(); + roleWrapper.eq(CustomFormRole::getCustomFormId, formId); + List roles = customFormRoleMapper.selectListByLambda(roleWrapper); + if (CollectionUtils.isNotEmpty(roles)) { + List roleIds = roles.stream().map(CustomFormRole::getId).toList(); + LambdaQueryWrapper roleUserWrapper = new LambdaQueryWrapper<>(); + roleUserWrapper.in(CustomFormRoleUser::getRoleId, roleIds); + customFormRoleUserMapper.deleteByLambda(roleUserWrapper); + customFormRoleMapper.deleteByLambda(roleWrapper); + } + } + + private void deleteCustomFormAdminByFormId(String formId) { + LambdaQueryWrapper adminWrapper = new LambdaQueryWrapper<>(); + adminWrapper.eq(CustomFormAdmin::getCustomFormId, formId); + customFormAdminMapper.deleteByLambda(adminWrapper); + } + + @OperationLog(module = LogModule.CUSTOM_FORM, type = LogType.UPDATE, resourceId = "{#request.customFormId}") + public void setAdmins(CustomFormAdminBatchRequest request, String userId) { + checkFormAdmin(request.getCustomFormId(), userId); + + List originUserIds = getAdminOptions(request.getCustomFormId()).stream().map(OptionDTO::getId).toList(); + + String formId = request.getCustomFormId(); + deleteCustomFormAdminByFormId(formId); + insertAdmins(formId, request.getUserIds()); + + CustomForm customForm = customFormMapper.selectByPrimaryKey(formId); + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceName(customForm.getName()) + .originalValue(Map.of("adminUserIds", originUserIds)) + .modifiedValue(Map.of("adminUserIds", request.getUserIds())) + .build() + ); + } + + private void insertAdmins(String formId, List userIds) { + List toInsert = userIds.stream() + .distinct() + .map(uid -> { + CustomFormAdmin admin = new CustomFormAdmin(); + admin.setId(IDGenerator.nextStr()); + admin.setCustomFormId(formId); + admin.setUserId(uid); + return admin; + }).toList(); + + if (CollectionUtils.isNotEmpty(toInsert)) { + customFormAdminMapper.batchInsert(toInsert); + } + } + + private void checkFormAccess(String formId, String userId) { + if (InternalUser.ADMIN.getValue().equals(userId)) { + return; + } + + CustomFormAdmin example = new CustomFormAdmin(); + example.setCustomFormId(formId); + example.setUserId(userId); + Long adminCount = customFormAdminMapper.countByExample(example); + if (adminCount > 0) { + return; + } + + LambdaQueryWrapper ruWrapper = new LambdaQueryWrapper<>(); + ruWrapper.eq(CustomFormRoleUser::getUserId, userId); + List roleUsers = customFormRoleUserMapper.selectListByLambda(ruWrapper); + if (CollectionUtils.isNotEmpty(roleUsers)) { + List roleIds = roleUsers.stream().map(CustomFormRoleUser::getRoleId).distinct().toList(); + LambdaQueryWrapper roleWrapper = new LambdaQueryWrapper<>(); + roleWrapper.eq(CustomFormRole::getCustomFormId, formId) + .in(CustomFormRole::getId, roleIds); + List roles = customFormRoleMapper.selectListByLambda(roleWrapper); + if (CollectionUtils.isNotEmpty(roles)) { + return; + } + } + + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + + private void checkFormAdmin(String formId, String userId) { + if (!isFormAdminUser(formId, userId)) { + throw new GenericException(CrmHttpResultCode.FORBIDDEN); + } + } + + public Boolean isFormAdminUser(String formId, String userId) { + if (InternalUser.ADMIN.getValue().equals(userId)) { + return true; + } + CustomFormAdmin example = new CustomFormAdmin(); + example.setCustomFormId(formId); + example.setUserId(userId); + return customFormAdminMapper.countByExample(example) > 0; + } + + Set getAdminFormIds(String userId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CustomFormAdmin::getUserId, userId); + List admins = customFormAdminMapper.selectListByLambda(wrapper); + return admins.stream().map(CustomFormAdmin::getCustomFormId).collect(Collectors.toSet()); + } + + private List getFormRoles(String userId) { + LambdaQueryWrapper ruWrapper = new LambdaQueryWrapper<>(); + ruWrapper.eq(CustomFormRoleUser::getUserId, userId); + List roleUsers = customFormRoleUserMapper.selectListByLambda(ruWrapper); + if (CollectionUtils.isNotEmpty(roleUsers)) { + List roleIds = roleUsers.stream().map(CustomFormRoleUser::getRoleId).distinct().toList(); + LambdaQueryWrapper roleWrapper = new LambdaQueryWrapper<>(); + roleWrapper.in(CustomFormRole::getId, roleIds); + List roles = customFormRoleMapper.selectListByLambda(roleWrapper); + return roles; + } + return List.of(); + } + + private void checkNameUnique(String name, String excludeFormId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CustomForm::getName, name); + List existForms = customFormMapper.selectListByLambda(wrapper); + if (CollectionUtils.isNotEmpty(existForms)) { + boolean conflict = existForms.stream() + .anyMatch(f -> excludeFormId == null || !excludeFormId.equals(f.getId())); + if (conflict) { + throw new GenericException(Translator.get("custom.form.name.duplicate")); + } + } + } + + private void checkAddExist(CustomForm customForm) { + if (extCustomFormMapper.checkAddExist(customForm)) { + throw new GenericException(Translator.get("custom.form.name.duplicate")); + } + } + + private void checkUpdateExist(CustomForm customForm) { + if (StringUtils.isNotBlank(customForm.getName()) && extCustomFormMapper.checkUpdateExist(customForm)) { + throw new GenericException(Translator.get("custom.form.name.duplicate")); + } + } + + private void createBuiltinRoles(String formId, String userId) { + long now = System.currentTimeMillis(); + List roles = Arrays.stream(CustomFormRoleKey.values()) + .map(key -> { + CustomFormRole role = new CustomFormRole(); + role.setId(IDGenerator.nextStr()); + role.setName(key.getName()); + role.setCustomFormId(formId); + role.setInternalKey(key.getKey()); + role.setCreateTime(now); + role.setUpdateTime(now); + role.setCreateUser(userId); + role.setUpdateUser(userId); + return role; + }).toList(); + customFormRoleMapper.batchInsert(roles); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/home/constants/HomeStatisticPeriod.java b/backend/crm/src/main/java/cn/cordys/crm/home/constants/HomeStatisticPeriod.java new file mode 100644 index 0000000..a6fccff --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/home/constants/HomeStatisticPeriod.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.home.constants; + +/** + * @Author: jianxing + * @CreateTime: 2025-07-09 16:53 + */ +public enum HomeStatisticPeriod { + TODAY, + THIS_WEEK, + THIS_MONTH, + THIS_YEAR +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/home/controller/HomeStatisticController.java b/backend/crm/src/main/java/cn/cordys/crm/home/controller/HomeStatisticController.java new file mode 100644 index 0000000..2c3e20d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/home/controller/HomeStatisticController.java @@ -0,0 +1,77 @@ +package cn.cordys.crm.home.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.home.dto.request.HomeStatisticBaseSearchRequest; +import cn.cordys.crm.home.dto.response.HomeClueStatistic; +import cn.cordys.crm.home.dto.response.HomeOpportunityStatistic; +import cn.cordys.crm.home.service.HomeStatisticService; +import cn.cordys.crm.opportunity.constants.OpportunityStageScenario; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * @author jianxing + * @date 2025-02-08 17:42:41 + */ +@Tag(name = "首页统计") +@RestController +@RequestMapping("/home/statistic") +public class HomeStatisticController { + + @Resource + private HomeStatisticService homeStatisticService; + + @PostMapping("/lead") + @RequiresPermissions(PermissionConstants.CLUE_MANAGEMENT_READ) + @Operation(summary = "线索统计") + public HomeClueStatistic getClueStatistic(@RequestBody @Validated HomeStatisticBaseSearchRequest request) { + DeptDataPermissionDTO deptDataPermission = homeStatisticService.getDeptDataPermissionDTO(request, PermissionConstants.CLUE_MANAGEMENT_READ); + return homeStatisticService.isEmptyDeptData(request.getSearchType(), deptDataPermission) ? + new HomeClueStatistic() : homeStatisticService.getClueStatistic(request, deptDataPermission, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/opportunity") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "商机统计") + public HomeOpportunityStatistic getOpportunityStatistic(@RequestBody @Validated HomeStatisticBaseSearchRequest request) { + DeptDataPermissionDTO deptDataPermission = homeStatisticService.getDeptDataPermissionDTO(request, PermissionConstants.OPPORTUNITY_MANAGEMENT_READ); + return homeStatisticService.isEmptyDeptData(request.getSearchType(), deptDataPermission) ? + new HomeOpportunityStatistic() : homeStatisticService.getOpportunityStatistic(request, null, deptDataPermission, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/opportunity/underway") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "进行中商机统计") + public HomeOpportunityStatistic getUnderwayOpportunityStatistic(@RequestBody @Validated HomeStatisticBaseSearchRequest request) { + DeptDataPermissionDTO deptDataPermission = homeStatisticService.getDeptDataPermissionDTO(request, PermissionConstants.OPPORTUNITY_MANAGEMENT_READ); + return homeStatisticService.isEmptyDeptData(request.getSearchType(), deptDataPermission) ? + new HomeOpportunityStatistic() : homeStatisticService.getOpportunityStatistic(request, OpportunityStageScenario.UNDERWAY, + deptDataPermission, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/opportunity/success") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "赢单统计") + public HomeOpportunityStatistic getSuccessOpportunityStatistic(@RequestBody @Validated HomeStatisticBaseSearchRequest request) { + DeptDataPermissionDTO deptDataPermission = homeStatisticService.getDeptDataPermissionDTO(request, PermissionConstants.OPPORTUNITY_MANAGEMENT_READ); + return homeStatisticService.isEmptyDeptData(request.getSearchType(), deptDataPermission) ? + new HomeOpportunityStatistic() : homeStatisticService.getOpportunityStatistic(request, OpportunityStageScenario.SUCCESS, + deptDataPermission, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @GetMapping("/department/tree") + @Operation(summary = "用户部门权限树") + public List getDepartmentTree() { + return homeStatisticService.getDepartmentTree(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/home/dto/request/HomeOppStatisticSearchWrapperRequest.java b/backend/crm/src/main/java/cn/cordys/crm/home/dto/request/HomeOppStatisticSearchWrapperRequest.java new file mode 100644 index 0000000..2d9aa28 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/home/dto/request/HomeOppStatisticSearchWrapperRequest.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.home.dto.request; + +import cn.cordys.common.dto.DeptDataPermissionDTO; +import lombok.Data; +import lombok.NoArgsConstructor; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +@NoArgsConstructor +public class HomeOppStatisticSearchWrapperRequest extends HomeStatisticSearchWrapperRequest { + + private String stageScenario; + + public HomeOppStatisticSearchWrapperRequest(HomeStatisticSearchRequest staticRequest, + DeptDataPermissionDTO dataPermission, + String orgId, String userId) { + super(staticRequest, dataPermission, orgId, userId); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/home/dto/request/HomeStatisticBaseSearchRequest.java b/backend/crm/src/main/java/cn/cordys/crm/home/dto/request/HomeStatisticBaseSearchRequest.java new file mode 100644 index 0000000..d547921 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/home/dto/request/HomeStatisticBaseSearchRequest.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.home.dto.request; + +import cn.cordys.common.constants.BusinessSearchType; +import cn.cordys.common.constants.EnumValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Set; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class HomeStatisticBaseSearchRequest { + + @EnumValue(enumClass = BusinessSearchType.class) + @Schema(description = "搜索类型(ALL/SELF/DEPARTMENT)") + private String searchType; + + @Schema(description = "部门ID集合") + private Set deptIds; + + @EnumValue(enumClass = TimeFieldEnum.class) + @Schema(description = "用于检索的时间字段(CREATE_TIME, EXPECTED_END_TIME, ACTUAL_END_TIME)") + private String timeField; + + @EnumValue(enumClass = UserFieldEnum.class) + @Schema(description = "用于检索的用户字段(CREATE_USER, OWNER)") + private String userField; + + @Schema(description = "是否显示上期数据") + private Boolean priorPeriodEnable; + + public enum TimeFieldEnum { + CREATE_TIME, + EXPECTED_END_TIME, + ACTUAL_END_TIME + } + + public enum UserFieldEnum { + CREATE_USER, + OWNER + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/home/dto/request/HomeStatisticSearchRequest.java b/backend/crm/src/main/java/cn/cordys/crm/home/dto/request/HomeStatisticSearchRequest.java new file mode 100644 index 0000000..7d62147 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/home/dto/request/HomeStatisticSearchRequest.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.home.dto.request; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.crm.home.constants.HomeStatisticPeriod; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class HomeStatisticSearchRequest extends HomeStatisticBaseSearchRequest { + + @EnumValue(enumClass = HomeStatisticPeriod.class) + @Schema(description = "时间段(TODAY/THIS_WEEK/THIS_MONTH/THIS_YEAR)") + private String period; + + @Schema(description = "开始时间") + private Long startTime; + + @Schema(description = "结束时间") + private Long endTime; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/home/dto/request/HomeStatisticSearchWrapperRequest.java b/backend/crm/src/main/java/cn/cordys/crm/home/dto/request/HomeStatisticSearchWrapperRequest.java new file mode 100644 index 0000000..8a3ebd6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/home/dto/request/HomeStatisticSearchWrapperRequest.java @@ -0,0 +1,163 @@ +package cn.cordys.crm.home.dto.request; + +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.uid.utils.EnumUtils; +import cn.cordys.crm.home.constants.HomeStatisticPeriod; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.ZoneId; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +@NoArgsConstructor +public class HomeStatisticSearchWrapperRequest { + + private HomeStatisticSearchRequest staticRequest; + private DeptDataPermissionDTO dataPermission; + private String orgId; + private String userId; + + public HomeStatisticSearchWrapperRequest(HomeStatisticSearchRequest staticRequest, + DeptDataPermissionDTO dataPermission, + String orgId, String userId) { + this.staticRequest = staticRequest; + this.dataPermission = dataPermission; + this.orgId = orgId; + this.userId = userId; + } + + public boolean comparePeriod() { + return StringUtils.isNotBlank(staticRequest.getPeriod()) && BooleanUtils.isTrue(staticRequest.getPriorPeriodEnable()); + } + + public Long getStartTime() { + String period = staticRequest.getPeriod(); + Long startTime = staticRequest.getStartTime(); + if (startTime != null) { + return startTime; + } + if (StringUtils.isNotBlank(period)) { + LocalDate now = LocalDate.now(); + HomeStatisticPeriod statisticPeriod = EnumUtils.valueOf(HomeStatisticPeriod.class, period); + startTime = switch (statisticPeriod) { + case TODAY -> now.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(); + case THIS_WEEK -> now.with(DayOfWeek.MONDAY) + .atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(); + case THIS_MONTH -> now.withDayOfMonth(1) + .atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(); + case THIS_YEAR -> now.withDayOfYear(1) + .atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(); + }; + } + return startTime; + } + + @JsonIgnore + public void setStartTime(Long startTime) { + staticRequest.setStartTime(startTime); + } + + public Long getPeriodStartTime() { + String period = staticRequest.getPeriod(); + Long startTime = staticRequest.getStartTime(); + if (StringUtils.isNotBlank(period)) { + LocalDate now = LocalDate.now(); + HomeStatisticPeriod statisticPeriod = EnumUtils.valueOf(HomeStatisticPeriod.class, period); + startTime = switch (statisticPeriod) { + case TODAY -> now.minusDays(1) + .atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(); + case THIS_WEEK -> now.minusWeeks(1) + .with(DayOfWeek.MONDAY).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(); + case THIS_MONTH -> now.minusMonths(1).withDayOfMonth(1) + .atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(); + case THIS_YEAR -> now.minusYears(1).withDayOfYear(1) + .atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(); + }; + } + return startTime; + } + + public Long getPeriodEndTime() { + String period = staticRequest.getPeriod(); + Long endTime = staticRequest.getEndTime(); + if (StringUtils.isNotBlank(period)) { + LocalDate now = LocalDate.now(); + HomeStatisticPeriod statisticPeriod = EnumUtils.valueOf(HomeStatisticPeriod.class, period); + endTime = switch (statisticPeriod) { + case TODAY -> now.minusDays(1).atTime(23, 59, 59) + .atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + case THIS_WEEK -> now.minusWeeks(1).with(DayOfWeek.SUNDAY) + .atTime(23, 59, 59) + .atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + case THIS_MONTH -> { + LocalDate startOfMonth = now.minusMonths(1).withDayOfMonth(1); + yield startOfMonth.withDayOfMonth(startOfMonth.lengthOfMonth()) + .plusDays(1) + .minusDays(1) + .atTime(23, 59, 59) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli(); + } + case THIS_YEAR -> now.minusYears(1).withDayOfYear(1) + .withDayOfYear(now.minusYears(1).lengthOfYear()) + .atTime(23, 59, 59) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli(); + }; + } + return endTime; + } + + public Long getEndTime() { + String period = staticRequest.getPeriod(); + Long endTime = staticRequest.getEndTime(); + if (endTime != null) { + return endTime; + } + if (StringUtils.isNotBlank(period)) { + LocalDate now = LocalDate.now(); + HomeStatisticPeriod statisticPeriod = EnumUtils.valueOf(HomeStatisticPeriod.class, period); + endTime = switch (statisticPeriod) { + case TODAY -> now.atTime(23, 59, 59) + .atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + case THIS_WEEK -> now.with(DayOfWeek.SUNDAY) + .atTime(23, 59, 59) + .atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + case THIS_MONTH -> { + LocalDate startOfMonth = now.withDayOfMonth(1); + yield startOfMonth.withDayOfMonth(startOfMonth.lengthOfMonth()) + .plusDays(1) + .minusDays(1) + .atTime(23, 59, 59) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli(); + } + case THIS_YEAR -> now.withDayOfYear(1) + .withDayOfYear(now.lengthOfYear()) + .atTime(23, 59, 59) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli(); + }; + } + return endTime; + } + + @JsonIgnore + public void setEndTime(Long endTime) { + staticRequest.setEndTime(endTime); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeClueStatistic.java b/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeClueStatistic.java new file mode 100644 index 0000000..67306f0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeClueStatistic.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.home.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class HomeClueStatistic { + @Schema(description = "本年度新增线索") + private HomeStatisticSearchResponse thisYearClue; + @Schema(description = "本月新增线索") + private HomeStatisticSearchResponse thisMonthClue; + @Schema(description = "本周新增线索") + private HomeStatisticSearchResponse thisWeekClue; + @Schema(description = "本日新增线索") + private HomeStatisticSearchResponse todayClue; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeCustomerStatistic.java b/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeCustomerStatistic.java new file mode 100644 index 0000000..3b9ccd8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeCustomerStatistic.java @@ -0,0 +1,28 @@ +package cn.cordys.crm.home.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class HomeCustomerStatistic { + + @Schema(description = "客户总数") + private Long total; + + @Schema(description = "新增客户") + private HomeStatisticSearchResponse newCustomer; + + @Schema(description = "未跟进客户") + private HomeStatisticSearchResponse unfollowedCustomer; + + @Schema(description = "剩余库容") + private Long remainingCapacity; + + @Schema(description = "库容未配置") + private Boolean unConfigured; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeFollowUpPlanStatistic.java b/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeFollowUpPlanStatistic.java new file mode 100644 index 0000000..6f47d62 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeFollowUpPlanStatistic.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.home.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-07-18 16:07 + */ +@Data +public class HomeFollowUpPlanStatistic { + + @Schema(description = "新增跟进计划") + private HomeStatisticSearchResponse newFollowUpPlan; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeFollowUpRecordStatistic.java b/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeFollowUpRecordStatistic.java new file mode 100644 index 0000000..775220b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeFollowUpRecordStatistic.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.home.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-07-18 16:07 + */ +@Data +public class HomeFollowUpRecordStatistic { + + @Schema(description = "新增跟进记录") + private HomeStatisticSearchResponse newFollowUpRecord; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeOpportunityStatistic.java b/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeOpportunityStatistic.java new file mode 100644 index 0000000..6751629 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeOpportunityStatistic.java @@ -0,0 +1,30 @@ +package cn.cordys.crm.home.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class HomeOpportunityStatistic { + @Schema(description = "本年度商机") + private HomeStatisticSearchResponse thisYearOpportunity; + @Schema(description = "本月商机") + private HomeStatisticSearchResponse thisMonthOpportunity; + @Schema(description = "本周商机") + private HomeStatisticSearchResponse thisWeekOpportunity; + @Schema(description = "本日商机") + private HomeStatisticSearchResponse todayOpportunity; + + @Schema(description = "本年度商机总额") + private HomeStatisticSearchResponse thisYearOpportunityAmount; + @Schema(description = "本月商机总额") + private HomeStatisticSearchResponse thisMonthOpportunityAmount; + @Schema(description = "本周商机总额") + private HomeStatisticSearchResponse thisWeekOpportunityAmount; + @Schema(description = "本日商机总额") + private HomeStatisticSearchResponse todayOpportunityAmount; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeStatisticSearchResponse.java b/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeStatisticSearchResponse.java new file mode 100644 index 0000000..f8e4530 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/home/dto/response/HomeStatisticSearchResponse.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.home.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class HomeStatisticSearchResponse { + + @Schema(description = "数值") + private Long value; + + @Schema(description = "较上期对比率") + private Double priorPeriodCompareRate; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/home/service/HomeStatisticService.java b/backend/crm/src/main/java/cn/cordys/crm/home/service/HomeStatisticService.java new file mode 100644 index 0000000..1cfd4f7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/home/service/HomeStatisticService.java @@ -0,0 +1,316 @@ +package cn.cordys.crm.home.service; + +import cn.cordys.common.constants.BusinessSearchType; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.RoleDataScope; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.RoleDataScopeDTO; +import cn.cordys.common.dto.RolePermissionDTO; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.clue.mapper.ExtClueMapper; +import cn.cordys.crm.home.constants.HomeStatisticPeriod; +import cn.cordys.crm.home.dto.request.HomeOppStatisticSearchWrapperRequest; +import cn.cordys.crm.home.dto.request.HomeStatisticBaseSearchRequest; +import cn.cordys.crm.home.dto.request.HomeStatisticSearchRequest; +import cn.cordys.crm.home.dto.request.HomeStatisticSearchWrapperRequest; +import cn.cordys.crm.home.dto.response.HomeClueStatistic; +import cn.cordys.crm.home.dto.response.HomeOpportunityStatistic; +import cn.cordys.crm.home.dto.response.HomeStatisticSearchResponse; +import cn.cordys.crm.opportunity.constants.OpportunityStageScenario; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityMapper; +import cn.cordys.crm.system.domain.OrganizationUser; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.crm.system.service.RoleService; +import cn.cordys.security.SessionUtils; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class HomeStatisticService { + + private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + @Resource + private ExtClueMapper extClueMapper; + @Resource + private ExtOpportunityMapper extOpportunityMapper; + @Resource + private DataScopeService dataScopeService; + @Resource + private PermissionCache permissionCache; + @Resource + private DepartmentService departmentService; + @Resource + private RoleService roleService; + + public HomeClueStatistic getClueStatistic(HomeStatisticBaseSearchRequest request, DeptDataPermissionDTO deptDataPermission, String orgId, String userId) { + HomeClueStatistic clueStatistic = new HomeClueStatistic(); + try { + for (HomeStatisticPeriod statisticPeriod : HomeStatisticPeriod.values()) { + HomeStatisticSearchRequest searchRequest = BeanUtils.copyBean(new HomeStatisticSearchRequest(), request); + searchRequest.setPeriod(statisticPeriod.name()); + HomeStatisticSearchWrapperRequest wrapperRequest = new HomeStatisticSearchWrapperRequest(searchRequest, deptDataPermission, orgId, userId); + + // 多线程执行 + Future getNewClueStatistic = executor.submit(() -> + getStatisticSearchResponse(wrapperRequest, this::getNewClueCount)); + + switch (statisticPeriod) { + case TODAY -> clueStatistic.setTodayClue(getNewClueStatistic.get()); + case THIS_WEEK -> clueStatistic.setThisWeekClue(getNewClueStatistic.get()); + case THIS_MONTH -> clueStatistic.setThisMonthClue(getNewClueStatistic.get()); + case THIS_YEAR -> clueStatistic.setThisYearClue(getNewClueStatistic.get()); + } + } + } catch (Exception e) { + log.error(e.getMessage(), e); + } + return clueStatistic; + } + + public HomeOpportunityStatistic getOpportunityStatistic(HomeStatisticBaseSearchRequest request, + OpportunityStageScenario opportunityStageScenario, + DeptDataPermissionDTO deptDataPermission, + String orgId, String userId) { + HomeOpportunityStatistic opportunityStatistic = new HomeOpportunityStatistic(); + for (HomeStatisticPeriod statisticPeriod : HomeStatisticPeriod.values()) { + HomeStatisticSearchRequest searchRequest = BeanUtils.copyBean(new HomeStatisticSearchRequest(), request); + searchRequest.setPeriod(statisticPeriod.name()); + HomeOppStatisticSearchWrapperRequest wrapperRequest = new HomeOppStatisticSearchWrapperRequest(searchRequest, deptDataPermission, orgId, userId); + if (opportunityStageScenario != null) { + wrapperRequest.setStageScenario(opportunityStageScenario.name()); + } + + // 多线程执行 + Future getNewOpportunityStatistic = executor.submit(() -> + getStatisticSearchResponse(wrapperRequest, this::getOpportunityCount)); + Future getNewOpportunityTotalAmount = executor.submit(() -> + getStatisticSearchResponse(wrapperRequest, this::getOpportunityAmount)); + try { + switch (statisticPeriod) { + case TODAY -> { + opportunityStatistic.setTodayOpportunity(getNewOpportunityStatistic.get()); + opportunityStatistic.setTodayOpportunityAmount(getNewOpportunityTotalAmount.get()); + } + case THIS_WEEK -> { + opportunityStatistic.setThisWeekOpportunity(getNewOpportunityStatistic.get()); + opportunityStatistic.setThisWeekOpportunityAmount(getNewOpportunityTotalAmount.get()); + } + case THIS_MONTH -> { + opportunityStatistic.setThisMonthOpportunity(getNewOpportunityStatistic.get()); + opportunityStatistic.setThisMonthOpportunityAmount(getNewOpportunityTotalAmount.get()); + } + case THIS_YEAR -> { + opportunityStatistic.setThisYearOpportunity(getNewOpportunityStatistic.get()); + opportunityStatistic.setThisYearOpportunityAmount(getNewOpportunityTotalAmount.get()); + } + } + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + return opportunityStatistic; + } + + /** + * 获取新增线索统计 + * + * @param request + * + * @return + */ + public Long getNewClueCount(HomeStatisticSearchWrapperRequest request) { + return extClueMapper.selectClueCount(request, false); + } + + private HomeStatisticSearchWrapperRequest copyHomeStatisticSearchWrapperRequest(HomeStatisticSearchWrapperRequest request) { + return JSON.parseObject(JSON.toJSONString(request), request.getClass()); + } + + /** + * 获取商机数量 + * + * @param request + * + * @return + */ + public Long getOpportunityCount(HomeStatisticSearchWrapperRequest request) { + return extOpportunityMapper.selectOpportunityCount(request, false); + } + + /** + * 获取商机总额数量 + * + * @param request + * + * @return + */ + public Long getOpportunityAmount(HomeStatisticSearchWrapperRequest request) { + return Optional.ofNullable(extOpportunityMapper.selectOpportunityCount(request, true)).orElse(0L); + } + + /** + * 获取统计数量和较上期对比率的通用方法 + * + * @param request + * @param statisticFunction + * + * @return + */ + public HomeStatisticSearchResponse getStatisticSearchResponse(HomeStatisticSearchWrapperRequest request, + Function statisticFunction) { + HomeStatisticSearchResponse response = new HomeStatisticSearchResponse(); + Future getCount = executor.submit(() -> statisticFunction.apply(request)); + try { + + Long count = getCount.get(); + response.setValue(count); + + if (request.comparePeriod()) { + HomeStatisticSearchWrapperRequest periodRequest = copyHomeStatisticSearchWrapperRequest(request); + periodRequest.setStartTime(periodRequest.getPeriodStartTime()); + periodRequest.setEndTime(periodRequest.getPeriodEndTime()); + Future getPeriodCount = executor.submit(() -> statisticFunction.apply(periodRequest)); + Long periodCount = getPeriodCount.get(); + response.setPriorPeriodCompareRate(getPriorPeriodCompareRate(count, periodCount)); + } + } catch (Exception e) { + log.error(e.getMessage(), e); + } + return response; + } + + private Double getPriorPeriodCompareRate(Long count, Long periodCount) { + return periodCount == null || periodCount == 0 ? null : (count - periodCount) * 100.0 / periodCount; + } + + /** + * 获取部门数据权限 + * + * @param request + * @param permission + * + * @return + */ + public DeptDataPermissionDTO getDeptDataPermissionDTO(HomeStatisticBaseSearchRequest request, String permission) { + if (Strings.CS.equals(request.getSearchType(), BusinessSearchType.DEPARTMENT.name())) { + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), permission); + if (deptDataPermission.getAll()) { + // 如果是全部权限,则不需要过滤部门ID + deptDataPermission.setDeptIds(request.getDeptIds()); + } else if (CollectionUtils.isNotEmpty(deptDataPermission.getDeptIds())) { + // 如果只有部门权限,则过滤掉没有权限的部门ID + Set deptIds = request.getDeptIds() + .stream() + .filter(deptId -> CollectionUtils.isNotEmpty(deptDataPermission.getDeptIds()) + && deptDataPermission.getDeptIds().contains(deptId)) + .collect(Collectors.toSet()); + deptDataPermission.setDeptIds(deptIds); + } + return deptDataPermission; + } else if (Strings.CS.equals(request.getSearchType(), BusinessSearchType.ALL.name())) { + return dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), permission); + } else { + DeptDataPermissionDTO deptDataPermission = new DeptDataPermissionDTO(); + deptDataPermission.setSelf(true); + return deptDataPermission; + } + } + + public List getDepartmentTree(String userId, String orgId) { + Map> dataScopeRoleMap = permissionCache.getRolePermissions(userId, orgId) + .stream() + .collect(Collectors.groupingBy(RolePermissionDTO::getDataScope, Collectors.toList())); + + boolean hasAllPermission = dataScopeRoleMap.get(RoleDataScope.ALL.name()) != null; + boolean hasDeptAndChildPermission = dataScopeRoleMap.get(RoleDataScope.DEPT_AND_CHILD.name()) != null; + boolean hasDeptCustomPermission = dataScopeRoleMap.get(RoleDataScope.DEPT_CUSTOM.name()) != null; + + if (hasAllPermission || Strings.CS.equals(userId, InternalUser.ADMIN.getValue())) { + // 如果有全部数据权限,则返回所有部门树 + return departmentService.getTree(orgId); + } + + if (hasDeptAndChildPermission || hasDeptCustomPermission) { + List tree = departmentService.getTree(orgId); + Set deptIds = new HashSet<>(); + if (hasDeptAndChildPermission) { + // 查询本部门数据 + OrganizationUser organizationUser = dataScopeService.getOrganizationUser(userId, orgId); + deptIds.add(organizationUser.getDepartmentId()); + } + + if (hasDeptCustomPermission) { + // 查看指定部门及其子部门数据 + List customDeptRolesIds = dataScopeRoleMap.get(RoleDataScope.DEPT_CUSTOM.name()).stream() + .map(RoleDataScopeDTO::getId) + .toList(); + List parentDeptIds = roleService.getDeptIdsByRoleIds(customDeptRolesIds); + deptIds.addAll(parentDeptIds); + } + // 查询子部门数据 + deptIds = new HashSet<>(dataScopeService.getDeptIdsWithChild(tree, deptIds)); + + return pruningTree(tree, deptIds); + } + + return List.of(); + } + + /** + * 剪枝 + * 从树中移除不在已选部门ID中的节点,同时保留已选部门ID的子节点 + * + * @param tree + * @param deptIds + * + * @return + */ + public List pruningTree(List tree, Set deptIds) { + Iterator iterator = tree.iterator(); + + List addNodes = new ArrayList<>(); + while (iterator.hasNext()) { + BaseTreeNode node = iterator.next(); + List children = node.getChildren(); + if (CollectionUtils.isNotEmpty(node.getChildren())) { + // 递归搜索子节点 + node.setChildren(pruningTree(children, deptIds)); + } + if (!deptIds.contains(node.getId())) { + // 如果当前节点不在已选部门ID中,并且没有子部门,则移除该节点 + iterator.remove(); + if (CollectionUtils.isNotEmpty(node.getChildren())) { + // 如果当前节点有子部门,则保留子部门 + addNodes.addAll(node.getChildren()); + } + } + } + tree.addAll(addNodes); + return tree; + } + + public boolean isEmptyDeptData(String searchType, DeptDataPermissionDTO deptDataPermission) { + return Strings.CS.equals(searchType, BusinessSearchType.DEPARTMENT.name()) + && CollectionUtils.isEmpty(deptDataPermission.getDeptIds()); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/constant/MaxKBApiPaths.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/constant/MaxKBApiPaths.java new file mode 100644 index 0000000..849ab47 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/constant/MaxKBApiPaths.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.integration.agent.constant; + +public interface MaxKBApiPaths { + + String WORKSPACE = "/admin/api/workspace"; + String EDITION = "/admin/api/profile"; + + String APPLICATION = "/admin/api/workspace/{0}/application"; + + String ACCESS_TOKEN = "/admin/api/workspace/{0}/application/{1}/access_token"; + + String APPLICATION_DETAIL = "/admin/api/workspace/{0}/application/{1}"; + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/controller/AgentController.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/controller/AgentController.java new file mode 100644 index 0000000..e6806f5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/controller/AgentController.java @@ -0,0 +1,173 @@ +package cn.cordys.crm.integration.agent.controller; + + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.constants.ThirdConfigTypeConstants; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.integration.agent.domain.Agent; +import cn.cordys.crm.integration.agent.dto.AgentOptionDTO; +import cn.cordys.crm.integration.agent.dto.request.*; +import cn.cordys.crm.integration.agent.dto.response.AgentDetailResponse; +import cn.cordys.crm.integration.agent.dto.response.AgentPageResponse; +import cn.cordys.crm.integration.agent.dto.response.ScriptResponse; +import cn.cordys.crm.integration.agent.service.AgentBaseService; +import cn.cordys.crm.integration.common.dto.ThirdConfigBaseDTO; +import cn.cordys.crm.system.service.IntegrationConfigService; +import cn.cordys.security.SessionUtils; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "智能体") +@RestController +@RequestMapping("/agent") +public class AgentController { + + @Resource + private AgentBaseService agentBaseService; + + @Resource + private IntegrationConfigService integrationConfigService; + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.AGENT_ADD) + @Operation(summary = "智能体-添加") + public Agent addAgent(@Validated @RequestBody AgentAddRequest request) { + return agentBaseService.addAgent(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + + @GetMapping("/detail/{id}") + @Operation(summary = "智能体-详情") + @RequiresPermissions(PermissionConstants.AGENT_READ) + public AgentDetailResponse getDetail(@PathVariable String id) { + return agentBaseService.getDetail(id); + } + + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.AGENT_UPDATE) + @Operation(summary = "智能体-更新") + public void updateAgent(@Validated @RequestBody AgentUpdateRequest request) { + agentBaseService.updateAgent(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + + @PostMapping("/rename") + @RequiresPermissions(PermissionConstants.AGENT_UPDATE) + @Operation(summary = "智能体-重命名") + public void rename(@Validated @RequestBody AgentRenameRequest request) { + agentBaseService.rename(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.AGENT_DELETE) + @Operation(summary = "智能体-删除") + public void delete(@PathVariable String id) { + agentBaseService.delete(id); + } + + + @PostMapping("/page") + @RequiresPermissions(PermissionConstants.AGENT_READ) + @Operation(summary = "智能体列表") + public Pager> list(@Validated @RequestBody AgentPageRequest request) { + return agentBaseService.getList(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/collect/{id}") + @RequiresPermissions(PermissionConstants.AGENT_READ) + @Operation(summary = "智能体收藏") + public void collect(@PathVariable String id) { + agentBaseService.collect(id, SessionUtils.getUserId()); + } + + + @GetMapping("/un-collect/{id}") + @RequiresPermissions(PermissionConstants.AGENT_READ) + @Operation(summary = "智能体取消收藏") + public void unCollect(@PathVariable String id) { + agentBaseService.unCollect(id, SessionUtils.getUserId()); + } + + + @PostMapping("/collect/page") + @RequiresPermissions(PermissionConstants.AGENT_READ) + @Operation(summary = "智能体收藏列表") + public Pager> collectPage(@Validated @RequestBody BasePageRequest request) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, agentBaseService.collectList(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId())); + } + + + @PostMapping("/edit/pos") + @Operation(summary = "智能体-拖拽排序") + @RequiresPermissions(PermissionConstants.AGENT_UPDATE) + public void editPos(@Validated @RequestBody AgentEditPosRequest request) { + agentBaseService.editPos(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping(value = "/option") + @Operation(summary = "用户可选智能体下拉option") + public List getAgentList() { + return agentBaseService.getAgentOptions(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/check") + @Operation(summary = "配置连接检测") + @RequiresPermissions(PermissionConstants.AGENT_READ) + public Boolean checkConfig() { + return agentBaseService.checkConfig(OrganizationContext.getOrganizationId()); + } + + @GetMapping("/workspace") + @Operation(summary = "获取用户工作空间") + @RequiresPermissions(PermissionConstants.AGENT_READ) + public List getWorkspace() { + return agentBaseService.workspace(OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/application/{workspaceId}") + @Operation(summary = "获取智能体应用") + @RequiresPermissions(PermissionConstants.AGENT_READ) + public List getApplication(@PathVariable String workspaceId) { + return agentBaseService.application(workspaceId, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/script") + @Operation(summary = "获取脚本信息") + @RequiresPermissions(PermissionConstants.AGENT_READ) + public ScriptResponse getScript(@Validated @RequestBody ScriptRequest request) { + return agentBaseService.script(request, OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/edition") + @Operation(summary = "获取版本") + @RequiresPermissions(PermissionConstants.AGENT_READ) + public String getEdition() { + return agentBaseService.edition(OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/application/config") + @Operation(summary = "获取智能体设置") + @RequiresPermissions(PermissionConstants.AGENT_READ) + public ThirdConfigBaseDTO getApplicationConfig() { + return integrationConfigService.getApplicationConfig(OrganizationContext.getOrganizationId(), SessionUtils.getUserId(), ThirdConfigTypeConstants.MAXKB.name()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/controller/AgentModuleController.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/controller/AgentModuleController.java new file mode 100644 index 0000000..7ee92b1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/controller/AgentModuleController.java @@ -0,0 +1,77 @@ +package cn.cordys.crm.integration.agent.controller; + + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.integration.agent.domain.AgentModule; +import cn.cordys.crm.integration.agent.dto.request.AgentModuleAddRequest; +import cn.cordys.crm.integration.agent.dto.request.AgentModuleRenameRequest; +import cn.cordys.crm.integration.agent.service.AgentModuleService; +import cn.cordys.crm.system.dto.request.NodeMoveRequest; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.NotEmpty; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@Tag(name = "智能体模块") +@RestController +@RequestMapping("/agent/module") +public class AgentModuleController { + + + @Resource + private AgentModuleService agentModuleService; + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.AGENT_ADD) + @Operation(summary = "智能体-添加文件夹") + public AgentModule addFileModule(@Validated @RequestBody AgentModuleAddRequest request) { + return agentModuleService.addFileModule(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + + @PostMapping("/rename") + @RequiresPermissions(PermissionConstants.AGENT_UPDATE) + @Operation(summary = "智能体-重命名文件夹") + public void rename(@Validated @RequestBody AgentModuleRenameRequest request) { + agentModuleService.rename(request, SessionUtils.getUserId()); + } + + @PostMapping("/delete") + @RequiresPermissions(PermissionConstants.AGENT_DELETE) + @Operation(summary = "智能体-刪除文件夹") + public void deleteAgentModule(@RequestBody @NotEmpty List ids) { + agentModuleService.delete(ids, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/tree") + @Operation(summary = "智能体-文件树查询") + @RequiresPermissions(PermissionConstants.AGENT_READ) + public List getTree() { + return agentModuleService.getTree(OrganizationContext.getOrganizationId()); + } + + @GetMapping("/count") + @Operation(summary = "智能体-文件树数量") + @RequiresPermissions(PermissionConstants.AGENT_READ) + public Map moduleCount() { + return agentModuleService.moduleCount(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/move") + @Operation(summary = "智能体-文件夹移动") + @RequiresPermissions(PermissionConstants.AGENT_UPDATE) + public void moveNode(@Validated @RequestBody NodeMoveRequest request) { + agentModuleService.moveNode(request, SessionUtils.getUserId()); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/domain/Agent.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/domain/Agent.java new file mode 100644 index 0000000..675b42a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/domain/Agent.java @@ -0,0 +1,43 @@ +package cn.cordys.crm.integration.agent.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "agent") +public class Agent extends BaseModel { + + @Schema(description = "名称") + private String name; + + @Schema(description = "模块id") + private String agentModuleId; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "同一节点下顺序") + private Long pos; + + @Schema(description = "应用范围") + private String scopeId; + + @Schema(description = "嵌入脚本") + private String script; + + @Schema(description = "描述") + private String description; + + @Schema(description = "添加方式") + private String type; + + @Schema(description = "工作空间id") + private String workspaceId; + + @Schema(description = "智能体应用id") + private String applicationId; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/domain/AgentCollection.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/domain/AgentCollection.java new file mode 100644 index 0000000..02e9c05 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/domain/AgentCollection.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.integration.agent.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "agent_collection") +public class AgentCollection extends BaseModel { + + @Schema(description = "用户id") + private String userId; + + @Schema(description = "智能体id") + private String agentId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/domain/AgentModule.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/domain/AgentModule.java new file mode 100644 index 0000000..0c7a00d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/domain/AgentModule.java @@ -0,0 +1,24 @@ +package cn.cordys.crm.integration.agent.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "agent_module") +public class AgentModule extends BaseModel { + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "名称") + private String name; + + @Schema(description = "父节点id") + private String parentId; + + @Schema(description = "同一节点下顺序") + private Long pos; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/AgentLogDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/AgentLogDTO.java new file mode 100644 index 0000000..c188d69 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/AgentLogDTO.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.integration.agent.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class AgentLogDTO { + + @Schema(description = "名称") + private String name; + + @Schema(description = "文件夹名称") + private String agentModuleName; + + @Schema(description = "可查看人员集合") + private List members; + + @Schema(description = "嵌入脚本") + private String script; + + @Schema(description = "描述") + private String description; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/AgentOptionDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/AgentOptionDTO.java new file mode 100644 index 0000000..e2320fe --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/AgentOptionDTO.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.agent.dto; + +import cn.cordys.common.dto.OptionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class AgentOptionDTO extends OptionDTO { + + @Schema(description = "脚本") + private String script; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/MaxKBConfigDetailDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/MaxKBConfigDetailDTO.java new file mode 100644 index 0000000..fcb8841 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/MaxKBConfigDetailDTO.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.integration.agent.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class MaxKBConfigDetailDTO { + + @Schema(description = "maxKB地址", requiredMode = Schema.RequiredMode.REQUIRED) + private String mkAddress; + + @Schema(description = "apiKey") + private String appSecret; + + @Schema(description = "是否验证通过") + private Boolean verify; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/ParameterDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/ParameterDTO.java new file mode 100644 index 0000000..b3e27f1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/ParameterDTO.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.integration.agent.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class ParameterDTO { + + @Schema(description = "参数") + private String parameter; + + @Schema(description = "参数值") + private String value; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentAddRequest.java new file mode 100644 index 0000000..fc49208 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentAddRequest.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.integration.agent.dto.request; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class AgentAddRequest { + + @Size(min = 1, max = 255, message = "{department.name.length_range}", groups = {Created.class, Updated.class}) + @NotBlank(message = "{dashboard.name.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "智能体名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Schema(description = "文件夹id") + @NotBlank(message = "{dashboard.parent.id.not_blank}") + private String agentModuleId; + + @NotNull + @Schema(description = "范围ID集合") + private List scopeIds; + + @NotBlank + @Schema(description = "嵌入脚本") + private String script; + + @Schema(description = "描述") + private String description; + + @Schema(description = "添加方式") + @NotBlank + private String type; + + @Schema(description = "工作空间id") + private String workspaceId; + + @Schema(description = "智能体应用id") + private String applicationId; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentEditPosRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentEditPosRequest.java new file mode 100644 index 0000000..bc6f6c7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentEditPosRequest.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.integration.agent.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class AgentEditPosRequest { + + @Schema(description = "文件夹id") + private String agentModuleId; + + @Schema(description = "移动智能体id", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{source_id.not_blank}") + private String moveId; + + @Schema(description = "目标智能体id", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{target_id.not_blank}") + private String targetId; + + @Schema(description = "移动类型", requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"BEFORE", "AFTER", "APPEND"}) + @NotBlank(message = "{moveMode.not_blank}") + private String moveMode; + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentModuleAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentModuleAddRequest.java new file mode 100644 index 0000000..84ef5f1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentModuleAddRequest.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.integration.agent.dto.request; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class AgentModuleAddRequest { + + @Size(min = 1, max = 255, message = "{agent.name.length_range}", groups = {Created.class, Updated.class}) + @NotBlank(message = "{agent_module.name.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + + @Schema(description = "父级id") + @NotBlank(message = "{agent_module.parent.id.not_blank}") + private String parentId; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentModuleRenameRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentModuleRenameRequest.java new file mode 100644 index 0000000..433370e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentModuleRenameRequest.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.integration.agent.dto.request; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class AgentModuleRenameRequest { + + @NotBlank(message = "{agentModule.id.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Size(min = 1, max = 255, message = "{department.name.length_range}", groups = {Created.class, Updated.class}) + @NotBlank(message = "{agentModule.name.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentPageRequest.java new file mode 100644 index 0000000..c3ea6cc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentPageRequest.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.integration.agent.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class AgentPageRequest extends BasePageRequest { + + @Schema(description = "文件ids") + private List agentModuleIds; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentRenameRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentRenameRequest.java new file mode 100644 index 0000000..37acd65 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentRenameRequest.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.integration.agent.dto.request; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class AgentRenameRequest { + + @NotBlank(message = "{dashboard.id.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Size(min = 1, max = 255, message = "{department.name.length_range}", groups = {Created.class, Updated.class}) + @NotBlank(message = "{dashboard.name.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @NotBlank(message = "{dashboardModule.id.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "当前文件夹id", requiredMode = Schema.RequiredMode.REQUIRED) + private String agentModuleId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentUpdateRequest.java new file mode 100644 index 0000000..7b0829d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/AgentUpdateRequest.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.integration.agent.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class AgentUpdateRequest extends AgentAddRequest { + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + private String id; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/ScriptRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/ScriptRequest.java new file mode 100644 index 0000000..fba5a5a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/request/ScriptRequest.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.integration.agent.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ScriptRequest { + + + @Schema(description = "工作空间id") + @NotBlank + private String workspaceId; + + @Schema(description = "智能体应用id") + @NotBlank + private String applicationId; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/response/AgentDetailResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/response/AgentDetailResponse.java new file mode 100644 index 0000000..242acbb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/response/AgentDetailResponse.java @@ -0,0 +1,44 @@ +package cn.cordys.crm.integration.agent.dto.response; + +import cn.cordys.crm.system.dto.ScopeNameDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class AgentDetailResponse { + + @Schema(description = "id") + private String id; + + @Schema(description = "仪表板名称") + private String name; + + @Schema(description = "文件夹id") + private String agentModuleId; + + @Schema(description = "文件夹名称") + private String agentModuleName; + + @Schema(description = "应用范围") + private String scopeId; + + @Schema(description = "应用范围集合") + private List members; + + @Schema(description = "嵌入脚本") + private String script; + + @Schema(description = "描述") + private String description; + + @Schema(description = "添加方式") + private String type; + + @Schema(description = "工作空间id") + private String workspaceId; + + @Schema(description = "智能体应用id") + private String applicationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/response/AgentPageResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/response/AgentPageResponse.java new file mode 100644 index 0000000..07267af --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/response/AgentPageResponse.java @@ -0,0 +1,62 @@ +package cn.cordys.crm.integration.agent.dto.response; + +import cn.cordys.crm.system.dto.ScopeNameDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class AgentPageResponse { + + @Schema(description = "id") + private String id; + + @Schema(description = "名称") + private String name; + + @Schema(description = "文件夹id") + private String agentModuleId; + + @Schema(description = "文件夹名称") + private String agentModuleName; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "同一节点下顺序") + private Long pos; + + @Schema(description = "范围") + private String scopeId; + + @Schema(description = "可查看人员集合") + private List members; + + @Schema(description = "嵌入脚本") + private String script; + + @Schema(description = "描述") + private String description; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "修改人名称") + private String updateUserName; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "是否收藏") + private boolean myCollect = false; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/response/ScriptResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/response/ScriptResponse.java new file mode 100644 index 0000000..7fae7e2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/dto/response/ScriptResponse.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.integration.agent.dto.response; + + +import cn.cordys.crm.integration.agent.dto.ParameterDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class ScriptResponse { + + private List parameters; + + @Schema(description = "地址值") + private String src; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentCollectionMapper.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentCollectionMapper.java new file mode 100644 index 0000000..58955c3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentCollectionMapper.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.integration.agent.mapper; + +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.crm.integration.agent.dto.response.AgentPageResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtAgentCollectionMapper { + + void deleteByAgentId(@Param("agentId") String agentId); + + List getByUserId(@Param("userId") String userId); + + int checkCollect(@Param("id") String id, @Param("userId") String userId); + + void unCollect(@Param("agentId") String agentId, @Param("userId") String userId); + + List collectList(@Param("request") BasePageRequest request, @Param("userId") String userId, @Param("orgId") String orgId); + + int countMyCollect(@Param("userId") String userId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentCollectionMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentCollectionMapper.xml new file mode 100644 index 0000000..63c0e51 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentCollectionMapper.xml @@ -0,0 +1,81 @@ + + + + + + + delete from agent_collection + where agent_id = #{agentId} + + + + + + + + + delete from agent_collection + where agent_id = #{agentId} + and user_id =#{userId} + + + + + + + + + order by + + + a.`create_time` ${sortType} + + + order by a.pos desc + + + + + order by a.pos desc + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentMapper.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentMapper.java new file mode 100644 index 0000000..1e8f8ae --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentMapper.java @@ -0,0 +1,37 @@ +package cn.cordys.crm.integration.agent.mapper; + + +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.NodeSortQueryParam; +import cn.cordys.crm.dashboard.dto.DropNode; +import cn.cordys.crm.integration.agent.dto.AgentOptionDTO; +import cn.cordys.crm.integration.agent.dto.request.AgentPageRequest; +import cn.cordys.crm.integration.agent.dto.response.AgentDetailResponse; +import cn.cordys.crm.integration.agent.dto.response.AgentPageResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtAgentMapper { + + + int countByName(@Param("name") String name, @Param("agentModuleId") String agentModuleId, @Param("orgId") String orgId, @Param("id") String id); + + Long getNextPosByOrgId(@Param("orgId") String orgId); + + AgentDetailResponse getDetail(@Param("id") String id); + + List list(@Param("request") AgentPageRequest request, @Param("userId") String userId, @Param("orgId") String orgId, @Param("departmentIds") List departmentIds); + + DropNode selectDragInfoById(@Param("dragNodeId") String dragNodeId); + + DropNode selectNodeByPosOperator(@Param("nodeSortQueryParam") NodeSortQueryParam nodeSortQueryParam); + + void updatePos(@Param("id") String id, @Param("pos") long pos); + + List selectIdByOrgIdOrderByPos(@Param("orgId") String orgId); + + List selectAgentNode(@Param("departmentIds") List departmentIds, @Param("orgId") String orgId, @Param("userId") String userId); + + List getOptions(@Param("userId") String userId, @Param("orgId") String orgId, @Param("departmentIds") List departmentIds); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentMapper.xml new file mode 100644 index 0000000..d2a8cac --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentMapper.xml @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + order by + + + a.`create_time` ${sortType} + + + a.`name` ${sortType} + + + a.`agent_module_id` ${sortType} + + + a.`create_user` ${sortType} + + + order by a.pos desc + + + + + order by a.pos desc + + + + + + + + + + + + update agent + set pos =#{pos} + where id = #{id} + + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentModuleMapper.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentModuleMapper.java new file mode 100644 index 0000000..45517d2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentModuleMapper.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.integration.agent.mapper; + +import cn.cordys.common.dto.BaseTree; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.NodeSortQueryParam; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtAgentModuleMapper { + + int countByName(@Param("name") String name, @Param("parentId") String parentId, @Param("orgId") String orgId, @Param("id") String id); + + Long getNextPosByOrgId(@Param("orgId") String orgId); + + int countAgentByIds(@Param("agentModuleIds") List agentModuleIds, @Param("orgId") String orgId); + + List selectTreeNode(@Param("orgId") String orgId); + + BaseTree selectBaseModuleById(@Param("dragNodeId") String dragNodeId); + + BaseTree selectModuleByParentIdAndPosOperator(@Param("nodeSortQueryParam") NodeSortQueryParam nodeSortQueryParam); + + List selectChildrenIds(@Param("parentId") String parentId); + + void updatePos(@Param("id") String id, @Param("pos") long pos); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentModuleMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentModuleMapper.xml new file mode 100644 index 0000000..f127ebd --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/mapper/ExtAgentModuleMapper.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + update agent_module + set pos =#{pos} + where id = #{id} + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/ApiInputFieldList.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/ApiInputFieldList.java new file mode 100644 index 0000000..3729abf --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/ApiInputFieldList.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.integration.agent.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class ApiInputFieldList { + + @JsonProperty("default_value") + private String defaultValue; + + @JsonProperty("variable") + private String variable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/ApplicationDetail.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/ApplicationDetail.java new file mode 100644 index 0000000..9ce4761 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/ApplicationDetail.java @@ -0,0 +1,11 @@ +package cn.cordys.crm.integration.agent.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class ApplicationDetail { + + @JsonProperty("work_flow") + private WorkFlow workflow; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/MaxKBApplicationResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/MaxKBApplicationResponse.java new file mode 100644 index 0000000..42432dc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/MaxKBApplicationResponse.java @@ -0,0 +1,11 @@ +package cn.cordys.crm.integration.agent.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class MaxKBApplicationResponse extends MaxKBResponseEntity { + + @JsonProperty("data") + ApplicationDetail data; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/MaxKBDataResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/MaxKBDataResponse.java new file mode 100644 index 0000000..ea768b7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/MaxKBDataResponse.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.integration.agent.response; + +import cn.cordys.common.dto.OptionDTO; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +public class MaxKBDataResponse extends MaxKBResponseEntity { + + @JsonProperty("data") + List data; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/MaxKBResponseEntity.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/MaxKBResponseEntity.java new file mode 100644 index 0000000..6bdec8e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/MaxKBResponseEntity.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.integration.agent.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class MaxKBResponseEntity { + + /** + * 错误码 + */ + @JsonProperty("code") + private int code; + + /** + * 错误消息 + */ + @JsonProperty("message") + private String message; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/MaxKBTokenResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/MaxKBTokenResponse.java new file mode 100644 index 0000000..6c0645a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/MaxKBTokenResponse.java @@ -0,0 +1,11 @@ +package cn.cordys.crm.integration.agent.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class MaxKBTokenResponse extends MaxKBResponseEntity { + + @JsonProperty("data") + TokenResponse data; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/Nodes.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/Nodes.java new file mode 100644 index 0000000..544215a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/Nodes.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.integration.agent.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class Nodes { + + @JsonProperty("id") + private String id; + + @JsonProperty("type") + private String type; + + @JsonProperty("properties") + private Properties properties; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/Properties.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/Properties.java new file mode 100644 index 0000000..6a957a6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/Properties.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.integration.agent.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +public class Properties { + + @JsonProperty("api_input_field_list") + private List apiInputFieldList; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/TokenResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/TokenResponse.java new file mode 100644 index 0000000..58912e7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/TokenResponse.java @@ -0,0 +1,11 @@ +package cn.cordys.crm.integration.agent.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class TokenResponse { + + @JsonProperty("access_token") + private String accessToken; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/WorkFlow.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/WorkFlow.java new file mode 100644 index 0000000..5dfdf86 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/response/WorkFlow.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.integration.agent.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +public class WorkFlow { + + @JsonProperty("nodes") + private List nodes; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/service/AgentBaseService.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/service/AgentBaseService.java new file mode 100644 index 0000000..36ae1b8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/service/AgentBaseService.java @@ -0,0 +1,669 @@ +package cn.cordys.crm.integration.agent.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.ThirdConfigTypeConstants; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.NodeSortUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.dashboard.dto.MoveNodeSortDTO; +import cn.cordys.crm.dashboard.service.DashboardSortService; +import cn.cordys.crm.integration.agent.constant.MaxKBApiPaths; +import cn.cordys.crm.integration.agent.domain.Agent; +import cn.cordys.crm.integration.agent.domain.AgentCollection; +import cn.cordys.crm.integration.agent.dto.AgentLogDTO; +import cn.cordys.crm.integration.agent.dto.AgentOptionDTO; +import cn.cordys.crm.integration.agent.dto.ParameterDTO; +import cn.cordys.crm.integration.agent.dto.request.*; +import cn.cordys.crm.integration.agent.dto.response.AgentDetailResponse; +import cn.cordys.crm.integration.agent.dto.response.AgentPageResponse; +import cn.cordys.crm.integration.agent.dto.response.ScriptResponse; +import cn.cordys.crm.integration.agent.mapper.ExtAgentCollectionMapper; +import cn.cordys.crm.integration.agent.mapper.ExtAgentMapper; +import cn.cordys.crm.integration.agent.response.*; +import cn.cordys.crm.integration.common.client.QrCodeClient; +import cn.cordys.crm.integration.common.dto.ThirdConfigBaseDTO; +import cn.cordys.crm.integration.common.request.MaxKBThirdConfigRequest; +import cn.cordys.crm.integration.common.utils.HttpClientUtils; +import cn.cordys.crm.system.constants.OrganizationConfigConstants; +import cn.cordys.crm.system.domain.OrganizationConfig; +import cn.cordys.crm.system.domain.OrganizationConfigDetail; +import cn.cordys.crm.system.dto.ScopeNameDTO; +import cn.cordys.crm.system.dto.request.NodeMoveRequest; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigDetailMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationUserMapper; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.crm.system.service.UserExtendService; +import cn.cordys.mybatis.BaseMapper; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.ibatis.session.ExecutorType; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.mybatis.spring.SqlSessionUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class AgentBaseService extends DashboardSortService { + + @Resource + private ExtAgentMapper extAgentMapper; + @Resource + private AgentModuleService agentModuleService; + @Resource + private BaseMapper agentMapper; + @Resource + private UserExtendService userExtendService; + @Resource + private ExtAgentCollectionMapper extAgentCollectionMapper; + @Resource + private ExtOrganizationUserMapper extOrganizationUserMapper; + @Resource + private DepartmentService departmentService; + @Resource + private ExtUserMapper extUserMapper; + @Resource + private BaseMapper agentCollectionMapper; + @Resource + private SqlSessionFactory sqlSessionFactory; + @Resource + private ExtOrganizationConfigMapper extOrganizationConfigMapper; + @Resource + private ExtOrganizationConfigDetailMapper extOrganizationConfigDetailMapper; + @Resource + private QrCodeClient qrCodeClient; + + /** + * 添加智能体 + * + * @param request + * @param orgId + * @param userId + * @return + */ + @OperationLog(module = LogModule.AGENT, type = LogType.ADD) + public Agent addAgent(AgentAddRequest request, String orgId, String userId) { + checkAgentName(request.getName(), request.getAgentModuleId(), orgId, null); + agentModuleService.checkAgentModule(request.getAgentModuleId()); + String id = IDGenerator.nextStr(); + Agent agent = new Agent(); + agent.setId(id); + agent.setName(request.getName()); + agent.setAgentModuleId(request.getAgentModuleId()); + agent.setOrganizationId(orgId); + agent.setPos(getNextPos(orgId)); + agent.setScopeId(JSON.toJSONString(request.getScopeIds())); + agent.setScript(request.getScript()); + agent.setDescription(request.getDescription()); + agent.setCreateTime(System.currentTimeMillis()); + agent.setCreateUser(userId); + agent.setUpdateTime(System.currentTimeMillis()); + agent.setUpdateUser(userId); + agent.setType(request.getType()); + agent.setWorkspaceId(request.getWorkspaceId()); + agent.setApplicationId(request.getApplicationId()); + agentMapper.insert(agent); + + //日志 + OperationLogContext.setContext(LogContextInfo.builder() + .modifiedValue(agent) + .resourceId(id) + .resourceName(agent.getName()) + .build()); + + return agent; + } + + + /** + * 同一文件下智能体名称唯一 + * + * @param name + * @param agentModuleId + * @param orgId + * @param id + */ + private void checkAgentName(String name, String agentModuleId, String orgId, String id) { + if (extAgentMapper.countByName(name, agentModuleId, orgId, id) > 0) { + throw new GenericException(Translator.get("agent_name_exist")); + } + } + + + private Long getNextPos(String orgId) { + Long pos = extAgentMapper.getNextPosByOrgId(orgId); + return (pos == null ? 0 : pos) + NodeSortUtils.DEFAULT_NODE_INTERVAL_POS; + } + + + /** + * 智能体详情 + * + * @param id + * @return + */ + public AgentDetailResponse getDetail(String id) { + AgentDetailResponse response = extAgentMapper.getDetail(id); + if (response != null && !StringUtils.isBlank(response.getScopeId())) { + response.setMembers(userExtendService.getScope(JSON.parseArray(response.getScopeId(), String.class))); + } + return response; + } + + + /** + * 更新智能体 + * + * @param request + * @param orgId + * @param userId + */ + @OperationLog(module = LogModule.AGENT, type = LogType.UPDATE) + public void updateAgent(AgentUpdateRequest request, String orgId, String userId) { + checkAgentName(request.getName(), request.getAgentModuleId(), orgId, request.getId()); + Agent originalAgent = checkAgent(request.getId()); + AgentLogDTO originalDetail = getLogDetail(originalAgent.getId()); + + Agent agent = new Agent(); + BeanUtils.copyBean(agent, request); + agent.setScopeId(JSON.toJSONString(request.getScopeIds())); + agent.setUpdateTime(System.currentTimeMillis()); + agent.setUpdateUser(userId); + agentMapper.update(agent); + + AgentLogDTO newDetail = getLogDetail(originalAgent.getId()); + + // 添加日志上下文 + String resourceName = Optional.ofNullable(agent.getName()).orElse(originalAgent.getName()); + OperationLogContext.setContext( + LogContextInfo.builder() + .originalValue(originalDetail) + .modifiedValue(newDetail) + .resourceId(request.getId()) + .resourceName(resourceName) + .build() + ); + } + + + private AgentLogDTO getLogDetail(String id) { + AgentDetailResponse detail = getDetail(id); + AgentLogDTO detailDTO = new AgentLogDTO(); + BeanUtils.copyBean(detailDTO, detail); + List names = detail.getMembers().stream().map(ScopeNameDTO::getName).toList(); + detailDTO.setMembers(names); + return detailDTO; + } + + private Agent checkAgent(String id) { + Agent agent = agentMapper.selectByPrimaryKey(id); + if (agent == null) { + throw new GenericException(Translator.get("agent_blank")); + } + return agent; + } + + + /** + * 重命名智能体 + * + * @param request + * @param userId + * @param orgId + */ + @OperationLog(module = LogModule.AGENT, type = LogType.UPDATE) + public void rename(AgentRenameRequest request, String userId, String orgId) { + Agent originalAgent = checkAgent(request.getId()); + checkAgentName(request.getName(), request.getAgentModuleId(), orgId, request.getId()); + Agent agent = BeanUtils.copyBean(new Agent(), request); + agent.setUpdateTime(System.currentTimeMillis()); + agent.setName(request.getName()); + agent.setUpdateUser(userId); + agentMapper.update(agent); + + // 添加日志上下文 + String resourceName = Optional.ofNullable(agent.getName()).orElse(originalAgent.getName()); + OperationLogContext.setContext( + LogContextInfo.builder() + .originalValue(originalAgent) + .modifiedValue(checkAgent(request.getId())) + .resourceId(request.getId()) + .resourceName(resourceName) + .build() + ); + } + + + /** + * 删除智能体 + * + * @param id + */ + @OperationLog(module = LogModule.AGENT, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + Agent agent = checkAgent(id); + agentMapper.deleteByPrimaryKey(id); + extAgentCollectionMapper.deleteByAgentId(id); + + // 设置操作对象 + OperationLogContext.setResourceName(agent.getName()); + } + + + /** + * 智能体列表 + * + * @param request + * @param userId + * @param orgId + * @return + */ + public Pager> getList(AgentPageRequest request, String userId, String orgId) { + List departmentIds = new ArrayList<>(); + if (!Strings.CI.equals(userId, InternalUser.ADMIN.getValue())) { + String departmentId = extOrganizationUserMapper.getDepartmentByUserId(userId); + List departmentTree = departmentService.getTree(orgId); + departmentIds = agentModuleService.getParentIds(departmentTree, departmentId); + } + + + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List agentList = extAgentMapper.list(request, userId, orgId, departmentIds); + handleData(agentList, userId); + return PageUtils.setPageInfo(page, agentList); + } + + + /** + * 数据处理 + * + * @param agentList + * @param userId + */ + private void handleData(List agentList, String userId) { + if (CollectionUtils.isNotEmpty(agentList)) { + //创建人 更新人 + List ids = new ArrayList<>(); + ids.addAll(agentList.stream().map(AgentPageResponse::getCreateUser).toList()); + ids.addAll(agentList.stream().map(AgentPageResponse::getUpdateUser).toList()); + List options = extUserMapper.selectUserOptionByIds(ids); + Map userMap = options + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + + Set myCollects = new HashSet<>(extAgentCollectionMapper.getByUserId(userId)); + + agentList.forEach(agent -> { + agent.setMembers(userExtendService.getScope(JSON.parseArray(agent.getScopeId(), String.class))); + if (userMap.containsKey(agent.getCreateUser())) { + agent.setCreateUserName(userMap.get(agent.getCreateUser())); + } + if (userMap.containsKey(agent.getUpdateUser())) { + agent.setUpdateUserName(userMap.get(agent.getUpdateUser())); + } + if (myCollects.contains(agent.getId())) { + agent.setMyCollect(true); + } + }); + } + + } + + + /** + * 收藏 + * + * @param id + * @param userId + */ + public void collect(String id, String userId) { + checkCollect(id, userId); + AgentCollection agentCollection = new AgentCollection(); + agentCollection.setId(IDGenerator.nextStr()); + agentCollection.setAgentId(id); + agentCollection.setUserId(userId); + agentCollection.setCreateTime(System.currentTimeMillis()); + agentCollection.setUpdateTime(System.currentTimeMillis()); + agentCollection.setCreateUser(userId); + agentCollection.setUpdateUser(userId); + agentCollectionMapper.insert(agentCollection); + } + + private void checkCollect(String id, String userId) { + if (extAgentCollectionMapper.checkCollect(id, userId) > 0) { + throw new GenericException(Translator.get("agent_collect_exist")); + } + } + + + /** + * 取消收藏 + * + * @param agentId + * @param userId + */ + public void unCollect(String agentId, String userId) { + extAgentCollectionMapper.unCollect(agentId, userId); + } + + + /** + * 收藏列表 + * + * @param request + * @param userId + * @param orgId + * @return + */ + public List collectList(BasePageRequest request, String userId, String orgId) { + List agentList = extAgentCollectionMapper.collectList(request, userId, orgId); + handleData(agentList, userId); + return agentList; + } + + + /** + * 拖拽排序 + * + * @param request + * @param userId + * @param orgId + */ + public void editPos(AgentEditPosRequest request, String userId, String orgId) { + Agent agent = checkAgent(request.getMoveId()); + if (StringUtils.isNotBlank(request.getAgentModuleId()) && !Strings.CS.equals(request.getAgentModuleId(), agent.getAgentModuleId())) { + agentModuleService.checkAgentModule(request.getAgentModuleId()); + checkAgentName(agent.getName(), request.getAgentModuleId(), orgId, agent.getId()); + + agent.setAgentModuleId(request.getAgentModuleId()); + agent.setUpdateUser(userId); + agent.setUpdateTime(System.currentTimeMillis()); + agentMapper.update(agent); + } + if (Strings.CS.equals(request.getTargetId(), request.getMoveId())) { + return; + } + moveNode(request, orgId); + } + + + public void moveNode(AgentEditPosRequest posRequest, String orgId) { + NodeMoveRequest request = super.getNodeMoveRequest(posRequest.getMoveId(), posRequest.getTargetId(), posRequest.getMoveMode(), true); + MoveNodeSortDTO sortDTO = super.getNodeSortDTO( + orgId, + request, + extAgentMapper::selectDragInfoById, + extAgentMapper::selectNodeByPosOperator + ); + super.sort(sortDTO); + } + + + @Override + public void updatePos(String id, long pos) { + extAgentMapper.updatePos(id, pos); + } + + @Override + public void refreshPos(String orgId) { + List posIds = extAgentMapper.selectIdByOrgIdOrderByPos(orgId); + SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); + ExtAgentMapper batchUpdateMapper = sqlSession.getMapper(ExtAgentMapper.class); + for (int i = 0; i < posIds.size(); i++) { + batchUpdateMapper.updatePos(posIds.get(i), i * NodeSortUtils.DEFAULT_NODE_INTERVAL_POS); + } + sqlSession.flushStatements(); + SqlSessionUtils.closeSqlSession(sqlSession, sqlSessionFactory); + } + + + /** + * 获取用户可选智能体选项 + * + * @param userId + * @param orgId + * @return + */ + public List getAgentOptions(String userId, String orgId) { + List departmentIds = new ArrayList<>(); + if (!Strings.CI.equals(userId, InternalUser.ADMIN.getValue())) { + String departmentId = extOrganizationUserMapper.getDepartmentByUserId(userId); + List departmentTree = departmentService.getTree(orgId); + departmentIds = agentModuleService.getParentIds(departmentTree, departmentId); + } + return extAgentMapper.getOptions(userId, orgId, departmentIds); + } + + + /** + * 检测配置连接 + * + * @param orgId + * @return + */ + public Boolean checkConfig(String orgId) { + var configurationDTO = getConfig(orgId); + if (configurationDTO == null) { + return false; + } + return BooleanUtils.isTrue(configurationDTO.getVerify()); + } + + /** + * 获取配置 + * + * @param orgId + * @return + */ + private ThirdConfigBaseDTO getConfig(String orgId) { + OrganizationConfig organizationConfig = extOrganizationConfigMapper.getOrganizationConfig( + orgId, OrganizationConfigConstants.ConfigType.THIRD.name() + ); + if (organizationConfig == null) { + return null; + } + List details = extOrganizationConfigDetailMapper + .getOrgConfigDetailByType(organizationConfig.getId(), null, List.of(ThirdConfigTypeConstants.MAXKB.name())); + + if (CollectionUtils.isEmpty(details)) { + return null; + } + + + var thirdConfigBaseDTO = JSON.parseObject(new String(details.getFirst().getContent()), ThirdConfigBaseDTO.class); + MaxKBThirdConfigRequest maxKBThirdConfigRequest; + if (thirdConfigBaseDTO.getConfig() == null) { + maxKBThirdConfigRequest = JSON.parseObject(new String(details.getFirst().getContent()), MaxKBThirdConfigRequest.class); + } else { + maxKBThirdConfigRequest = JSON.MAPPER.convertValue(thirdConfigBaseDTO.getConfig(), MaxKBThirdConfigRequest.class); + } + + thirdConfigBaseDTO.setConfig(maxKBThirdConfigRequest); + return thirdConfigBaseDTO; + } + + /** + * 获取工作空间 + * + * @param orgId + * @return + */ + public List workspace(String orgId) { + ThirdConfigBaseDTO config = getConfig(orgId); + if (config == null) { + return Collections.emptyList(); + } + return getWorkspace(config); + } + + private List getWorkspace(ThirdConfigBaseDTO baseConfig) { + MaxKBThirdConfigRequest config = JSON.MAPPER.convertValue(baseConfig.getConfig(), MaxKBThirdConfigRequest.class); + String body = qrCodeClient.exchange( + config.getMkAddress().concat(MaxKBApiPaths.WORKSPACE), + "Bearer " + config.getAppSecret(), + HttpHeaders.AUTHORIZATION, + MediaType.APPLICATION_JSON, + MediaType.APPLICATION_JSON + ); + MaxKBDataResponse entity = JSON.parseObject(body, MaxKBDataResponse.class); + if (entity.getCode() != 200) { + throw new GenericException("获取工作空间失败,错误信息:" + entity.getMessage()); + } + return entity.getData(); + } + + + /** + * 获取智能体应用列表 + * + * @param workspaceId + * @param orgId + * @return + */ + public List application(String workspaceId, String orgId) { + ThirdConfigBaseDTO config = getConfig(orgId); + if (config == null) { + return Collections.emptyList(); + } + return getApplication(workspaceId, config); + } + + private List getApplication(String workspaceId, ThirdConfigBaseDTO baseConfig) { + MaxKBThirdConfigRequest config = JSON.MAPPER.convertValue(baseConfig.getConfig(), MaxKBThirdConfigRequest.class); + String body = qrCodeClient.exchange( + HttpClientUtils.urlTransfer(config.getMkAddress().concat(MaxKBApiPaths.APPLICATION), workspaceId), + "Bearer " + config.getAppSecret(), + HttpHeaders.AUTHORIZATION, + MediaType.APPLICATION_JSON, + MediaType.APPLICATION_JSON + ); + MaxKBDataResponse entity = JSON.parseObject(body, MaxKBDataResponse.class); + if (entity.getCode() != 200) { + throw new GenericException("获取智能体应用失败,错误信息:" + entity.getMessage()); + } + return entity.getData(); + } + + + /** + * 获取脚本信息 + * + * @param request + * @param orgId + * @return + */ + public ScriptResponse script(ScriptRequest request, String orgId) { + ThirdConfigBaseDTO config = getConfig(orgId); + if (config == null) { + return new ScriptResponse(); + } + return getScript(request, config); + } + + private ScriptResponse getScript(ScriptRequest request, ThirdConfigBaseDTO baseConfig) { + MaxKBThirdConfigRequest config = JSON.MAPPER.convertValue(baseConfig.getConfig(), MaxKBThirdConfigRequest.class); + ScriptResponse response = new ScriptResponse(); + List parameters = new ArrayList<>(); + String accessToken = qrCodeClient.exchange( + HttpClientUtils.urlTransfer(config.getMkAddress().concat(MaxKBApiPaths.ACCESS_TOKEN), request.getWorkspaceId(), request.getApplicationId()), + "Bearer " + config.getAppSecret(), + HttpHeaders.AUTHORIZATION, + MediaType.APPLICATION_JSON, + MediaType.APPLICATION_JSON + ); + MaxKBTokenResponse maxKBResponse = JSON.parseObject(accessToken, MaxKBTokenResponse.class); + if (maxKBResponse.getCode() != 200) { + throw new GenericException("获取accessToken失败,错误信息:" + maxKBResponse.getMessage()); + } + response.setSrc(config.getMkAddress().concat("/chat/").concat(maxKBResponse.getData().getAccessToken())); + + String application = qrCodeClient.exchange( + HttpClientUtils.urlTransfer(config.getMkAddress().concat(MaxKBApiPaths.APPLICATION_DETAIL), request.getWorkspaceId(), request.getApplicationId()), + "Bearer " + config.getAppSecret(), + HttpHeaders.AUTHORIZATION, + MediaType.APPLICATION_JSON, + MediaType.APPLICATION_JSON + ); + MaxKBApplicationResponse applicationResponse = JSON.parseObject(application, MaxKBApplicationResponse.class); + if (applicationResponse.getCode() != 200) { + throw new GenericException("获取应用信息失败,错误信息:" + applicationResponse.getMessage()); + } + + if (CollectionUtils.isNotEmpty(applicationResponse.getData().getWorkflow().getNodes())) { + List nodes = applicationResponse.getData().getWorkflow().getNodes(); + Nodes baseNode = nodes.stream().filter(node -> Strings.CI.equals(node.getId(), "base-node")).toList().getFirst(); + if (baseNode != null) { + List apiInputFieldList = baseNode.getProperties().getApiInputFieldList(); + if (CollectionUtils.isNotEmpty(apiInputFieldList)) { + apiInputFieldList.forEach(field -> { + ParameterDTO parameterDTO = new ParameterDTO(); + parameterDTO.setParameter(field.getVariable()); + parameterDTO.setValue(field.getDefaultValue()); + parameters.add(parameterDTO); + }); + } + } + } + response.setParameters(parameters); + return response; + } + + + /** + * 获取版本 + * + * @param orgId + * @return + */ + public String edition(String orgId) { + ThirdConfigBaseDTO config = getConfig(orgId); + if (config == null) { + throw new GenericException(Translator.get("third.config.not.exist")); + } + return getEdition(config); + } + + private String getEdition(ThirdConfigBaseDTO baseConfig) { + MaxKBThirdConfigRequest config = JSON.MAPPER.convertValue(baseConfig.getConfig(), MaxKBThirdConfigRequest.class); + String body = qrCodeClient.exchange( + config.getMkAddress().concat(MaxKBApiPaths.EDITION), + "Bearer " + config.getAppSecret(), + HttpHeaders.AUTHORIZATION, + MediaType.APPLICATION_JSON, + MediaType.APPLICATION_JSON + ); + var map = JSON.parseObject(body, Map.class); + if ((Integer) map.get("code") != 200) { + throw new GenericException("获取版本异常,错误信息:" + map.get("message")); + } + + var dataMap = (Map) map.get("data"); + return (String) dataMap.get("edition"); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/agent/service/AgentModuleService.java b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/service/AgentModuleService.java new file mode 100644 index 0000000..0853cb4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/agent/service/AgentModuleService.java @@ -0,0 +1,351 @@ +package cn.cordys.crm.integration.agent.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.NodeSortDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.NodeSortUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.integration.agent.domain.AgentModule; +import cn.cordys.crm.integration.agent.dto.request.AgentModuleAddRequest; +import cn.cordys.crm.integration.agent.dto.request.AgentModuleRenameRequest; +import cn.cordys.crm.integration.agent.mapper.ExtAgentCollectionMapper; +import cn.cordys.crm.integration.agent.mapper.ExtAgentMapper; +import cn.cordys.crm.integration.agent.mapper.ExtAgentModuleMapper; +import cn.cordys.crm.system.dto.request.NodeMoveRequest; +import cn.cordys.crm.system.mapper.ExtOrganizationUserMapper; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.MoveNodeService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.ibatis.session.ExecutorType; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.mybatis.spring.SqlSessionUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class AgentModuleService extends MoveNodeService { + + @Resource + private BaseMapper agentModuleMapper; + @Resource + private ExtAgentModuleMapper extAgentModuleMapper; + @Resource + private LogService logService; + @Resource + private ExtOrganizationUserMapper extOrganizationUserMapper; + @Resource + private DepartmentService departmentService; + @Resource + private ExtAgentMapper extAgentMapper; + @Resource + private ExtAgentCollectionMapper extAgentCollectionMapper; + @Resource + private SqlSessionFactory sqlSessionFactory; + + private static void buildIdNodeMap(List nodeList, Map idNodeMap) { + for (BaseTreeNode node : nodeList) { + idNodeMap.put(node.getId(), node); + if (CollectionUtils.isNotEmpty(node.getChildren())) { + buildIdNodeMap(node.getChildren(), idNodeMap); + } + } + } + + public AgentModule checkAgentModule(String id) { + AgentModule agentModule = agentModuleMapper.selectByPrimaryKey(id); + if (agentModule == null) { + throw new GenericException(Translator.get("agent_module_blank")); + } + return agentModule; + } + + public List getParentIds(List departmentTree, String departmentId) { + List ids = new ArrayList<>(); + if (CollectionUtils.isEmpty(departmentTree) || StringUtils.isBlank(departmentId)) { + return ids; + } + + Map idNodeMap = new HashMap<>(); + buildIdNodeMap(departmentTree, idNodeMap); + + BaseTreeNode currentNode = idNodeMap.get(departmentId); + while (currentNode != null && currentNode.getParentId() != null) { + ids.add(currentNode.getId()); + if ("NONE".equals(currentNode.getParentId())) { + break; + } + currentNode = idNodeMap.get(currentNode.getParentId()); + } + + return ids; + } + + /** + * 添加文件夹 + * + * @param request + * @param orgId + * @param userId + * + * @return + */ + @OperationLog(module = LogModule.AGENT, type = LogType.ADD) + public AgentModule addFileModule(AgentModuleAddRequest request, String orgId, String userId) { + //同一层级文件名称唯一 + checkFileName(request.getName(), request.getParentId(), orgId, null); + String id = IDGenerator.nextStr(); + AgentModule agentModule = new AgentModule(); + agentModule.setId(id); + agentModule.setName(request.getName()); + agentModule.setParentId(request.getParentId()); + agentModule.setOrganizationId(orgId); + agentModule.setPos(getNextPos(orgId)); + agentModule.setCreateTime(System.currentTimeMillis()); + agentModule.setUpdateTime(System.currentTimeMillis()); + agentModule.setCreateUser(userId); + agentModule.setUpdateUser(userId); + agentModuleMapper.insert(agentModule); + + //日志 + OperationLogContext.setContext(LogContextInfo.builder() + .modifiedValue(agentModule) + .resourceId(id) + .resourceName(agentModule.getName()) + .build()); + return agentModule; + } + + + public Long getNextPos(String orgId) { + Long pos = extAgentModuleMapper.getNextPosByOrgId(orgId); + return (pos == null ? 0 : pos) + NodeSortUtils.DEFAULT_NODE_INTERVAL_POS; + } + + /** + * 文件名重复校验 + * + * @param name + * @param parentId + * @param orgId + * @param id + */ + private void checkFileName(String name, String parentId, String orgId, String id) { + if (extAgentModuleMapper.countByName(name, parentId, orgId, id) > 0) { + throw new GenericException(Translator.get("dashboard_module_name_exist")); + } + } + + + /** + * 重命名 + * + * @param request + * @param userId + */ + @OperationLog(module = LogModule.AGENT, type = LogType.UPDATE) + public void rename(AgentModuleRenameRequest request, String userId) { + AgentModule originalAgentModule = checkAgentModule(request.getId()); + checkFileName(request.getName(), originalAgentModule.getParentId(), originalAgentModule.getOrganizationId(), originalAgentModule.getId()); + + AgentModule agentModule = BeanUtils.copyBean(new AgentModule(), request); + agentModule.setUpdateTime(System.currentTimeMillis()); + agentModule.setName(request.getName()); + agentModule.setUpdateUser(userId); + agentModuleMapper.update(agentModule); + + // 添加日志上下文 + String resourceName = Optional.ofNullable(agentModule.getName()).orElse(originalAgentModule.getName()); + OperationLogContext.setContext( + LogContextInfo.builder() + .originalValue(originalAgentModule) + .modifiedValue(checkAgentModule(request.getId())) + .resourceId(request.getId()) + .resourceName(resourceName) + .build() + ); + } + + + /** + * 删除文件夹 + * + * @param ids + * @param userId + * @param orgId + */ + public void delete(List ids, String userId, String orgId) { + deleteCheck(ids, orgId); + List agentModules = agentModuleMapper.selectByIds(ids); + //刪除文件夹 + agentModuleMapper.deleteByIds(ids); + List logs = new ArrayList<>(); + // 添加日志上下文 + agentModules.forEach(agentModule -> { + LogDTO logDTO = new LogDTO(agentModule.getOrganizationId(), agentModule.getId(), userId, LogType.DELETE, LogModule.AGENT, agentModule.getName()); + logDTO.setOriginalValue(agentModule); + logs.add(logDTO); + }); + logService.batchAdd(logs); + } + + + /** + * 删除校验 + * + * @param ids + * @param orgId + */ + private void deleteCheck(List ids, String orgId) { + if (extAgentModuleMapper.countAgentByIds(ids, orgId) > 0) { + throw new GenericException(Translator.get("agent_module_cannot_delete")); + } + } + + + /** + * 智能体树结构 + * + * @param orgId + * + * @return + */ + public List getTree(String orgId) { + List moduleNode = extAgentModuleMapper.selectTreeNode(orgId); + return BaseTreeNode.buildTree(moduleNode); + } + + + private List getDepartmentIds(String userId, String orgId) { + List departmentIds = new ArrayList<>(); + if (!Strings.CI.equals(userId, InternalUser.ADMIN.getValue())) { + String departmentId = extOrganizationUserMapper.getDepartmentByUserId(userId); + List departmentTree = departmentService.getTree(orgId); + departmentIds = getParentIds(departmentTree, departmentId); + } + return departmentIds; + } + + + /** + * 文件夹数量 + * + * @param userId + * @param orgId + * + * @return + */ + public Map moduleCount(String userId, String orgId) { + List moduleNode = extAgentModuleMapper.selectTreeNode(orgId); + //查询模块下仪表板数据 + List departmentIds = getDepartmentIds(userId, orgId); + List agentNode = extAgentMapper.selectAgentNode(departmentIds, orgId, userId); + + List agentTreeNodes = BaseTreeNode.buildTree(moduleNode); + + Map moduleCountMap = agentNode.stream() + .collect(Collectors.groupingBy( + BaseTreeNode::getParentId, + Collectors.summingInt(node -> 1) + )); + + Map countMap = new HashMap<>(); + for (BaseTreeNode root : agentTreeNodes) { + // 递归计算每个节点的仪表板数量 + calculateAgentCount(root, countMap, moduleCountMap); + } + + //我的收藏 + int myCollect = extAgentCollectionMapper.countMyCollect(userId); + countMap.put("myCollect", (long) myCollect); + return countMap; + } + + + private int calculateAgentCount(BaseTreeNode node, Map countMap, Map moduleCountMap) { + int count = 0; + + if (moduleCountMap.containsKey(node.getId())) { + count += moduleCountMap.get(node.getId()); + } + + if (node.getChildren() != null && !node.getChildren().isEmpty()) { + for (BaseTreeNode child : node.getChildren()) { + count += calculateAgentCount(child, countMap, moduleCountMap); + } + } + + countMap.put(node.getId(), (long) count); + + return count; + } + + + /** + * 移动文件夹 + * + * @param request + * @param userId + */ + public void moveNode(NodeMoveRequest request, String userId) { + NodeSortDTO nodeSortDTO = super.getNodeSortDTO(request, + extAgentModuleMapper::selectBaseModuleById, + extAgentModuleMapper::selectModuleByParentIdAndPosOperator, + false); + + + AgentModule agentModule = new AgentModule(); + agentModule.setParentId(nodeSortDTO.getParent().getId()); + agentModule.setId(request.getDragNodeId()); + if (agentModuleMapper.countByExample(agentModule) == 0) { + AgentModule moveModule = agentModuleMapper.selectByPrimaryKey(request.getDragNodeId()); + moveModule.setParentId(nodeSortDTO.getParent().getId()); + checkFileName(moveModule.getName(), moveModule.getParentId(), moveModule.getOrganizationId(), null); + + moveModule.setUpdateUser(userId); + moveModule.setUpdateTime(System.currentTimeMillis()); + agentModuleMapper.update(moveModule); + } + + super.sort(nodeSortDTO); + } + + @Override + public void updatePos(String id, long pos) { + AgentModule agentModule = new AgentModule(); + agentModule.setId(id); + agentModule.setPos(pos); + agentModuleMapper.update(agentModule); + } + + @Override + public void refreshPos(String parentId) { + List childrenIds = extAgentModuleMapper.selectChildrenIds(parentId); + SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); + ExtAgentModuleMapper batchUpdateMapper = sqlSession.getMapper(ExtAgentModuleMapper.class); + for (int i = 0; i < childrenIds.size(); i++) { + batchUpdateMapper.updatePos(childrenIds.get(i), i * NodeSortUtils.DEFAULT_NODE_INTERVAL_POS); + } + sqlSession.flushStatements(); + SqlSessionUtils.closeSqlSession(sqlSession, sqlSessionFactory); + + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/common/client/QrCodeClient.java b/backend/crm/src/main/java/cn/cordys/crm/integration/common/client/QrCodeClient.java new file mode 100644 index 0000000..e6a0dee --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/common/client/QrCodeClient.java @@ -0,0 +1,119 @@ +package cn.cordys.crm.integration.common.client; + + +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.*; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Collections; + +@Service +@Slf4j +public class QrCodeClient { + + private static final RestTemplate restTemplate; + private final static int CONNECT_TIMEOUT = 10000; + + static { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + // 设置连接超时时间(单位:毫秒) + factory.setConnectTimeout(CONNECT_TIMEOUT); + + // 设置读取超时时间(单位:毫秒) + factory.setReadTimeout(CONNECT_TIMEOUT); + + restTemplate = new RestTemplate(factory); + } + + /** + * 通用的异常处理方法 + */ + private String handleRequestError(ResponseEntity forEntity) throws Exception { + if (forEntity.getStatusCode().is4xxClientError() || forEntity.getStatusCode().is5xxServerError()) { + throw new Exception("StatusCode: " + forEntity.getStatusCode()); + } + return JSON.toJSONString(forEntity.getBody()); + } + + /** + * 获取 HTTP GET 请求响应 + */ + public String get(String url) { + try { + var forEntity = restTemplate.getForEntity(url, Object.class); + return handleRequestError(forEntity); + } catch (Exception e) { + log.error("HttpClient查询失败", e); + throw new GenericException(Translator.get("sync.organization.http.error") + ": " + e.getMessage()); + } + } + + /** + * 发送带 Header 参数的 GET 请求 + */ + public String exchange(String url, String headerParam, String paramName, MediaType mediaType, MediaType acceptMediaType) { + var headers = createHeaders(headerParam, paramName, mediaType, acceptMediaType); + var request = new HttpEntity<>(headers); + try { + var forEntity = restTemplate.exchange(url, HttpMethod.GET, request, Object.class); + return handleRequestError(forEntity); + } catch (Exception e) { + log.error("HttpClient查询失败", e); + throw new GenericException(Translator.get("sync.organization.http.error") + ": " + e.getMessage()); + } + } + + /** + * 发送带 Header 参数的 GET 请求,并返回 String 类型的响应 + */ + public String exchangeString(String url, String headerParam, String paramName, MediaType mediaType, MediaType acceptMediaType) { + var headers = createHeaders(headerParam, paramName, mediaType, acceptMediaType); + var request = new HttpEntity<>(headers); + try { + var forEntity = restTemplate.exchange(url, HttpMethod.GET, request, String.class); + if (forEntity.getStatusCode().is4xxClientError() || forEntity.getStatusCode().is5xxServerError()) { + throw new Exception("StatusCode: " + forEntity.getStatusCode()); + } + return JSON.toJSONString(forEntity.getBody()); + } catch (Exception e) { + log.error("HttpClient查询失败", e); + throw new GenericException(Translator.get("sync.organization.http.error") + ": " + e.getMessage()); + } + } + + /** + * 发送带 Header 参数的 POST 请求 + */ + public String postExchange(String url, String headerParam, String paramName, Object body, MediaType mediaType, MediaType acceptMediaType) { + var headers = createHeaders(headerParam, paramName, mediaType, acceptMediaType); + var requestEntity = new HttpEntity<>(body, headers); + try { + var forEntity = restTemplate.exchange(url, HttpMethod.POST, requestEntity, Object.class); + return handleRequestError(forEntity); + } catch (Exception e) { + log.error("HttpClient查询失败", e); + throw new GenericException(Translator.get("sync.organization.http.error") + ": " + e.getMessage()); + } + } + + /** + * 创建 HttpHeaders,避免重复代码 + */ + private HttpHeaders createHeaders(String headerParam, String paramName, MediaType mediaType, MediaType acceptMediaType) { + var headers = new HttpHeaders(); + headers.setContentType(mediaType); + if (acceptMediaType != null) { + headers.setAccept(Collections.singletonList(acceptMediaType)); + } + if (StringUtils.isNotBlank(paramName)) { + headers.set(paramName, headerParam); + } + return headers; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/common/dto/ThirdConfigBaseDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/common/dto/ThirdConfigBaseDTO.java new file mode 100644 index 0000000..461a8a4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/common/dto/ThirdConfigBaseDTO.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.integration.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class ThirdConfigBaseDTO { + + @Schema(description = "类型", requiredMode = Schema.RequiredMode.REQUIRED) + private String type; + + @Schema(description = "是否验证通过", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean verify; + + @Schema(description = "配置属性对象", requiredMode = Schema.RequiredMode.REQUIRED) + private T config; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/DeThirdConfigRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/DeThirdConfigRequest.java new file mode 100644 index 0000000..232da97 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/DeThirdConfigRequest.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.integration.common.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class DeThirdConfigRequest { + + @Schema(description = "APPID", requiredMode = Schema.RequiredMode.REQUIRED) + private String agentId; + + @Schema(description = "appSecret", requiredMode = Schema.RequiredMode.REQUIRED) + private String appSecret; + + @Schema(description = "DEAccessKey", requiredMode = Schema.RequiredMode.REQUIRED) + private String deAccessKey; + + @Schema(description = "DESecretKey", requiredMode = Schema.RequiredMode.REQUIRED) + private String deSecretKey; + + @Schema(description = "回调地址", requiredMode = Schema.RequiredMode.REQUIRED) + private String redirectUrl; + + @Schema(description = "DE组织id", requiredMode = Schema.RequiredMode.REQUIRED) + private String deOrgID; + + @Schema(description = "DE自动同步", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean deAutoSync; + + @Schema(description = "DE仪表板开启", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean deBoardEnable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/DingTalkThirdConfigRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/DingTalkThirdConfigRequest.java new file mode 100644 index 0000000..6d16885 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/DingTalkThirdConfigRequest.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.integration.common.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class DingTalkThirdConfigRequest { + + @Schema(description = "应用ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String agentId; + + @Schema(description = "应用密钥", requiredMode = Schema.RequiredMode.REQUIRED) + private String appSecret; + + @Schema(description = "企业ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String corpId; + + @Schema(description = "同步用户", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean startEnable; + + @Schema(description = "内部应用ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String appId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/LarkThirdConfigRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/LarkThirdConfigRequest.java new file mode 100644 index 0000000..616b1ee --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/LarkThirdConfigRequest.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.integration.common.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class LarkThirdConfigRequest { + + @Schema(description = "应用ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String agentId; + + @Schema(description = "应用密钥", requiredMode = Schema.RequiredMode.REQUIRED) + private String appSecret; + + @Schema(description = "企业ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String corpId; + + @Schema(description = "同步用户", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean startEnable; + + @Schema(description = "回调地址", requiredMode = Schema.RequiredMode.REQUIRED) + private String redirectUrl; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/MaxKBThirdConfigRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/MaxKBThirdConfigRequest.java new file mode 100644 index 0000000..a82d849 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/MaxKBThirdConfigRequest.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.integration.common.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class MaxKBThirdConfigRequest { + + + @Schema(description = "api key", requiredMode = Schema.RequiredMode.REQUIRED) + private String appSecret; + + @Schema(description = "maxKB地址", requiredMode = Schema.RequiredMode.REQUIRED) + private String mkAddress; + + @Schema(description = "mk开启", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean mkEnable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/QccThirdConfigRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/QccThirdConfigRequest.java new file mode 100644 index 0000000..922588a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/QccThirdConfigRequest.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.integration.common.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class QccThirdConfigRequest { + + @Schema(description = "企查查开启", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean qccEnable; + + @Schema(description = "企查查地址", requiredMode = Schema.RequiredMode.REQUIRED) + private String qccAddress; + + @Schema(description = "企查查AccessKey", requiredMode = Schema.RequiredMode.REQUIRED) + private String qccAccessKey; + + @Schema(description = "企查查SecretKey", requiredMode = Schema.RequiredMode.REQUIRED) + private String qccSecretKey; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/SqlBotThirdConfigRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/SqlBotThirdConfigRequest.java new file mode 100644 index 0000000..c694a18 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/SqlBotThirdConfigRequest.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.integration.common.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class SqlBotThirdConfigRequest { + + @Schema(description = "脚本", requiredMode = Schema.RequiredMode.REQUIRED) + private String appSecret; + + @Schema(description = "sqlBot仪表板开启", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean sqlBotBoardEnable; + + @Schema(description = "sqlBot问数开启", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean sqlBotChatEnable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/TenderThirdConfigRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/TenderThirdConfigRequest.java new file mode 100644 index 0000000..5411237 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/TenderThirdConfigRequest.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.integration.common.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class TenderThirdConfigRequest { + + @Schema(description = "tender开启") + private Boolean tenderEnable; + + @Schema(description = "地址") + private String tenderAddress; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/WecomThirdConfigRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/WecomThirdConfigRequest.java new file mode 100644 index 0000000..7b2492a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/common/request/WecomThirdConfigRequest.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.integration.common.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class WecomThirdConfigRequest { + + @Schema(description = "应用ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String agentId; + + @Schema(description = "应用密钥", requiredMode = Schema.RequiredMode.REQUIRED) + private String appSecret; + + @Schema(description = "企业ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String corpId; + + @Schema(description = "同步用户", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean startEnable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/common/service/OAuthUserService.java b/backend/crm/src/main/java/cn/cordys/crm/integration/common/service/OAuthUserService.java new file mode 100644 index 0000000..55a091e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/common/service/OAuthUserService.java @@ -0,0 +1,194 @@ +package cn.cordys.crm.integration.common.service; + +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.integration.common.client.QrCodeClient; +import cn.cordys.crm.integration.common.utils.HttpClientUtils; +import cn.cordys.crm.integration.dingtalk.constant.DingTalkApiPaths; +import cn.cordys.crm.integration.dingtalk.response.DingTalkUserResponse; +import cn.cordys.crm.integration.lark.constant.LarkApiPaths; +import cn.cordys.crm.integration.wecom.constant.WeComApiPaths; +import cn.cordys.crm.integration.wecom.dto.WeComUserTicketDTO; +import cn.cordys.crm.integration.wecom.response.WeComCommonUserResponse; +import cn.cordys.crm.integration.wecom.response.WeComUserResponse; +import com.fasterxml.jackson.core.type.TypeReference; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.Map; + +@Service +@Transactional(rollbackFor = Exception.class) +public class OAuthUserService { + + @Resource + private QrCodeClient qrCodeClient; + + /** + * 获取github Oauth2 用户信息 + * + * @param userInfoUrl 获取用户信息url + * @param accessToken token + * + * @return Map + */ + public Map getGitHubUser(String userInfoUrl, String accessToken) { + String body = qrCodeClient.exchange( + userInfoUrl, + "Bearer " + accessToken, + HttpHeaders.AUTHORIZATION, + MediaType.APPLICATION_JSON, + MediaType.APPLICATION_JSON + ); + return JSON.parseObject(body, new TypeReference>() { + }); + } + + /** + * 获取企业微信访问用户身份 + * + * @param assessToken token + * @param code code + * + * @return WeComUserResponse + */ + public WeComUserResponse getWeComUser(String assessToken, String code) { + // 1. 根据 code 获取通用用户信息 + String commonUrl = HttpClientUtils.urlTransfer(WeComApiPaths.USER_ID_TICKET, assessToken, code); + WeComCommonUserResponse commonRes = getWithWrap(commonUrl, WeComCommonUserResponse.class); + validateWeCom(commonRes.getErrCode(), commonRes.getErrMsg()); + + // 2. 根据是否有 user_ticket 获取详细信息 + WeComUserResponse userRes; + String userTicket = commonRes.getUserTicket(); + if (StringUtils.isNotBlank(userTicket)) { + String detailUrl = HttpClientUtils.urlTransfer(WeComApiPaths.USER_DETAIL, assessToken); + WeComUserTicketDTO body = new WeComUserTicketDTO(); + body.setUser_ticket(userTicket); + userRes = postWithWrap(detailUrl, body); + } else { + String detailUrl = HttpClientUtils.urlTransfer(WeComApiPaths.USER_INFO, assessToken, commonRes.getUserId()); + userRes = getWithWrap(detailUrl, WeComUserResponse.class); + } + + // 3. 校验详细信息返回 + validateWeCom(userRes.getErrCode(), userRes.getErrMsg()); + return userRes; + } + + /** + * 获取钉钉用户信息 + * + * @param dingToken 用户token + * + * @return DingTalkUserResponse + */ + public DingTalkUserResponse getDingTalkUser(String dingToken) { + return exchangeWithWrap( + dingToken + ); + } + + /** + * 根据unionid获取钉钉用户id + * + * @param accessToken access_token + * @param unionid unionid + * + * @return userId + */ + public String getUserIdByUnionId(String accessToken, String unionid) { + String url = HttpClientUtils.urlTransfer(DingTalkApiPaths.DING_USERID_BY_UNIONID, accessToken); + Map body = new HashMap<>(); + body.put("unionid", unionid); + String response = post(url, body); + Map result = JSON.parseObject(response, new TypeReference<>() { + }); + if (result.get("errcode") != null && (Integer) result.get("errcode") == 0) { + Map user = (Map) result.get("result"); + return (String) user.get("userid"); + } else { + throw new GenericException("Error getting user id from unionid: " + result.get("errmsg")); + } + } + + // -------------------- private helpers -------------------- + + private T getWithWrap(String url, Class clazz) { + try { + String response = HttpClientUtils.sendGetRequest(url, null); + return JSON.parseObject(response, clazz); + } catch (Exception e) { + throw new GenericException(Translator.get("auth.get.user.error")); + } + } + + private String post(String url, Object body) { + try { + return qrCodeClient.postExchange( + url, null, null, + body, + MediaType.APPLICATION_JSON, + MediaType.APPLICATION_JSON + ); + } catch (Exception e) { + throw new GenericException(Translator.get("auth.get.user.error")); + } + } + + private WeComUserResponse postWithWrap(String url, Object body) { + try { + String response = qrCodeClient.postExchange( + url, null, null, + body, + MediaType.APPLICATION_JSON, + MediaType.APPLICATION_JSON + ); + return JSON.parseObject(response, WeComUserResponse.class); + } catch (Exception e) { + throw new GenericException(Translator.get("auth.get.user.error")); + } + } + + private DingTalkUserResponse exchangeWithWrap(String token) { + try { + String body = qrCodeClient.exchange( + DingTalkApiPaths.DING_USER_INFO, + token, + "x-acs-dingtalk-access-token", + MediaType.APPLICATION_JSON, + MediaType.APPLICATION_JSON + ); + return JSON.parseObject(body, DingTalkUserResponse.class); + } catch (Exception e) { + throw new GenericException(Translator.get("auth.get.user.error")); + } + } + + private void validateWeCom(Integer errCode, String errMsg) { + if (errCode == null) { + throw new GenericException(Translator.get("auth.get.user.res.error")); + } + if (errCode != 0) { + throw new GenericException(Translator.get("auth.get.user.res.error") + ":" + errMsg); + } + } + + public Map getLarkUser(String assessToken) { + String body = qrCodeClient.exchange( + LarkApiPaths.LARK_USER_INFO_URL, + "Bearer " + assessToken, + HttpHeaders.AUTHORIZATION, + MediaType.APPLICATION_JSON, + MediaType.APPLICATION_JSON + ); + return JSON.parseObject(body, new TypeReference>() { + }); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/common/utils/DataHandleUtils.java b/backend/crm/src/main/java/cn/cordys/crm/integration/common/utils/DataHandleUtils.java new file mode 100644 index 0000000..98e8da5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/common/utils/DataHandleUtils.java @@ -0,0 +1,565 @@ +package cn.cordys.crm.integration.common.utils; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.ThirdConfigTypeConstants; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.CodingUtils; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.NodeSortUtils; +import cn.cordys.crm.approval.mapper.ExtApprovalTaskMapper; +import cn.cordys.crm.approval.service.ApprovalActionService; +import cn.cordys.crm.integration.sync.dto.ThirdDepartment; +import cn.cordys.crm.integration.sync.dto.ThirdUser; +import cn.cordys.crm.system.domain.*; +import cn.cordys.crm.system.dto.request.MessageTaskBatchRequest; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.crm.system.service.MessageNotificationService; +import cn.cordys.crm.system.service.OrganizationConfigService; +import cn.cordys.crm.system.service.OrganizationUserService; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.Strings; + +import java.util.*; +import java.util.stream.Collectors; + +public class DataHandleUtils { + + private final String orgId; + private final DepartmentService departmentService; + private final OrganizationUserService organizationUserService; + private final OrganizationConfigService organizationConfigService; + private final MessageNotificationService messageNotificationService; + private final ExtApprovalTaskMapper extApprovalTaskMapper; + private final ApprovalActionService approvalActionService; + private final List addDepartments = new ArrayList<>(); + private final List updateDepartments = new ArrayList<>(); + private final List addUsers = new ArrayList<>(); + private final List updateUsers = new ArrayList<>(); + private final List userExtends = new ArrayList<>(); + private final List organizationUsers = new ArrayList<>(); + private final List updateOrganizationUsers = new ArrayList<>(); + private final Map> departmentUserMap; + private final List departmentCommanders = new ArrayList<>(); + private final Department internalDepartment; + private final String type; + private List thirdDepartmentTree; + private Long nextPos; + + + public DataHandleUtils(String orgId, Map> departmentUserMap, String type) { + this.departmentService = CommonBeanFactory.getBean(DepartmentService.class); + this.organizationUserService = CommonBeanFactory.getBean(OrganizationUserService.class); + this.organizationConfigService = CommonBeanFactory.getBean(OrganizationConfigService.class); + this.messageNotificationService = CommonBeanFactory.getBean(MessageNotificationService.class); + this.extApprovalTaskMapper = CommonBeanFactory.getBean(ExtApprovalTaskMapper.class); + this.approvalActionService = CommonBeanFactory.getBean(ApprovalActionService.class); + this.orgId = orgId; + this.departmentUserMap = departmentUserMap; + + assert departmentService != null; + this.internalDepartment = departmentService.getInternalDepartment(orgId, ThirdConfigTypeConstants.INTERNAL.name()); + this.nextPos = departmentService.getNextPos(orgId); + this.type = type; + } + + /** + * 首次同步处理数据 + * + * @param thirdDepartments 微信部门数据 + * @param operatorId 操作人ID + */ + public void handleAddData(List thirdDepartments, String operatorId, String orgId, String type) { + this.thirdDepartmentTree = ThirdDepartment.buildDepartmentTree(internalDepartment.getId(), thirdDepartments); + //更新待办任务的审批人 + extApprovalTaskMapper.updateApprover(InternalUser.ADMIN.getValue()); + organizationUserService.deleteUser(orgId); + departmentService.deleteByOrgId(orgId); + + clearMessageConfig(orgId, operatorId); + + thirdDepartmentTree.forEach(wecomDept -> handleTreeAddData(wecomDept, operatorId)); + + saveAllEntities(); + organizationConfigService.updateSyncFlag(orgId, type, true); + } + + //删除消息配置的数据 + private void clearMessageConfig(String orgId, String operatorId) { + MessageTaskBatchRequest request = new MessageTaskBatchRequest(); + request.setWeComEnable(false); + request.setEmailEnable(false); + request.setLarkEnable(false); + request.setDingTalkEnable(false); + messageNotificationService.batchSaveMessageTask(request, orgId, operatorId); + } + + private void handleTreeAddData(ThirdDepartment thirdDepartment, String operatorId) { + buildData(thirdDepartment, operatorId); + + if (CollectionUtils.isNotEmpty(thirdDepartment.getChildren())) { + thirdDepartment.getChildren().stream() + .sorted(Comparator.comparing(ThirdDepartment::getOrder).reversed()) + .forEach(department -> + handleTreeAddData(department, operatorId) + ); + } + } + + /** + * 构建数据 + * + * @param thirdDepartment 微信部门 + * @param operatorId 操作人ID + */ + public void buildData(ThirdDepartment thirdDepartment, String operatorId) { + List thirdUsers = getThirdUsers(thirdDepartment); + buildDepartment(thirdDepartment, operatorId); + buildUser(thirdDepartment, thirdUsers, operatorId); + } + + /** + * 首次同步构建部门信息 + * + * @param thirdDepartment 微信部门 + * @param operatorId 操作人ID + */ + public void buildDepartment(ThirdDepartment thirdDepartment, String operatorId) { + long currentTime = System.currentTimeMillis(); + + if (thirdDepartment.getIsRoot()) { + Department department = new Department(); + department.setId(internalDepartment.getId()); + department.setName(thirdDepartment.getName()); + department.setResourceId(thirdDepartment.getId()); + department.setUpdateUser(operatorId); + department.setUpdateTime(currentTime); + updateDepartments.add(department); + } else { + Department department = new Department(); + department.setId(thirdDepartment.getCrmId()); + department.setName(thirdDepartment.getName()); + department.setOrganizationId(orgId); + department.setParentId(thirdDepartment.getCrmParentId()); + department.setPos(nextPos); + department.setResource(type); + department.setResourceId(thirdDepartment.getId()); + department.setCreateUser(operatorId); + department.setUpdateUser(operatorId); + department.setCreateTime(currentTime); + department.setUpdateTime(currentTime); + addDepartments.add(department); + nextPos += NodeSortUtils.DEFAULT_NODE_INTERVAL_POS; + } + } + + /** + * 首次同步构建用户信息 + * + * @param thirdDepartment 部门 + * @param thirdUsers 用户列表 + * @param operatorId 操作人ID + */ + public void buildUser(ThirdDepartment thirdDepartment, List thirdUsers, String operatorId) { + if (CollectionUtils.isEmpty(thirdUsers)) { + return; + } + + long currentTime = System.currentTimeMillis(); + + thirdUsers.forEach(thirdUser -> { + String id = IDGenerator.nextStr(); + + if (CollectionUtils.isNotEmpty(organizationUsers)) { + OrganizationUser organizationUser = organizationUsers.stream().filter(user -> Strings.CI.equals(user.getResourceUserId(), thirdUser.getUserId())) + .findFirst().orElse(null); + if (organizationUser != null) { + return; + } + } + + // 基本信息 + User user = createUser(id, thirdUser, operatorId, currentTime); + addUsers.add(user); + + // 拓展信息 + UserExtend userExtend = createUserExtend(id, thirdUser); + userExtends.add(userExtend); + + // 组织用户关系 + OrganizationUser orgUser = createOrganizationUser(id, thirdDepartment, thirdUser, operatorId, currentTime); + organizationUsers.add(orgUser); + + // 部门负责人信息 + addDepartmentCommanderIfNeeded(id, thirdDepartment, thirdUser, operatorId, currentTime); + }); + } + + /** + * 多次同步更新数据 + * + * @param thirdDepartments 微信部门数据 + * @param operatorId 操作人ID + */ + public void handleUpdateData(List thirdDepartments, String operatorId) { + // 获取用户列表 + List userList = organizationUserService.getUserByOrgId(orgId); + + // 微信全量用户 + List thirdUserList = departmentUserMap.values().stream() + .flatMap(List::stream) + .toList(); + + // 需要禁用的用户(企业微信不存在而系统存在的用户) + List disableUserList = userList.stream() + .filter(user -> thirdUserList.stream() + .noneMatch(thirdUser -> Strings.CI.equalsAny(thirdUser.getUserId(), user.getResourceUserId()))) + .collect(Collectors.toList()); + + List userIds = disableUserList.stream().map(OrganizationUser::getUserId).toList(); + approvalActionService.refreshApprovingTasksForDisabledUser(userIds, orgId); + organizationUserService.disableUsers(disableUserList); + + // 当前系统数据 + List currentDepartmentList = departmentService.getDepartmentByOrgId(orgId); + List currentUserList = organizationUserService.getUserByOrgId(orgId); + List currentCommander = departmentService.getDepartmentCommander(currentUserList); + + if (CollectionUtils.isNotEmpty(currentDepartmentList)) { + thirdDepartmentTree = ThirdDepartment.buildDepartmentTreeMultiple( + internalDepartment.getId(), + currentDepartmentList, + thirdDepartments + ); + } + + thirdDepartmentTree.forEach(thirdDepartment -> + handleTreeUpdateData(thirdDepartment, operatorId, currentDepartmentList, currentUserList, currentCommander) + ); + + saveAllEntities(); + } + + private void handleTreeUpdateData(ThirdDepartment thirdDepartment, String operatorId, + List currentDepartmentList, + List currentUserList, + List currentCommander) { + buildUpdateData(thirdDepartment, operatorId, currentDepartmentList, currentUserList, currentCommander); + + if (CollectionUtils.isNotEmpty(thirdDepartment.getChildren())) { + thirdDepartment.getChildren().stream() + .sorted(Comparator.comparing(ThirdDepartment::getOrder).reversed()) + .forEach(department -> + handleTreeUpdateData(department, operatorId, currentDepartmentList, currentUserList, currentCommander) + ); + } + } + + private void buildUpdateData(ThirdDepartment thirdDepartment, String operatorId, + List currentDepartmentList, + List currentUserList, + List currentCommander) { + List thirdUsers = getThirdUsers(thirdDepartment); + + // 构建更新和新增部门参数 + buildUpdateAndAddDepartment(thirdDepartment, operatorId, currentDepartmentList); + + // 构建更新和新增用户参数 + buildUpdateAndAddUser(thirdDepartment, thirdUsers, operatorId, currentUserList, currentDepartmentList, currentCommander); + } + + /** + * 构建更新和新增用户数据 + */ + private void buildUpdateAndAddUser(ThirdDepartment thirdDepartment, List thirdUsers, String operatorId, + List currentUserList, + List currentDepartmentList, + List currentCommander) { + if (CollectionUtils.isEmpty(thirdUsers)) { + return; + } + + long currentTime = System.currentTimeMillis(); + + thirdUsers.forEach(thirdUser -> { + // 查找当前用户是否存在 + OrganizationUser organizationUser = findExistingUser(currentUserList, thirdUser); + + // 查找部门 + Department department = findExistingDepartment(currentDepartmentList, thirdDepartment); + String departmentId = (department != null) ? department.getId() : thirdDepartment.getCrmId(); + + if (organizationUser != null) { + // 更新已有用户 + updateExistingUser(organizationUser, thirdUser, departmentId, operatorId, currentTime); + + // 检查并添加部门负责人 + addCommanderIfNeeded(organizationUser.getUserId(), departmentId, thirdUser, + currentCommander, operatorId, currentTime); + } else { + // 添加新用户 + addNewUser(thirdUser, thirdDepartment, departmentId, operatorId, currentTime); + } + }); + } + + /** + * 同步构建更新和新增部门数据 + */ + private void buildUpdateAndAddDepartment(ThirdDepartment thirdDepartment, String operatorId, List currentDepartmentList) { + if (thirdDepartment.getIsRoot()) { + // 更新根部门 + Department department = new Department(); + department.setId(internalDepartment.getId()); + department.setName(thirdDepartment.getName()); + department.setResourceId(thirdDepartment.getId()); + department.setUpdateUser(operatorId); + department.setUpdateTime(System.currentTimeMillis()); + updateDepartments.add(department); + } else { + // 处理子部门 + buildChildrenDepartment(thirdDepartment, operatorId, currentDepartmentList); + } + } + + /** + * 构建子部门数据 + */ + private void buildChildrenDepartment(ThirdDepartment thirdDepartment, String operatorId, List currentDepartmentList) { + long currentTime = System.currentTimeMillis(); + + if (CollectionUtils.isEmpty(currentDepartmentList)) { + // 如果当前没有部门数据,则添加新部门 + addNewDepartment(thirdDepartment, operatorId, currentTime); + } else { + // 查找是否已存在该部门 + Department existingDept = currentDepartmentList.stream() + .filter(dept -> Strings.CI.equalsAny(dept.getResourceId(), thirdDepartment.getId())) + .findFirst() + .orElse(null); + + if (existingDept != null) { + // 更新已有部门 + updateExistingDepartment(existingDept, thirdDepartment, currentDepartmentList, operatorId, currentTime); + } else { + // 添加新部门 + addNewDepartment(thirdDepartment, operatorId, currentTime); + } + } + } + + // 辅助方法 + + private List getThirdUsers(ThirdDepartment thirdDepartment) { + return departmentUserMap.getOrDefault(thirdDepartment.getId(), new ArrayList<>()); + } + + private void saveAllEntities() { + departmentService.save(addDepartments); + departmentService.update(updateDepartments); + organizationUserService.save(addUsers, userExtends, organizationUsers, departmentCommanders); + organizationUserService.update(updateUsers, updateOrganizationUsers); + } + + private User createUser(String id, ThirdUser thirdUser, String operatorId, long timestamp) { + User user = new User(); + user.setId(id); + user.setName(thirdUser.getName()); + user.setPhone(thirdUser.getMobile()); + user.setEmail(thirdUser.getEmail()); + user.setPassword(CodingUtils.md5(orgId + id)); + user.setGender(thirdUser.getGender() != null && thirdUser.getGender() != 1); + user.setLanguage("zh_CN"); + user.setCreateTime(timestamp); + user.setCreateUser(operatorId); + user.setUpdateTime(timestamp); + user.setUpdateUser(operatorId); + user.setLastOrganizationId(orgId); + return user; + } + + private UserExtend createUserExtend(String id, ThirdUser thirdUser) { + UserExtend userExtend = new UserExtend(); + userExtend.setId(id); + userExtend.setAvatar(thirdUser.getAvatar()); + return userExtend; + } + + private OrganizationUser createOrganizationUser(String userId, ThirdDepartment thirdDepartment, + ThirdUser thirdUser, String operatorId, long timestamp) { + OrganizationUser orgUser = new OrganizationUser(); + orgUser.setId(IDGenerator.nextStr()); + orgUser.setDepartmentId(thirdDepartment.getCrmId()); + orgUser.setOrganizationId(orgId); + orgUser.setUserId(userId); + orgUser.setResourceUserId(thirdUser.getUserId()); + orgUser.setEnable(true); + orgUser.setPosition(thirdUser.getPosition()); + orgUser.setCreateTime(timestamp); + orgUser.setCreateUser(operatorId); + orgUser.setUpdateTime(timestamp); + orgUser.setUpdateUser(operatorId); + return orgUser; + } + + private void addDepartmentCommanderIfNeeded(String userId, ThirdDepartment thirdDepartment, + ThirdUser thirdUser, String operatorId, long timestamp) { + + if (thirdUser.getIsLeaderInDept()) { + DepartmentCommander commander = new DepartmentCommander(); + commander.setId(IDGenerator.nextStr()); + commander.setUserId(userId); + commander.setDepartmentId(thirdDepartment.getCrmId()); + commander.setCreateTime(timestamp); + commander.setCreateUser(operatorId); + commander.setUpdateTime(timestamp); + commander.setUpdateUser(operatorId); + departmentCommanders.add(commander); + } + } + + private OrganizationUser findExistingUser(List currentUserList, ThirdUser thirdUser) { + return Optional.ofNullable(currentUserList) + .orElse(Collections.emptyList()) + .stream() + .filter(user -> Strings.CI.equalsAny(user.getResourceUserId(), thirdUser.getUserId())) + .findFirst() + .orElse(null); + } + + private Department findExistingDepartment(List currentDepartmentList, ThirdDepartment thirdDepartment) { + return Optional.ofNullable(currentDepartmentList) + .orElse(Collections.emptyList()) + .stream() + .filter(dept -> Strings.CI.equalsAny(dept.getResourceId(), thirdDepartment.getId())) + .findFirst() + .orElse(null); + } + + private void updateExistingUser(OrganizationUser orgUser, ThirdUser thirdUser, + String departmentId, String operatorId, long timestamp) { + // 更新用户基本信息 + User updateUser = new User(); + updateUser.setId(orgUser.getUserId()); + updateUser.setName(thirdUser.getName()); + Optional.ofNullable(thirdUser.getGender()).ifPresent(g -> updateUser.setGender(g != 1)); + updateUser.setUpdateTime(timestamp); + updateUser.setUpdateUser(operatorId); + updateUser.setLastOrganizationId(orgId); + updateUsers.add(updateUser); + + // 更新组织用户关系 + OrganizationUser updateOrgUser = new OrganizationUser(); + updateOrgUser.setId(orgUser.getId()); + updateOrgUser.setDepartmentId(departmentId); + updateOrgUser.setPosition(thirdUser.getPosition()); + updateOrgUser.setUpdateTime(timestamp); + updateOrgUser.setUpdateUser(operatorId); + updateOrganizationUsers.add(updateOrgUser); + } + + private void addCommanderIfNeeded(String userId, String departmentId, ThirdUser thirdUser, + List currentCommander, + String operatorId, long timestamp) { + + if (thirdUser.getIsLeaderInDept()) { + boolean commanderExists = Optional.ofNullable(currentCommander) + .orElse(Collections.emptyList()) + .stream() + .anyMatch(cmd -> Strings.CI.equalsAny(cmd.getUserId(), userId) && + Strings.CI.equalsAny(cmd.getDepartmentId(), departmentId)); + + if (!commanderExists) { + DepartmentCommander commander = new DepartmentCommander(); + commander.setId(IDGenerator.nextStr()); + commander.setUserId(userId); + commander.setDepartmentId(departmentId); + commander.setCreateTime(timestamp); + commander.setCreateUser(operatorId); + commander.setUpdateTime(timestamp); + commander.setUpdateUser(operatorId); + departmentCommanders.add(commander); + } + } + } + + private void addNewUser(ThirdUser thirdUser, ThirdDepartment thirdDepartment, + String departmentId, String operatorId, long timestamp) { + String id = IDGenerator.nextStr(); + + if (CollectionUtils.isNotEmpty(organizationUsers)) { + OrganizationUser organizationUser = organizationUsers.stream().filter(user -> Strings.CI.equals(user.getResourceUserId(), thirdUser.getUserId())) + .findFirst().orElse(null); + if (organizationUser != null) { + return; + } + } + + // 创建用户基本信息 + User user = createUser(id, thirdUser, operatorId, timestamp); + addUsers.add(user); + + // 创建用户扩展信息 + UserExtend userExtend = createUserExtend(id, thirdUser); + userExtends.add(userExtend); + + // 创建组织用户关系 + OrganizationUser orgUser = new OrganizationUser(); + orgUser.setId(IDGenerator.nextStr()); + orgUser.setDepartmentId(departmentId); + orgUser.setOrganizationId(orgId); + orgUser.setUserId(id); + orgUser.setResourceUserId(thirdUser.getUserId()); + orgUser.setEnable(true); + orgUser.setPosition(thirdUser.getPosition()); + orgUser.setCreateTime(timestamp); + orgUser.setCreateUser(operatorId); + orgUser.setUpdateTime(timestamp); + orgUser.setUpdateUser(operatorId); + organizationUsers.add(orgUser); + + // 检查并创建部门负责人 + addDepartmentCommanderIfNeeded(id, thirdDepartment, thirdUser, operatorId, timestamp); + } + + private void updateExistingDepartment(Department existingDept, ThirdDepartment thirdDepartment, + List currentDepartmentList, String operatorId, long timestamp) { + Department parentDep = currentDepartmentList.stream() + .filter(dept -> Strings.CI.equalsAny(dept.getId(), existingDept.getParentId())) + .findFirst() + .orElse(null); + + Department crmParentDep = currentDepartmentList.stream() + .filter(dept -> Strings.CI.equalsAny(dept.getId(), thirdDepartment.getCrmParentId())) + .findFirst() + .orElse(null); + + Department updateDept = new Department(); + updateDept.setId(existingDept.getId()); + updateDept.setName(thirdDepartment.getName()); + updateDept.setUpdateTime(timestamp); + updateDept.setUpdateUser(operatorId); + if (crmParentDep != null) { + updateDept.setParentId(crmParentDep.getId()); + } else { + updateDept.setParentId(parentDep == null ? thirdDepartment.getCrmParentId() : parentDep.getId()); + } + updateDepartments.add(updateDept); + } + + private void addNewDepartment(ThirdDepartment thirdDepartment, String operatorId, long timestamp) { + Department department = new Department(); + department.setId(thirdDepartment.getCrmId()); + department.setName(thirdDepartment.getName()); + department.setOrganizationId(orgId); + department.setParentId(thirdDepartment.getCrmParentId()); + department.setPos(nextPos); + department.setResource(type); + department.setResourceId(thirdDepartment.getId()); + department.setCreateUser(operatorId); + department.setUpdateUser(operatorId); + department.setCreateTime(timestamp); + department.setUpdateTime(timestamp); + addDepartments.add(department); + nextPos += NodeSortUtils.DEFAULT_NODE_INTERVAL_POS; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/common/utils/HttpClientUtils.java b/backend/crm/src/main/java/cn/cordys/crm/integration/common/utils/HttpClientUtils.java new file mode 100644 index 0000000..1da25a3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/common/utils/HttpClientUtils.java @@ -0,0 +1,209 @@ +package cn.cordys.crm.integration.common.utils; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.*; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.text.MessageFormat; +import java.time.Duration; +import java.util.Map; + +/** + * HTTP 请求工具类,基于 Spring RestTemplate 实现,内建重试机制。 + * + *

默认重试 3 次,采用指数退避策略(1s → 2s → 4s,上限 30s)。 + * 支持 GET / POST / 通用 method 请求,可通过重载方法自定义重试次数。

+ */ +@Slf4j +public class HttpClientUtils { + + /** + * 默认最大重试次数 + */ + private static final int DEFAULT_MAX_RETRIES = 3; + + /** + * 默认连接超时 + */ + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); + + /** + * 默认读取超时 + */ + private static final Duration READ_TIMEOUT = Duration.ofSeconds(30); + + /** + * 退避时间上限 + */ + private static final long MAX_BACKOFF_MS = Duration.ofSeconds(30).toMillis(); + + private static final RestTemplate restTemplate; + + static { + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(CONNECT_TIMEOUT); + factory.setReadTimeout(READ_TIMEOUT); + restTemplate = new RestTemplate(factory); + } + + /** + * 发送 GET 请求(默认重试 3 次) + */ + public static String sendGetRequest(String url, Map headers) { + return executeWithRetry(() -> doGet(url, headers), DEFAULT_MAX_RETRIES, url); + } + + /** + * 发送 GET 请求,指定重试次数 + */ + public static String sendGetRequest(String url, Map headers, int maxRetries) { + return executeWithRetry(() -> doGet(url, headers), maxRetries, url); + } + + private static String doGet(String url, Map headers) { + var entity = new HttpEntity<>(null, buildHeaders(headers)); + return extractBody(restTemplate.exchange(URI.create(url), HttpMethod.GET, entity, String.class)); + } + + /** + * 发送 POST 请求(默认重试 3 次,Content-Type 默认为 application/json) + */ + public static String sendPostRequest(String url, String body, Map headers) { + return executeWithRetry(() -> doPost(url, body, headers), DEFAULT_MAX_RETRIES, url); + } + + /** + * 发送 POST 请求,指定重试次数 + */ + public static String sendPostRequest(String url, String body, Map headers, int maxRetries) { + return executeWithRetry(() -> doPost(url, body, headers), maxRetries, url); + } + + private static String doPost(String url, String body, Map headers) { + var httpHeaders = buildHeaders(headers); + if (!httpHeaders.containsKey(HttpHeaders.CONTENT_TYPE)) { + httpHeaders.setContentType(MediaType.APPLICATION_JSON); + } + var entity = new HttpEntity<>(body, httpHeaders); + return extractBody(restTemplate.exchange(URI.create(url), HttpMethod.POST, entity, String.class)); + } + + /** + * 发送指定 HTTP 方法的请求(默认重试 3 次) + */ + public static String sendRequest(HttpMethod method, String url, String body, Map headers) { + return executeWithRetry(() -> doRequest(method, url, body, headers), DEFAULT_MAX_RETRIES, url); + } + + /** + * 发送指定 HTTP 方法的请求,指定重试次数 + */ + public static String sendRequest(HttpMethod method, String url, String body, Map headers, int maxRetries) { + return executeWithRetry(() -> doRequest(method, url, body, headers), maxRetries, url); + } + + private static String doRequest(HttpMethod method, String url, String body, Map headers) { + var httpHeaders = buildHeaders(headers); + if (body != null && !httpHeaders.containsKey(HttpHeaders.CONTENT_TYPE)) { + httpHeaders.setContentType(MediaType.APPLICATION_JSON); + } + var entity = new HttpEntity<>(body, httpHeaders); + return extractBody(restTemplate.exchange(URI.create(url), method, entity, String.class)); + } + + /** + * 提取响应体:2xx 直接返回 body,非 2xx 记录日志后仍返回 body(兼容原有行为) + */ + private static String extractBody(ResponseEntity response) { + if (response.getStatusCode().is2xxSuccessful()) { + return response.getBody(); + } + var msg = "Error: " + response.getStatusCode().value() + " - " + response.getBody(); + log.warn("HTTP 请求返回非成功状态码: {}", msg); + return msg; + } + + /** + * 带重试的执行器,指数退避策略。 + * + * @param callable 请求逻辑 + * @param maxRetries 最大重试次数 + * @param url 请求 URL(仅用于日志) + * + * @return 响应字符串 + * + * @throws RestClientException 重试耗尽后抛出 + */ + private static String executeWithRetry(HttpCallable callable, int maxRetries, String url) { + var retries = Math.max(maxRetries, 1); + Exception lastException = null; + + for (int attempt = 1; attempt <= retries; attempt++) { + try { + return callable.call(); + } catch (Exception e) { + lastException = e; + if (attempt >= retries) { + break; + } + var delayMs = computeBackoff(attempt); + log.error("HTTP 请求失败 (第 {}/{} 次), URL: {}, 原因: {}, {}ms 后重试", + attempt, retries, url, e.getMessage(), delayMs); + sleepUnchecked(delayMs); + } + } + + log.error("HTTP 请求重试 {} 次后仍然失败, URL: {}", retries, url, lastException); + throw new RestClientException("请求失败,已重试 " + retries + " 次: " + url, lastException); + } + + /** + * 指数退避:1s, 2s, 4s, 8s, … 上限 30s + */ + private static long computeBackoff(int attempt) { + return Math.min(1_000L << (attempt - 1), MAX_BACKOFF_MS); + } + + private static void sleepUnchecked(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RestClientException("重试等待被中断", e); + } + } + + /** + * URL 模板拼接,参数自动 URL 编码,null 值替换为空字符串。 + * + *
{@code
+     *   urlTransfer("https://api.example.com/{0}/{1}", token, keyword)
+     * }
+ */ + public static String urlTransfer(String urlPattern, Object... params) { + var vars = new Object[params.length]; + for (int i = 0; i < params.length; i++) { + vars[i] = (params[i] == null) ? "" : URLEncoder.encode(StringUtils.stripToEmpty(params[i].toString()), StandardCharsets.UTF_8); + } + return MessageFormat.format(urlPattern, vars); + } + + private static HttpHeaders buildHeaders(Map headers) { + var httpHeaders = new HttpHeaders(); + if (headers != null) { + headers.forEach(httpHeaders::set); + } + return httpHeaders; + } + + @FunctionalInterface + private interface HttpCallable { + String call() throws Exception; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/DataEaseClient.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/DataEaseClient.java new file mode 100644 index 0000000..6e9b0d0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/DataEaseClient.java @@ -0,0 +1,220 @@ +package cn.cordys.crm.integration.dataease; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.integration.common.request.DeThirdConfigRequest; +import cn.cordys.crm.integration.dataease.dto.*; +import cn.cordys.crm.integration.dataease.dto.request.*; +import cn.cordys.crm.integration.dataease.dto.response.*; +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTCreator; +import com.auth0.jwt.algorithms.Algorithm; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Slf4j +public class DataEaseClient { + + protected static final RestTemplate restTemplate = new RestTemplate(); + + private final String accessKey; + private final String secretKey; + protected String endpoint; + protected String prefix = "/de2api/"; + + public DataEaseClient(DeThirdConfigRequest cfg) { + this.accessKey = cfg.getDeAccessKey(); + this.secretKey = cfg.getDeSecretKey(); + this.endpoint = trimTrailingSlash(cfg.getRedirectUrl()); + } + + private String trimTrailingSlash(String url) { + return (url != null && url.endsWith("/")) + ? url.substring(0, url.length() - 1) + : url; + } + + public boolean validate() { + try { + get("user/personInfo"); + return true; + } catch (Exception e) { + log.error(e.getMessage(), e); + return false; + } + } + + public List listSysVariable() { + return post("sysVariable/query", SysVariableListResponse.class).getData(); + } + + public List listSysVariableValue(String sysVariableId) { + return post("sysVariable/value/selected/1/10000", + Map.of("sysVariableId", sysVariableId), + SysVariableValueListResponse.class) + .getData() + .getRecords(); + } + + public SysVariableDTO createSysVariable(SysVariableCreateRequest request) { + return post("sysVariable/create", request, SysVariableCreateResponse.class).getData(); + } + + public Long createRole(RoleCreateRequest request) { + return (Long) post("role/create", request, DataEaseResponse.class).getData(); + } + + public void switchOrg(String orgId) { + post("user/switch/{orgId}", orgId); + } + + public List listRole() { + return post("role/query", RoleListResponse.class).getData(); + } + + public SysVariableValueDTO createSysVariableValue(SysVariableValueCreateRequest request) { + return post("sysVariable/value/create", + request, + SysVariableValueCreateResponse.class).getData(); + } + + public void batchDelSysVariableValue(List valueIds) { + post("sysVariable/value/batchDel", valueIds); + } + + public PageDTO listUserPage(Integer pageNum, Integer pageSize) { + return post("user/pager/{pageNum}/{pageSize}", + UserListResponse.class, + pageNum.toString(), + pageSize.toString()).getData(); + } + + public void createUser(UserCreateRequest request) { + post("user/create", request, DataEaseBaseResponse.class); + } + + // ---------- post wrappers ---------- + + public DataEaseBaseResponse post(String path, Object body, String... uriVariables) { + return post(path, body, DataEaseBaseResponse.class, uriVariables); + } + + public DataEaseBaseResponse post(String path, String... uriVariables) { + return post(path, Map.of(), DataEaseBaseResponse.class, uriVariables); + } + + public V post(String path, Class responseClass, String... uriVars) { + return post(path, Map.of(), responseClass, uriVars); + } + + public V post(String path, Object body, Class responseClass, String... uriVars) { + var url = getUrl(path); + var response = restTemplate.exchange(url, HttpMethod.POST, getHttpEntity(body), responseClass, (Object[]) uriVars) + .getBody(); + validateResponse(response); + return response; + } + + // ---------- get wrappers ---------- + + public DataEaseBaseResponse get(String path, Object... uriVars) { + return get(path, DataEaseBaseResponse.class, uriVars); + } + + public V get(String path, Class responseClass, Object... uriVars) { + var url = getUrl(path); + var response = restTemplate.exchange(url, HttpMethod.GET, getHttpEntity(), responseClass, uriVars) + .getBody(); + validateResponse(response); + return response; + } + + private void validateResponse(T resp) { + if (resp != null && resp.getCode() != 0) { + throw new GenericException(resp.getMsg()); + } + } + + public void editUser(UserUpdateRequest request) { + post("user/edit", request); + } + + public void deleteUser(String id) { + post("user/delete/{id}", id); + } + + private String getUrl(String path) { + return endpoint + prefix + path; + } + + private String getSignature() { + return aesEncrypt(accessKey + "|" + UUID.randomUUID() + "|" + System.currentTimeMillis(), + secretKey, accessKey); + } + + protected HttpEntity> getHttpEntity() { + return new HttpEntity<>(getHeader()); + } + + protected HttpEntity getHttpEntity(Object obj) { + return new HttpEntity<>(JSON.toJSONString(obj), getHeader()); + } + + protected HttpHeaders getHeader() { + var headers = new HttpHeaders(); + headers.setAccept(List.of(MediaType.APPLICATION_JSON)); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("accessKey", accessKey); + + var sig = getSignature(); + headers.set("signature", sig); + headers.set("x-de-ask-token", getJWTToken(sig)); + return headers; + } + + private String getJWTToken(String signature) { + var algorithm = Algorithm.HMAC256(secretKey); + JWTCreator.Builder builder = JWT.create(); + builder.withClaim("accessKey", accessKey) + .withClaim("signature", signature); + return builder.sign(algorithm); + } + + public String aesEncrypt(String src, String key, String iv) { + if (StringUtils.isBlank(src) || StringUtils.isBlank(key)) { + throw new IllegalArgumentException("Input or secretKey cannot be null or empty"); + } + try { + var raw = key.getBytes(StandardCharsets.UTF_8); + var secretKeySpec = new SecretKeySpec(raw, "AES"); + var cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + var ivSpec = new IvParameterSpec(iv.getBytes()); + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivSpec); + return Base64.encodeBase64String(cipher.doFinal(src.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new RuntimeException("AES encrypt error:", e); + } + } + + public List listOrg() { + return post("org/page/lazyTree", Map.of("desc", true), OrgListResponse.class) + .getData() + .getNodes(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/constants/DataScopeDeptVariable.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/constants/DataScopeDeptVariable.java new file mode 100644 index 0000000..2699472 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/constants/DataScopeDeptVariable.java @@ -0,0 +1,11 @@ +package cn.cordys.crm.integration.dataease.constants; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-19 16:29 + */ +public enum DataScopeDeptVariable { + ACCOUNT_DATA_SCOPE_DEPT_ID, + LEAD_DATA_SCOPE_DEPT_ID, + OPPORTUNITY_DATA_SCOPE_DEPT_ID +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/constants/DataScopeVariable.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/constants/DataScopeVariable.java new file mode 100644 index 0000000..936d1b0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/constants/DataScopeVariable.java @@ -0,0 +1,39 @@ +package cn.cordys.crm.integration.dataease.constants; + +import cn.cordys.common.constants.PermissionConstants; + +import java.util.Objects; + +/** + * 数据权限变量与部门范围变量的绑定枚举。 + */ +public enum DataScopeVariable { + ACCOUNT_DATA_SCOPE_TYPE( + PermissionConstants.CUSTOMER_MANAGEMENT_READ, + DataScopeDeptVariable.ACCOUNT_DATA_SCOPE_DEPT_ID + ), + LEAD_DATA_SCOPE_TYPE( + PermissionConstants.CLUE_MANAGEMENT_READ, + DataScopeDeptVariable.LEAD_DATA_SCOPE_DEPT_ID + ), + OPPORTUNITY_DATA_SCOPE_TYPE( + PermissionConstants.OPPORTUNITY_MANAGEMENT_READ, + DataScopeDeptVariable.OPPORTUNITY_DATA_SCOPE_DEPT_ID + ); + + private final String permission; + private final DataScopeDeptVariable dataScopeDeptVariable; + + DataScopeVariable(String permission, DataScopeDeptVariable dataScopeDeptVariable) { + this.permission = Objects.requireNonNull(permission, "permission"); + this.dataScopeDeptVariable = Objects.requireNonNull(dataScopeDeptVariable, "dataScopeDeptVariable"); + } + + public String getPermission() { + return permission; + } + + public DataScopeDeptVariable getDataScopeDeptVariable() { + return dataScopeDeptVariable; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/DeAuthDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/DeAuthDTO.java new file mode 100644 index 0000000..6c38cd1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/DeAuthDTO.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.integration.dataease.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class DeAuthDTO { + + @Schema(description = "token") + private String token; + @Schema(description = "url") + private String url; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/DeConfigDetailDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/DeConfigDetailDTO.java new file mode 100644 index 0000000..62f3a37 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/DeConfigDetailDTO.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.integration.dataease.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class DeConfigDetailDTO { + + @Schema(description = "应用ID") + private String agentId; + @Schema(description = "应用密钥", requiredMode = Schema.RequiredMode.REQUIRED) + private String appSecret; + @Schema(description = "回调地址") + private String redirectUrl; + @Schema(description = "是否验证通过") + private Boolean verify; + @Schema(description = "DE自动同步") + private Boolean deAutoSync; + @Schema(description = "DEAccessKey") + private String deAccessKey; + @Schema(description = "DESecretKey") + private String deSecretKey; + @Schema(description = "DE组织id") + private String deOrgID; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/DeConfigDetailLogDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/DeConfigDetailLogDTO.java new file mode 100644 index 0000000..1f8f72c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/DeConfigDetailLogDTO.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.integration.dataease.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class DeConfigDetailLogDTO { + + @Schema(description = "应用ID") + private String deAppId; + @Schema(description = "应用密钥", requiredMode = Schema.RequiredMode.REQUIRED) + private String deAppSecret; + @Schema(description = "回调地址") + private String deUrl; + @Schema(description = "DE仪表板开启") + private Boolean deBoardEnable; + @Schema(description = "DE自动同步") + private Boolean deAutoSync; + @Schema(description = "DEAccessKey") + private String deAccessKey; + @Schema(description = "DESecretKey") + private String deSecretKey; + @Schema(description = "DE组织id") + private String deOrgID; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/DeTempResourceDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/DeTempResourceDTO.java new file mode 100644 index 0000000..0177865 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/DeTempResourceDTO.java @@ -0,0 +1,74 @@ +package cn.cordys.crm.integration.dataease.dto; + +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.crm.integration.dataease.DataEaseClient; +import cn.cordys.crm.system.domain.UserRole; +import cn.cordys.crm.system.dto.response.RoleListResponse; +import cn.cordys.security.UserDTO; +import lombok.Data; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-21 18:21 + */ +@Data +public class DeTempResourceDTO { + + private DataEaseClient dataEaseClient; + /** + * CRM组织ID + */ + private String crmOrgId; + /** + * DE组织ID + */ + private String deOrgId; + /** + * CRM角色 + */ + private List crmRoles; + /** + * CRM用户 + */ + private List crmUsers; + /** + * 记录DE用户ID + */ + private List deUserIds; + /** + * 记录角色和权限的映射关系 + */ + private Map> rolePermissionMap; + /** + * 记录角色ID和角色的映射关系 + */ + private Map crmRoleMap; + /** + * 部门树 + */ + private List deptTree; + /** + * 用户下的角色信息 + */ + private Map> crmUserRoleMap; + /** + * 自定义部门角色对应的部门ID + */ + private Map> customDeptRoleDeptMap; + /** + * 记录变量名和变量的映射 + */ + private Map sysVariableMap; + /** + * 记录系统变量和对应的值,key 为变量ID,值为变量值的名字和ID映射 + */ + private Map> variableValueMap; + /** + * 记录角色名和角色的映射 + */ + private Map deRoleMap; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/OrgListDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/OrgListDTO.java new file mode 100644 index 0000000..e2ca6ab --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/OrgListDTO.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.integration.dataease.dto; + +import cn.cordys.common.dto.OptionDTO; +import lombok.Data; + +import java.util.List; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-22 18:55 + */ +@Data +public class OrgListDTO { + private List nodes; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/PageDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/PageDTO.java new file mode 100644 index 0000000..e5eebee --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/PageDTO.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.integration.dataease.dto; + +import lombok.Data; + +import java.util.List; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-20 13:56 + */ +@Data +public class PageDTO { + private Integer current; + private Integer pages; + private Integer size; + private Integer total; + private List records; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/RoleDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/RoleDTO.java new file mode 100644 index 0000000..1e2552d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/RoleDTO.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.integration.dataease.dto; + +import cn.cordys.crm.integration.dataease.dto.request.RoleUpdateRequest; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 15:54 + */ +@Data +public class RoleDTO extends RoleUpdateRequest { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/SysVariableDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/SysVariableDTO.java new file mode 100644 index 0000000..acb0b0d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/SysVariableDTO.java @@ -0,0 +1,28 @@ +package cn.cordys.crm.integration.dataease.dto; + +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 15:54 + */ +@Data +public class SysVariableDTO { + private String id; + + private String type = "text"; + + private String name; + + private String min; + + private String max; + + private String startTime; + + private String endTime; + + private boolean root; + + private boolean disabled; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/SysVariableValueDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/SysVariableValueDTO.java new file mode 100644 index 0000000..7461064 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/SysVariableValueDTO.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.dataease.dto; + +import cn.cordys.crm.integration.dataease.dto.request.SysVariableValueUpdateRequest; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 15:54 + */ +@Data +public class SysVariableValueDTO extends SysVariableValueUpdateRequest { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/UserPageDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/UserPageDTO.java new file mode 100644 index 0000000..aa3b423 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/UserPageDTO.java @@ -0,0 +1,28 @@ +package cn.cordys.crm.integration.dataease.dto; + +import cn.cordys.common.dto.OptionDTO; +import lombok.Data; + +import java.util.List; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 15:54 + */ +@Data +public class UserPageDTO { + + private String id; + + private String account; + + private String name; + + private String email; + + private Boolean enable = true; + + private List roleItems; + + private String sysVariable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/UserVariableTempDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/UserVariableTempDTO.java new file mode 100644 index 0000000..c637188 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/UserVariableTempDTO.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.integration.dataease.dto; + +import lombok.Data; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-21 18:21 + */ +@Data +public class UserVariableTempDTO { + + /** + * 记录用户的数据权限变量值 + * key 参考 DataScopeVariable + */ + private Map scopeValueMap = new HashMap<>(5); + /** + * 记录用户的数据部门权限变量值 + * key 参考 DataScopeDeptVariable + */ + private Map> userDeptIdMap = new HashMap<>(0); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/OrgCreateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/OrgCreateRequest.java new file mode 100644 index 0000000..d60b284 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/OrgCreateRequest.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.integration.dataease.dto.request; + +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 16:30 + */ +@Data +public class OrgCreateRequest { + private String name; + private String pid; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/RoleCreateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/RoleCreateRequest.java new file mode 100644 index 0000000..f2d2ee3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/RoleCreateRequest.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.integration.dataease.dto.request; + +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 16:30 + */ +@Data +public class RoleCreateRequest { + private String name; + /** + * 角色类型 + * 0: 普通用户 + * 1: 组织管理员 + */ + private Integer typeCode = 0; + private String desc; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/RoleMountUserRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/RoleMountUserRequest.java new file mode 100644 index 0000000..792d94f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/RoleMountUserRequest.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.integration.dataease.dto.request; + +import lombok.Data; + +import java.util.List; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 16:30 + */ +@Data +public class RoleMountUserRequest { + private String roleId; + private List userIds; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/RoleUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/RoleUpdateRequest.java new file mode 100644 index 0000000..b7e4fa9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/RoleUpdateRequest.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.dataease.dto.request; + +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 16:30 + */ +@Data +public class RoleUpdateRequest extends RoleCreateRequest { + private String id; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/SysVariableCreateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/SysVariableCreateRequest.java new file mode 100644 index 0000000..0edde8c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/SysVariableCreateRequest.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.integration.dataease.dto.request; + +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 16:30 + */ +@Data +public class SysVariableCreateRequest { + private String name; + private String type = "text"; + private String desc; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/SysVariableUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/SysVariableUpdateRequest.java new file mode 100644 index 0000000..6833989 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/SysVariableUpdateRequest.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.dataease.dto.request; + +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 16:30 + */ +@Data +public class SysVariableUpdateRequest extends SysVariableCreateRequest { + private String id; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/SysVariableValueCreateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/SysVariableValueCreateRequest.java new file mode 100644 index 0000000..b4f0404 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/SysVariableValueCreateRequest.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.integration.dataease.dto.request; + +import lombok.Data; +import org.apache.commons.lang3.StringUtils; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 16:30 + */ +@Data +public class SysVariableValueCreateRequest { + private String sysVariableId; + private String value; + private String end = StringUtils.EMPTY; + private String begin = StringUtils.EMPTY; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/SysVariableValueUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/SysVariableValueUpdateRequest.java new file mode 100644 index 0000000..ff8a31c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/SysVariableValueUpdateRequest.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.dataease.dto.request; + +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 16:30 + */ +@Data +public class SysVariableValueUpdateRequest extends SysVariableValueCreateRequest { + private String id; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/UserCreateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/UserCreateRequest.java new file mode 100644 index 0000000..707596d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/UserCreateRequest.java @@ -0,0 +1,41 @@ +package cn.cordys.crm.integration.dataease.dto.request; + +import cn.cordys.crm.integration.dataease.dto.SysVariableDTO; +import lombok.Data; + +import java.util.List; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 14:52 + */ +@Data +public class UserCreateRequest { + + private String account; + + private String name; + + private String email; + + private boolean enable = true; + + private String phone; + + private String phonePrefix; + + private List roleIds; + + private List variables; + + @Data + public static class Variable { + private String variableId; + + private String variableValueId; + + private SysVariableDTO sysVariableDto; + + private List variableValueIds; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/UserUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/UserUpdateRequest.java new file mode 100644 index 0000000..c570754 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/request/UserUpdateRequest.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.dataease.dto.request; + +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 14:52 + */ +@Data +public class UserUpdateRequest extends UserCreateRequest { + private String id; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/DataEaseBaseResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/DataEaseBaseResponse.java new file mode 100644 index 0000000..2996ffc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/DataEaseBaseResponse.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.integration.dataease.dto.response; + +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 15:48 + */ +@Data +public class DataEaseBaseResponse { + private int code; + + private String msg; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/DataEaseListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/DataEaseListResponse.java new file mode 100644 index 0000000..01ea600 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/DataEaseListResponse.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.integration.dataease.dto.response; + +import lombok.Data; + +import java.util.List; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 15:48 + */ +@Data +public class DataEaseListResponse extends DataEaseBaseResponse { + private List data; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/DataEasePageResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/DataEasePageResponse.java new file mode 100644 index 0000000..16b2b4e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/DataEasePageResponse.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.dataease.dto.response; + +import cn.cordys.crm.integration.dataease.dto.PageDTO; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 15:48 + */ +@Data +public class DataEasePageResponse extends DataEaseResponse> { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/DataEaseResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/DataEaseResponse.java new file mode 100644 index 0000000..35fe08c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/DataEaseResponse.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.dataease.dto.response; + +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 15:48 + */ +@Data +public class DataEaseResponse extends DataEaseBaseResponse { + private T data; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/OrgListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/OrgListResponse.java new file mode 100644 index 0000000..139fffc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/OrgListResponse.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.dataease.dto.response; + +import cn.cordys.crm.integration.dataease.dto.OrgListDTO; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 15:54 + */ +@Data +public class OrgListResponse extends DataEaseResponse { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/RoleListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/RoleListResponse.java new file mode 100644 index 0000000..625a898 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/RoleListResponse.java @@ -0,0 +1,10 @@ +package cn.cordys.crm.integration.dataease.dto.response; + +import cn.cordys.crm.integration.dataease.dto.RoleDTO; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-20 10:08 + */ +public class RoleListResponse extends DataEaseListResponse { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/SysVariableCreateResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/SysVariableCreateResponse.java new file mode 100644 index 0000000..903e0cd --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/SysVariableCreateResponse.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.dataease.dto.response; + +import cn.cordys.crm.integration.dataease.dto.SysVariableDTO; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 16:30 + */ +@Data +public class SysVariableCreateResponse extends DataEaseResponse { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/SysVariableListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/SysVariableListResponse.java new file mode 100644 index 0000000..e25eaeb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/SysVariableListResponse.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.dataease.dto.response; + +import cn.cordys.crm.integration.dataease.dto.SysVariableDTO; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 15:54 + */ +@Data +public class SysVariableListResponse extends DataEaseListResponse { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/SysVariableValueCreateResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/SysVariableValueCreateResponse.java new file mode 100644 index 0000000..5c9181d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/SysVariableValueCreateResponse.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.dataease.dto.response; + +import cn.cordys.crm.integration.dataease.dto.SysVariableValueDTO; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 16:30 + */ +@Data +public class SysVariableValueCreateResponse extends DataEaseResponse { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/SysVariableValueListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/SysVariableValueListResponse.java new file mode 100644 index 0000000..0234975 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/SysVariableValueListResponse.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.dataease.dto.response; + +import cn.cordys.crm.integration.dataease.dto.SysVariableValueDTO; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 15:54 + */ +@Data +public class SysVariableValueListResponse extends DataEasePageResponse { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/UserListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/UserListResponse.java new file mode 100644 index 0000000..d464768 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/dto/response/UserListResponse.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.dataease.dto.response; + +import cn.cordys.crm.integration.dataease.dto.UserPageDTO; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-15 15:54 + */ +@Data +public class UserListResponse extends DataEasePageResponse { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/service/DataEaseService.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/service/DataEaseService.java new file mode 100644 index 0000000..90ab186 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/service/DataEaseService.java @@ -0,0 +1,101 @@ +package cn.cordys.crm.integration.dataease.service; + +import cn.cordys.common.constants.ThirdDetailType; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.integration.common.dto.ThirdConfigBaseDTO; +import cn.cordys.crm.integration.common.request.DeThirdConfigRequest; +import cn.cordys.crm.integration.dataease.dto.DeAuthDTO; +import cn.cordys.crm.system.constants.OrganizationConfigConstants; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigDetailMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigMapper; +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTCreator; +import com.auth0.jwt.algorithms.Algorithm; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.List; +import java.util.Optional; + +@Service +public class DataEaseService { + @Resource + private ExtOrganizationConfigMapper extOrganizationConfigMapper; + @Resource + private ExtOrganizationConfigDetailMapper extOrganizationConfigDetailMapper; + + /** + * 获取嵌入式DE-Token + */ + public DeAuthDTO getEmbeddedDeToken(String organizationId, String userId, boolean isModule) { + // 获取组织配置 + DeThirdConfigRequest thirdConfig = getDeConfig(organizationId); + return getEmbeddedDeToken(userId, thirdConfig); + } + + public DeAuthDTO getEmbeddedDeToken(String account, DeThirdConfigRequest thirdConfig) { + // 生成token + String token = generateJwtToken( + thirdConfig.getAgentId(), + thirdConfig.getAppSecret(), + account + ); + + return DeAuthDTO.builder() + .token(token) + .url(thirdConfig.getRedirectUrl()) + .build(); + } + + public DeThirdConfigRequest getDeConfig(String organizationId) { + var config = Optional.ofNullable( + extOrganizationConfigMapper.getOrganizationConfig( + organizationId, + OrganizationConfigConstants.ConfigType.THIRD.name() + ) + ).orElseThrow(() -> + new GenericException("未找到 DataEase 相关配置") + ); + + var detail = extOrganizationConfigDetailMapper + .getOrgConfigDetailByType( + config.getId(), + null, + List.of(ThirdDetailType.DE_BOARD.name()) + ) + .stream() + .findFirst() + .orElseThrow(() -> + new GenericException("未找到 DataEase 配置详情") + ); + + var baseDTO = Optional.ofNullable( + JSON.parseObject(new String(detail.getContent()), ThirdConfigBaseDTO.class) + ).orElseThrow(() -> + new GenericException("DataEase 配置内容解析失败") + ); + + return JSON.MAPPER.convertValue( + baseDTO.getConfig(), + DeThirdConfigRequest.class + ); + } + + /** + * 生成JWT-Token + * + * @param appId 应用ID + * @param appSecret 应用密钥 + * + * @return JWT Token + */ + private String generateJwtToken(String appId, String appSecret, String account) { + Algorithm algorithm = Algorithm.HMAC256(appSecret); + JWTCreator.Builder builder = JWT.create(); + builder.withClaim("account", account).withClaim("appId", appId); + builder.withIssuedAt(new Date()); + return builder.sign(algorithm); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/service/DataEaseSyncService.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/service/DataEaseSyncService.java new file mode 100644 index 0000000..9d738c6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dataease/service/DataEaseSyncService.java @@ -0,0 +1,732 @@ +package cn.cordys.crm.integration.dataease.service; + +import cn.cordys.common.constants.RoleDataScope; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.crm.integration.common.request.DeThirdConfigRequest; +import cn.cordys.crm.integration.dataease.DataEaseClient; +import cn.cordys.crm.integration.dataease.constants.DataScopeDeptVariable; +import cn.cordys.crm.integration.dataease.constants.DataScopeVariable; +import cn.cordys.crm.integration.dataease.dto.*; +import cn.cordys.crm.integration.dataease.dto.request.*; +import cn.cordys.crm.system.domain.RolePermission; +import cn.cordys.crm.system.domain.RoleScopeDept; +import cn.cordys.crm.system.domain.UserRole; +import cn.cordys.crm.system.dto.response.RoleListResponse; +import cn.cordys.crm.system.mapper.ExtDepartmentMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationMapper; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.crm.system.service.RoleService; +import cn.cordys.quartz.anno.QuartzScheduled; +import cn.cordys.security.UserDTO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static java.util.Arrays.stream; + +@Service +@Slf4j +public class DataEaseSyncService { + public static final String NONE_DATA_SCOPE = "NONE"; + @Resource + private ExtDepartmentMapper extDepartmentMapper; + @Resource + private ExtOrganizationMapper extOrganizationMapper; + @Resource + private RoleService roleService; + @Resource + private ExtUserMapper extUserMapper; + @Resource + private DepartmentService departmentService; + @Resource + private DataScopeService dataScopeService; + @Resource + private DataEaseService dataEaseService; + + @QuartzScheduled(cron = "0 0 0 * * ?") + public void syncDataEase() { + Set orgIds = extOrganizationMapper.selectAllOrganizationIds(); + // 同步角色 + for (String orgId : orgIds) { + log.info("定时同步DataEase数据,组织ID: {}", orgId); + syncDataEase(orgId); + } + } + + public void syncDataEase(String orgId) { + LocaleContextHolder.setLocale(Locale.SIMPLIFIED_CHINESE); + DeThirdConfigRequest thirdConfig; + try { + thirdConfig = dataEaseService.getDeConfig(orgId); + } catch (Exception e) { + log.error("获取DataEase配置失败,组织ID: {}", orgId, e); + return; + } + if (thirdConfig == null || StringUtils.isAnyBlank(thirdConfig.getDeAccessKey(), thirdConfig.getDeSecretKey(), thirdConfig.getDeOrgID(), thirdConfig.getRedirectUrl())) { + return; + } + try { + syncDataEase(orgId, thirdConfig); + } catch (Exception e) { + log.error(e.getMessage(), e); + throw e; + } + } + + public void syncDataEase(String orgId, DeThirdConfigRequest thirdConfig) { + DataEaseClient dataEaseClient = new DataEaseClient(thirdConfig); + + DeTempResourceDTO deTempResourceDTO = new DeTempResourceDTO(); + deTempResourceDTO.setDataEaseClient(dataEaseClient); + deTempResourceDTO.setCrmOrgId(orgId); + deTempResourceDTO.setDeOrgId(thirdConfig.getDeOrgID()); + + // 手动切到 DE 组织,DE 接口设计不是很好,接口调用会受到页面切组织的影响,后天手动切组织,降低影响 + dataEaseClient.switchOrg(deTempResourceDTO.getDeOrgId()); + syncSysVariable(deTempResourceDTO); + + // 获取当前组织下的所有角色 + List crmRoles = roleService.getRoleListResponses(orgId); + deTempResourceDTO.setCrmRoles(crmRoles); + + dataEaseClient.switchOrg(deTempResourceDTO.getDeOrgId()); + syncRole(deTempResourceDTO); + + syncUser(deTempResourceDTO); + } + + private void syncUser(DeTempResourceDTO deTempResourceDTO) { + List crmUsers = extUserMapper.selectNameAndEmail(deTempResourceDTO.getCrmOrgId()); + deTempResourceDTO.setCrmUsers(crmUsers); + + // 记录CRM用户ID和用户的映射关系 + Map crmUserMap = crmUsers.stream() + .collect(Collectors.toMap(UserDTO::getId, Function.identity())); + + List crmRoles = deTempResourceDTO.getCrmRoles(); + DataEaseClient dataEaseClient = deTempResourceDTO.getDataEaseClient(); + + // 获取所有的角色 + List roleIds = crmRoles.stream() + .map(RoleListResponse::getId) + .collect(Collectors.toList()); + + // 角色名称集合 + Set roleNameSet = crmRoles.stream() + .map(RoleListResponse::getName) + .collect(Collectors.toSet()); + + setSyncUserTempParam(deTempResourceDTO, roleIds); + + int pageNum = 1; + int pageSize = 20; + // 记录DE的用户ID + List deUserIds = new ArrayList<>(); + deTempResourceDTO.setDeUserIds(deUserIds); + do { + dataEaseClient.switchOrg(deTempResourceDTO.getDeOrgId()); + PageDTO userPage = dataEaseClient.listUserPage(pageNum, pageSize); + List users = userPage.getRecords(); + for (UserPageDTO user : users) { + deUserIds.add(user.getAccount()); + updateUser(deTempResourceDTO, crmUserMap, roleNameSet, user); + } + if (userPage.getRecords().size() < pageSize) { + break; + } + pageNum++; + } while (true); + + addUsers(deTempResourceDTO); + } + + private void setSyncUserTempParam(DeTempResourceDTO deTempResourceDTO, List roleIds) { + List crmRoles = deTempResourceDTO.getCrmRoles(); + + // 获取用户下的角色信息 + Map> userRoleMap = roleService.getUserRoleByRoleIds(roleIds) + .stream() + .collect(Collectors.groupingBy(UserRole::getUserId)); + deTempResourceDTO.setCrmUserRoleMap(userRoleMap); + + // 获取自定义部门角色对应的部门ID + Map> customDeptRoleDeptMap = getCustomDeptRoleDeptMap(crmRoles); + deTempResourceDTO.setCustomDeptRoleDeptMap(customDeptRoleDeptMap); + + // 获取部门树 + List tree = departmentService.getTree(deTempResourceDTO.getCrmOrgId()); + deTempResourceDTO.setDeptTree(tree); + + Map crmRoleMap = crmRoles.stream() + .collect(Collectors.toMap(RoleListResponse::getId, Function.identity())); + deTempResourceDTO.setCrmRoleMap(crmRoleMap); + + // 记录角色和权限的映射关系 + Map> rolePermissionMap = getRolePermissionMap(roleIds); + deTempResourceDTO.setRolePermissionMap(rolePermissionMap); + } + + private void updateUser(DeTempResourceDTO deTempResourceDTO, Map crmUserMap, Set roleNameSet, + UserPageDTO deUser) { + UserDTO crmUser = crmUserMap.get(deUser.getAccount()); + DataEaseClient dataEaseClient = deTempResourceDTO.getDataEaseClient(); + Map roleMap = deTempResourceDTO.getDeRoleMap(); + Map sysVariableMap = deTempResourceDTO.getSysVariableMap(); + Map> variableValueMap = deTempResourceDTO.getVariableValueMap(); + Map crmRoleMap = deTempResourceDTO.getCrmRoleMap(); + List tree = deTempResourceDTO.getDeptTree(); + Map> rolePermissionMap = deTempResourceDTO.getRolePermissionMap(); + Map> customDeptRoleDeptMap = deTempResourceDTO.getCustomDeptRoleDeptMap(); + Map> userRoleMap = deTempResourceDTO.getCrmUserRoleMap(); + + if (crmUser == null) { + // 不在crm的用户,不处理 + return; + } + + String userId = crmUser.getId(); + List roleItems = deUser.getRoleItems(); + + List userCrmRoles = getUserCrmRoles(userRoleMap, crmRoleMap, userId); + + UserUpdateRequest userUpdateRequest = new UserUpdateRequest(); + userUpdateRequest.setId(deUser.getId()); + boolean needUpdate = false; + + if (!Strings.CS.equals(deUser.getName(), crmUser.getName()) || !Strings.CS.equals(deUser.getEmail(), crmUser.getEmail())) { + needUpdate = true; + userUpdateRequest.setName(crmUser.getName()); + userUpdateRequest.setEmail(crmUser.getEmail()); + } else { + // 设置为DE原值 + userUpdateRequest.setName(deUser.getName()); + userUpdateRequest.setEmail(deUser.getEmail()); + } + + // 如果该用户有crm中的角色不存在于DE,则需要更新 + Set crmRoleNames = userCrmRoles.stream() + .map(RoleListResponse::getName) + .collect(Collectors.toSet()); + Set deRoleNames = roleItems.stream() + .map(OptionDTO::getName) + .collect(Collectors.toSet()); + + // 获取DE中存在的CRM角色 + Set linkDeRoleNames = deRoleNames.stream() + .filter(roleNameSet::contains) + .collect(Collectors.toSet()); + if (!linkDeRoleNames.equals(crmRoleNames)) { + // 如果DE中的角色不包含CRM中的角色,则需要更新 + needUpdate = true; + List updateRoleIds = crmRoleNames.stream() + .map(crmRoleName -> roleMap.get(crmRoleName).getId()) + .collect(Collectors.toList()); + // DE中非CRM的角色保留 + List deRoleIds = deRoleNames.stream() + .filter(deRoleName -> !roleNameSet.contains(deRoleName)) + .map(crmRoleName -> roleMap.get(crmRoleName).getId()) + .toList(); + updateRoleIds.addAll(deRoleIds); + userUpdateRequest.setRoleIds(updateRoleIds); + } else { + // 设置为DE原值 + List roleIds = roleItems.stream().map(OptionDTO::getId).toList(); + userUpdateRequest.setRoleIds(roleIds); + } + + List variables = new ArrayList<>(); + Map userVariablePairs = extractUserVariablePairs(deUser.getSysVariable()); + UserVariableTempDTO userVariableTempDTO = getUserVariableTempDTO(userCrmRoles, customDeptRoleDeptMap, + tree, rolePermissionMap, crmUser); + + for (DataScopeVariable value : DataScopeVariable.values()) { + String dataScopeValue = userVariablePairs.get(value.name()); + String crmDataScope = userVariableTempDTO.getScopeValueMap().get(value.name()); + if (!Strings.CS.equals(dataScopeValue, crmDataScope)) { + // 如果DE和CRM的数据权限不一致,则更新 + needUpdate = true; + } + // 记录待更新的用户变量 + UserCreateRequest.Variable variable = getCreateVariable(deTempResourceDTO.getSysVariableMap(), value.name(), deTempResourceDTO.getVariableValueMap(), crmDataScope); + variables.add(variable); + } + + for (DataScopeDeptVariable value : DataScopeDeptVariable.values()) { + String dataScopeDeptValue = userVariablePairs.get(value.name()); + List deDeptIds = List.of(); + if (StringUtils.isNotBlank(dataScopeDeptValue)) { + deDeptIds = stream(dataScopeDeptValue.trim().split(",")).toList(); + } + List crmDeptIds = Optional.ofNullable(userVariableTempDTO.getUserDeptIdMap().get(value.name())) + .orElse(List.of()); + if (!crmDeptIds.equals(deDeptIds)) { + needUpdate = true; + } + if (CollectionUtils.isNotEmpty(crmDeptIds)) { + // 记录待更新的用户变量 + UserCreateRequest.Variable variable = getCreateVariable(sysVariableMap, value.name(), variableValueMap, crmDeptIds); + variables.add(variable); + } + } + + if (!deUser.getEnable().equals(crmUser.getEnable())) { + needUpdate = true; + userUpdateRequest.setEnable(crmUser.getEnable()); + } else { + // 设置为DE原值 + userUpdateRequest.setEnable(deUser.getEnable()); + } + + if (needUpdate) { + userUpdateRequest.setVariables(variables); + try { + if (CollectionUtils.isEmpty(userUpdateRequest.getRoleIds())) { + // 没有角色则删除 + dataEaseClient.deleteUser(userUpdateRequest.getId()); + } else { + dataEaseClient.editUser(userUpdateRequest); + } + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + } + + private void addUsers(DeTempResourceDTO deTempResourceDTO) { + List enableUsers = deTempResourceDTO.getCrmUsers() + .stream().filter(UserDTO::getEnable) + .toList(); + Map> customDeptRoleDeptMap = deTempResourceDTO.getCustomDeptRoleDeptMap(); + Map> userRoleMap = deTempResourceDTO.getCrmUserRoleMap(); + List tree = deTempResourceDTO.getDeptTree(); + Map crmRoleMap = deTempResourceDTO.getCrmRoleMap(); + Map> rolePermissionMap = deTempResourceDTO.getRolePermissionMap(); + DataEaseClient dataEaseClient = deTempResourceDTO.getDataEaseClient(); + Map deRoleMap = deTempResourceDTO.getDeRoleMap(); + + enableUsers.stream() + .filter(crmUser -> crmUser.getEnable() && !deTempResourceDTO.getDeUserIds().contains(crmUser.getId())) + .forEach(crmUser -> { + if (StringUtils.isBlank(crmUser.getEmail())) { + log.error("同步用户到 DataEase 失败,用户[ {} ]邮箱为空", crmUser.getName()); + return; + } + List userCrmRoles = getUserCrmRoles(userRoleMap, crmRoleMap, crmUser.getId()); + UserVariableTempDTO userVariableTempDTO = getUserVariableTempDTO(userCrmRoles, customDeptRoleDeptMap, + tree, rolePermissionMap, crmUser); + + UserCreateRequest userCreateRequest = new UserCreateRequest(); + userCreateRequest.setName(crmUser.getName()); + userCreateRequest.setEmail(crmUser.getEmail()); + userCreateRequest.setAccount(crmUser.getId()); + List createRoleIds = userCrmRoles.stream() + .filter(role -> deRoleMap.containsKey(role.getName())) + .map(role -> deRoleMap.get(role.getName()).getId()) + .collect(Collectors.toList()); + userCreateRequest.setRoleIds(createRoleIds); + userCreateRequest.setVariables(getCreateVariables(deTempResourceDTO, userVariableTempDTO)); + try { + if (CollectionUtils.isNotEmpty(createRoleIds)) { + dataEaseClient.createUser(userCreateRequest); + } + } catch (Exception e) { + log.error(e.getMessage(), e); + } + }); + } + + private List getCreateVariables(DeTempResourceDTO deTempResourceDTO, UserVariableTempDTO userVariableTempDTO) { + List variables = new ArrayList<>(); + Map sysVariableMap = deTempResourceDTO.getSysVariableMap(); + Map> variableValueMap = deTempResourceDTO.getVariableValueMap(); + for (int i = 0; i < DataScopeVariable.values().length; i++) { + DataScopeVariable value = DataScopeVariable.values()[i]; + String crmDataScope = userVariableTempDTO.getScopeValueMap().get(value.name()); + UserCreateRequest.Variable variable = getCreateVariable(sysVariableMap, value.name(), variableValueMap, crmDataScope); + variables.add(variable); + } + + for (DataScopeDeptVariable value : DataScopeDeptVariable.values()) { + List crmDeptIds = Optional.ofNullable(userVariableTempDTO.getUserDeptIdMap().get(value.name())) + .orElse(List.of()); + + if (CollectionUtils.isNotEmpty(crmDeptIds)) { + UserCreateRequest.Variable variable = getCreateVariable(sysVariableMap, value.name(), variableValueMap, crmDeptIds); + variables.add(variable); + } + } + return variables; + } + + private Map> getRolePermissionMap(List roleIds) { + Map> rolePermissionMap = new HashMap<>(); + List rolePermissions = roleService.getRolePermissionByRoleIds(roleIds); + for (RolePermission rolePermission : rolePermissions) { + rolePermissionMap.putIfAbsent(rolePermission.getRoleId(), new HashSet<>()); + rolePermissionMap.get(rolePermission.getRoleId()).add(rolePermission.getPermissionId()); + } + return rolePermissionMap; + } + + private Map> getCustomDeptRoleDeptMap(List crmRoles) { + // 获取自定义部门角色ID + List customDeptRolesIds = crmRoles.stream() + .filter(role -> Strings.CS.equalsAny(role.getDataScope(), RoleDataScope.DEPT_CUSTOM.name())) + .map(RoleListResponse::getId) + .collect(Collectors.toList()); + + List roleScopeDeptList = roleService.getRoleScopeDeptByRoleIds(customDeptRolesIds); + Map> customDeptRoleDeptMap = new HashMap<>(); + roleScopeDeptList.forEach(roleScopeDept -> { + customDeptRoleDeptMap.putIfAbsent(roleScopeDept.getRoleId(), new ArrayList<>()); + customDeptRoleDeptMap.get(roleScopeDept.getRoleId()).add(roleScopeDept.getDepartmentId()); + }); + return customDeptRoleDeptMap; + } + + private UserCreateRequest.Variable getCreateVariable(Map sysVariableMap, String variableName, + Map> variableValueMap, String variableValueName) { + SysVariableDTO sysVariable = sysVariableMap.get(variableName); + String variableId = sysVariable.getId(); + UserCreateRequest.Variable variable = new UserCreateRequest.Variable(); + variable.setVariableId(variableId); + Map nameValueMap = variableValueMap.get(variableId); + String variableValueId = nameValueMap.get(variableValueName); + variable.setVariableValueId(variableValueId); + variable.setVariableValueIds(List.of(variableValueId)); + variable.setSysVariableDto(sysVariable); + return variable; + } + + private UserCreateRequest.Variable getCreateVariable(Map sysVariableMap, String variableName, + Map> variableValueMap, List variableValueNames) { + SysVariableDTO sysVariable = sysVariableMap.get(variableName); + String variableId = sysVariable.getId(); + UserCreateRequest.Variable variable = new UserCreateRequest.Variable(); + variable.setVariableId(variableId); + Map nameValueMap = variableValueMap.get(variableId); + List variableValueIds = variableValueNames.stream().map(nameValueMap::get).toList(); + variable.setVariableValueIds(variableValueIds); + variable.setSysVariableDto(sysVariable); + return variable; + } + + private UserVariableTempDTO getUserVariableTempDTO(List userCrmRoles, + Map> customDeptRoleDeptMap, + List tree, + Map> rolePermissionMap, + UserDTO orgUser) { + UserVariableTempDTO userVariableTempDTO = new UserVariableTempDTO(); + + for (DataScopeVariable value : DataScopeVariable.values()) { + // 获取有对应的权限的角色 + List permissionRoles = userCrmRoles.stream() + .filter(role -> rolePermissionMap.get(role.getId()) != null + && rolePermissionMap.get(role.getId()).contains(value.getPermission())) + .toList(); + + // 获取用户的DataScope + String crmDataScope = getCrmDataScope(permissionRoles); + // 记录用户的数据权限 + userVariableTempDTO.getScopeValueMap().put(value.name(), crmDataScope); + + if (Strings.CS.equals(crmDataScope, RoleDataScope.DEPT_CUSTOM.name())) { + List crmDataScopeDeptIds = getCrmDataScopeDeptIds(tree, permissionRoles, + customDeptRoleDeptMap, orgUser); + // 记录用户有权限的部门ID + userVariableTempDTO.getUserDeptIdMap().put(value.getDataScopeDeptVariable().name(), crmDataScopeDeptIds); + } + } + return userVariableTempDTO; + } + + /** + * 获取当前用户的角色 + * + * @param userRoleMap + * @param crmRoleMap + * @param userId + * + * @return + */ + private List getUserCrmRoles(Map> userRoleMap, Map crmRoleMap, String userId) { + if (userRoleMap.get(userId) == null) { + return List.of(); + } + return userRoleMap.get(userId) + .stream() + .filter(userRole -> crmRoleMap.containsKey(userRole.getRoleId())) + .map(userRole -> crmRoleMap.get(userRole.getRoleId())) + .collect(Collectors.toList()); + } + + private String getCrmDataScope(List userCrmRoles) { + String dataScope = NONE_DATA_SCOPE; + for (RoleListResponse userCrmRole : userCrmRoles) { + if (Strings.CS.equals(userCrmRole.getDataScope(), RoleDataScope.ALL.name())) { + return RoleDataScope.ALL.name(); + } else if (Strings.CS.equalsAny(userCrmRole.getDataScope(), RoleDataScope.DEPT_CUSTOM.name(), RoleDataScope.DEPT_AND_CHILD.name())) { + dataScope = RoleDataScope.DEPT_CUSTOM.name(); + } else { + dataScope = RoleDataScope.SELF.name(); + } + } + return dataScope; + } + + private List getCrmDataScopeDeptIds(List tree, List userCrmRoles, + Map> customDeptRoleDeptMap, UserDTO orgUser) { + List parentDeptIds = new ArrayList<>(); + for (RoleListResponse userCrmRole : userCrmRoles) { + if (Strings.CS.equals(userCrmRole.getDataScope(), RoleDataScope.DEPT_CUSTOM.name())) { + if (CollectionUtils.isNotEmpty(customDeptRoleDeptMap.get(userCrmRole.getId()))) { + // 获取指定部门角色中的部门Id + List customDeptIds = customDeptRoleDeptMap.get(userCrmRole.getId()); + parentDeptIds.addAll(customDeptIds); + } + } else if (Strings.CS.equals(userCrmRole.getDataScope(), RoleDataScope.DEPT_AND_CHILD.name())) { + String departmentId = orgUser.getDepartmentId(); + if (StringUtils.isNotBlank(departmentId)) { + // 获取当前部门ID + parentDeptIds.add(departmentId); + } + } + } + // 获取部门及子部门ID + return dataScopeService.getDeptIdsWithChild(tree, new HashSet<>(parentDeptIds)); + } + + public Map extractUserVariablePairs(String input) { + if (StringUtils.isBlank(input)) { + return Map.of(); + } + Map keyValuePairs = new HashMap<>(); + + // 正则表达式匹配 {key: value} 格式 + Pattern pattern = Pattern.compile("\\{(.*?)\\}"); + Matcher matcher = pattern.matcher(input); + + while (matcher.find()) { + String pair = matcher.group(1); + // 分割键值对 + String[] parts = pair.split(":", 2); + if (parts.length == 2) { + String key = parts[0].trim(); + String value = parts[1].trim(); + + // 处理值中的可能的分隔符 + if (value.endsWith(",")) { + value = value.substring(0, value.length() - 1); + } + + keyValuePairs.put(key, value); + } + } + + return keyValuePairs; + } + + private void syncRole(DeTempResourceDTO deTempResourceDTO) { + DataEaseClient dataEaseClient = deTempResourceDTO.getDataEaseClient(); + List crmRoles = deTempResourceDTO.getCrmRoles(); + List roles = dataEaseClient.listRole(); + Map roleMap = roles.stream() + .collect(Collectors.toMap(RoleDTO::getName, Function.identity())); + // 记录角色名和角色的映射 + deTempResourceDTO.setDeRoleMap(roleMap); + + RoleCreateRequest roleCreateRequest = new RoleCreateRequest(); + for (RoleListResponse crmRole : crmRoles) { + if (!roleMap.containsKey(crmRole.getName())) { + roleCreateRequest.setName(crmRole.getName()); + // 创建角色 + try { + Long roleId = dataEaseClient.createRole(roleCreateRequest); + RoleDTO roleDTO = new RoleDTO(); + roleDTO.setId(roleId.toString()); + roleDTO.setName(crmRole.getName()); + roleMap.put(roleDTO.getName(), roleDTO); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + } + } + + private void syncSysVariable(DeTempResourceDTO deTempResourceDTO) { + // 记录变量名和变量的映射 + deTempResourceDTO.setVariableValueMap(new HashMap<>()); + Map> variableValueMap = deTempResourceDTO.getVariableValueMap(); + + DataEaseClient dataEaseClient = deTempResourceDTO.getDataEaseClient(); + List sysVariables = dataEaseClient.listSysVariable(); + + Map sysVariableMap = sysVariables.stream() + .collect(Collectors.toMap(SysVariableDTO::getName, Function.identity())); + // 记录变量名和变量的映射 + deTempResourceDTO.setSysVariableMap(sysVariableMap); + + for (DataScopeVariable value : DataScopeVariable.values()) { + if (!sysVariableMap.containsKey(value.name())) { + // 创建数据权限系统变量 + SysVariableDTO dataScopeVariable = createDataScopeVariable(dataEaseClient, value.name(), variableValueMap); + sysVariableMap.put(value.name(), dataScopeVariable); + } else { + // 同步数据权限系统变量值 + SysVariableDTO sysVariable = sysVariableMap.get(value.name()); + Map valueMap = dataEaseClient.listSysVariableValue(sysVariable.getId()) + .stream().collect(Collectors.toMap(SysVariableValueDTO::getValue, Function.identity())); + + variableValueMap.putIfAbsent(sysVariable.getId(), new HashMap<>()); + Map variableValueIdNameMap = variableValueMap.get(sysVariable.getId()); + + valueMap.forEach((variableValueId, sysVariableValue) -> + variableValueIdNameMap.put(sysVariableValue.getValue(), sysVariableValue.getId())); + + List dataScopeValues = List.of(RoleDataScope.ALL.name(), + RoleDataScope.SELF.name(), + RoleDataScope.DEPT_CUSTOM.name(), NONE_DATA_SCOPE); + + for (String dataScopeValue : dataScopeValues) { + if (!valueMap.containsKey(dataScopeValue)) { + // 创建缺失的变量 + SysVariableValueCreateRequest variableValueCreateRequest = new SysVariableValueCreateRequest(); + variableValueCreateRequest.setSysVariableId(sysVariable.getId()); + variableValueCreateRequest.setValue(dataScopeValue); + try { + SysVariableValueDTO sysVariableValue = dataEaseClient.createSysVariableValue(variableValueCreateRequest); + variableValueIdNameMap.put(sysVariableValue.getValue(), sysVariableValue.getId()); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + } + } + } + + List deptIds = extDepartmentMapper.selectAllDepartmentIds(deTempResourceDTO.getCrmOrgId()); + Set deptIdSet = new HashSet<>(deptIds); + for (DataScopeDeptVariable value : DataScopeDeptVariable.values()) { + if (!sysVariableMap.containsKey(value.name())) { + // 创建数据权限部门变量 + try { + SysVariableDTO dataScopeDeptVariable = createDataScopeDeptVariable(dataEaseClient, value.name(), deptIds, variableValueMap); + sysVariableMap.put(value.name(), dataScopeDeptVariable); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } else { + // 同步部门 + SysVariableDTO sysVariable = sysVariableMap.get(value.name()); + Map valueMap = dataEaseClient.listSysVariableValue(sysVariable.getId()) + .stream().collect(Collectors.toMap(SysVariableValueDTO::getValue, Function.identity())); + + variableValueMap.putIfAbsent(sysVariable.getId(), new HashMap<>()); + Map variableValueIdNameMap = variableValueMap.get(sysVariable.getId()); + valueMap.forEach((variableValueId, sysVariableValue) -> + variableValueIdNameMap.put(sysVariableValue.getValue(), sysVariableValue.getId())); + + // 取 deptIds 和 valueMap.key() 的差集 + List addValues = deptIds.stream() + .filter(deptId -> !valueMap.containsKey(deptId)) + .toList(); + + SysVariableValueCreateRequest variableValueCreateRequest = new SysVariableValueCreateRequest(); + variableValueCreateRequest.setSysVariableId(sysVariable.getId()); + for (String addValue : addValues) { + variableValueCreateRequest.setValue(addValue); + try { + SysVariableValueDTO sysVariableValue = dataEaseClient.createSysVariableValue(variableValueCreateRequest); + variableValueIdNameMap.put(sysVariableValue.getValue(), sysVariableValue.getId()); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + + // 取 valueMap.key() 和 deptIds 的差集 + List deleteValueIds = valueMap.keySet().stream() + .filter(deDeptId -> !deptIdSet.contains(deDeptId)) + .map(key -> valueMap.get(key).getId()) + .collect(Collectors.toList()); + if (CollectionUtils.isNotEmpty(deleteValueIds)) { + dataEaseClient.batchDelSysVariableValue(deleteValueIds); + } + } + } + } + + private SysVariableDTO createDataScopeVariable(DataEaseClient dataEaseClient, String sysVariableName, + Map> variableValueMap) { + SysVariableCreateRequest sysVariableCreateRequest = new SysVariableCreateRequest(); + sysVariableCreateRequest.setName(sysVariableName); + SysVariableDTO sysVariable = dataEaseClient.createSysVariable(sysVariableCreateRequest); + + variableValueMap.putIfAbsent(sysVariable.getId(), new HashMap<>()); + Map variableValueIdNameMap = variableValueMap.get(sysVariable.getId()); + + List dataScopeValues = List.of(RoleDataScope.ALL.name(), + RoleDataScope.SELF.name(), + RoleDataScope.DEPT_CUSTOM.name(), NONE_DATA_SCOPE); + + + SysVariableValueCreateRequest sysVariableValueCreateRequest = new SysVariableValueCreateRequest(); + sysVariableValueCreateRequest.setSysVariableId(sysVariable.getId()); + for (String dataScopeValue : dataScopeValues) { + sysVariableValueCreateRequest.setValue(dataScopeValue); + try { + SysVariableValueDTO sysVariableValue = dataEaseClient.createSysVariableValue(sysVariableValueCreateRequest); + variableValueIdNameMap.put(sysVariableValue.getValue(), sysVariableValue.getId()); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + return sysVariable; + } + + private SysVariableDTO createDataScopeDeptVariable(DataEaseClient dataEaseClient, String sysVariableName, + List deptIds, Map> variableValueMap) { + SysVariableCreateRequest sysVariableCreateRequest = new SysVariableCreateRequest(); + sysVariableCreateRequest.setName(sysVariableName); + sysVariableCreateRequest.setDesc("CRM生成,请勿修改!"); + SysVariableDTO sysVariable = dataEaseClient.createSysVariable(sysVariableCreateRequest); + + variableValueMap.putIfAbsent(sysVariable.getId(), new HashMap<>()); + Map variableValueIdNameMap = variableValueMap.get(sysVariable.getId()); + + SysVariableValueCreateRequest sysVariableValueCreateRequest = new SysVariableValueCreateRequest(); + for (String deptId : deptIds) { + sysVariableValueCreateRequest.setSysVariableId(sysVariable.getId()); + sysVariableValueCreateRequest.setValue(deptId); + try { + SysVariableValueDTO sysVariableValue = dataEaseClient.createSysVariableValue(sysVariableValueCreateRequest); + variableValueIdNameMap.put(sysVariableValue.getValue(), sysVariableValue.getId()); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + return sysVariable; + } + + public List getDeOrgList(DeThirdConfigRequest deThirdConfigRequest) { + try { + DataEaseClient dataEaseClient = new DataEaseClient(deThirdConfigRequest); + return dataEaseClient.listOrg(); + } catch (Exception e) { + log.error(e.getMessage(), e); + return List.of(); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/constant/DingTalkApiPaths.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/constant/DingTalkApiPaths.java new file mode 100644 index 0000000..5e70892 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/constant/DingTalkApiPaths.java @@ -0,0 +1,72 @@ +package cn.cordys.crm.integration.dingtalk.constant; + +public interface DingTalkApiPaths { + + String DING_TALK_GET_TOKEN = "https://api.dingtalk.com/v1.0/oauth2/accessToken"; + + String DING_USER_INFO = "https://api.dingtalk.com/v1.0/contact/users/me"; + + String DING_USER_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken"; + + /** + * 根据unionid获取userid + * POST + * https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=ACCESS_TOKEN + */ + String DING_USERID_BY_UNIONID = "https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token={0}"; + + + /** + * 调用本接口,获取企业部门下的所有直属子部门列表。调用本接口获取根部门的子部门ID列表,获取的结果是[部门1Id,部门2Id],部门1和部门2下的子部门是获取不到的。 + * POST + * body { + * "dept_id":1 部门ID,根部门ID为1。 + * } + */ + String DING_DEPARTMENT_IDS = "https://oapi.dingtalk.com/topapi/v2/department/listsubid?access_token={0}"; + + + /** + * POST + * body { + * "language":"zh_CN", + * "dept_id":1 部门ID,根部门ID为1。 + * } + */ + String DING_DEPARTMENT_DETAIL = "https://oapi.dingtalk.com/topapi/v2/department/get?access_token={0}"; + + + /** + * 本接口只支持获取指定部门下的员工详情信息,子部门员工信息获取不到。 + * POST + * body { + * "dept_id":1 部门ID,如果是根部门,该参数传1 + * "cursor":"", 分页游标,第一次调用时,cursor填空字符串。 + * "size":100 分页大小,最大100。 + * } + */ + String DING_DEPARTMENT_USER_DETAIL_LIST = "https://oapi.dingtalk.com/topapi/v2/user/list?access_token={0}"; + + + /** + * 发送工作通知消息 + * 接口文档: https://developers.dingtalk.com/document/app/send-work-notification + *
+     *     https请求方式: POST https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2?access_token=ACCESS_TOKEN
+     *     access_token	是	调用接口凭证,由钉钉开放平台或企业自建应用后台生成
+     *     {
+     *         "msg":{
+     *                 "text":{
+     *                         "content":"123"
+     *                 },
+     *                 "msgtype":"text"
+     *         },
+     *         "to_all_user":"false",
+     *         "agent_id":"123",
+     *         "userid_list":"123,456"
+     * }
+     */
+    String DING_NOTICE_URL = "https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2?access_token={0}";
+
+
+}
\ No newline at end of file
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkBaseParamDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkBaseParamDTO.java
new file mode 100644
index 0000000..4bb2db2
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkBaseParamDTO.java
@@ -0,0 +1,18 @@
+package cn.cordys.crm.integration.dingtalk.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Schema(description = "钉钉基础信息")
+@Data
+public class DingTalkBaseParamDTO {
+    @Schema(description = "应用key", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String clientId;
+    @Schema(description = "应用密钥", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String clientSecret;
+
+    @Schema(description = "应用key", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String appKey;
+    @Schema(description = "应用密钥", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String appSecret;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkDepartment.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkDepartment.java
new file mode 100644
index 0000000..8afb488
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkDepartment.java
@@ -0,0 +1,79 @@
+package cn.cordys.crm.integration.dingtalk.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class DingTalkDepartment {
+    /**
+     * 部门id
+     */
+    @JsonProperty("dept_id")
+    private Long deptId;
+
+    /**
+     * 部门名称
+     */
+    private String name;
+
+    /**
+     * 父部门id。根部门为1
+     */
+    @JsonProperty("parent_id")
+    private Long parentId;
+
+    /**
+     * 在父部门中的次序值。order值大的排序靠前。值范围是[0, 2^32)
+     */
+    private Long order;
+
+    /**
+     * 部门群ID
+     */
+    @JsonProperty("dept_group_chat_id")
+    private String deptGroupChatId;
+
+    /**
+     * 是否隐藏本部门
+     */
+    @JsonProperty("hide_dept")
+    private Boolean hideDept;
+
+    /**
+     * 部门的主管列表
+     */
+    @JsonProperty("dept_manager_userid_list")
+    private List deptManagerUseridList;
+
+    /**
+     * 部门的负责人
+     */
+    @JsonProperty("org_dept_owner")
+    private String orgDeptOwner;
+
+    /**
+     * 当群已经创建后,是否有新人加入部门会自动加入该群
+     */
+    @JsonProperty("auto_add_user")
+    private Boolean autoAddUser;
+
+    /**
+     * 是否开启部门群
+     */
+    @JsonProperty("create_dept_group")
+    private Boolean createDeptGroup;
+
+    /**
+     * 是否是外部部门
+     */
+    @JsonProperty("outer_dept")
+    private Boolean outerDept;
+
+    /**
+     * 部门是否包含子部门
+     */
+    @JsonProperty("group_contain_sub_dept")
+    private Boolean groupContainSubDept;
+}
\ No newline at end of file
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkMsgDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkMsgDTO.java
new file mode 100644
index 0000000..ad7d88b
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkMsgDTO.java
@@ -0,0 +1,9 @@
+package cn.cordys.crm.integration.dingtalk.dto;
+
+import lombok.Data;
+
+@Data
+public class DingTalkMsgDTO {
+    private DingTalkTextDTO text;
+    private String msgtype;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkSendDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkSendDTO.java
new file mode 100644
index 0000000..b580fc6
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkSendDTO.java
@@ -0,0 +1,12 @@
+package cn.cordys.crm.integration.dingtalk.dto;
+
+import lombok.Data;
+
+@Data
+public class DingTalkSendDTO {
+
+    private String agent_id;
+    private String userid_list;
+    private String to_all_user;
+    private DingTalkMsgDTO msg;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkTextDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkTextDTO.java
new file mode 100644
index 0000000..3c3ac64
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkTextDTO.java
@@ -0,0 +1,12 @@
+package cn.cordys.crm.integration.dingtalk.dto;
+
+import lombok.Data;
+
+@Data
+public class DingTalkTextDTO {
+    private String content;
+
+    public DingTalkTextDTO(String content) {
+        this.content = content;
+    }
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkToken.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkToken.java
new file mode 100644
index 0000000..6d3c33d
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkToken.java
@@ -0,0 +1,20 @@
+package cn.cordys.crm.integration.dingtalk.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class DingTalkToken {
+    /**
+     * 生成的accessToken。
+     */
+    @JsonProperty("accessToken")
+    private String accessToken;
+
+    /**
+     * accessToken的过期时间,单位秒。
+     */
+    @JsonProperty("expireIn")
+    private String expireIn;
+
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkTokenParamDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkTokenParamDTO.java
new file mode 100644
index 0000000..d088037
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkTokenParamDTO.java
@@ -0,0 +1,22 @@
+package cn.cordys.crm.integration.dingtalk.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Schema(description = "钉钉")
+@Data
+public class DingTalkTokenParamDTO extends DingTalkBaseParamDTO {
+    @Schema(description = "accessToken")
+    public String accessToken;
+    @Schema(description = "expireIn")
+    public Long expireIn;
+    @Schema(description = "clientId")
+    public String clientId;
+    @Schema(description = "clientSecret")
+    public String clientSecret;
+    @Schema(description = "code")
+    public String code;
+    @Schema(description = "grantType")
+    public String grantType;
+
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkUser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkUser.java
new file mode 100644
index 0000000..bccb641
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/dto/DingTalkUser.java
@@ -0,0 +1,138 @@
+package cn.cordys.crm.integration.dingtalk.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 钉钉用户 DTO
+ */
+@Data
+public class DingTalkUser {
+
+    /**
+     * 员工的userid
+     */
+    @JsonProperty("userid")
+    private String userId;
+
+    /**
+     * 员工在当前开发者企业账号范围内的唯一标识
+     */
+    @JsonProperty("unionid")
+    private String unionId;
+
+    /**
+     * 员工名称
+     */
+    private String name;
+
+    /**
+     * 头像
+     */
+    private String avatar;
+
+    /**
+     * 国际电话区号
+     */
+    @JsonProperty("state_code")
+    private String stateCode;
+
+    /**
+     * 手机号码
+     */
+    private String mobile;
+
+    /**
+     * 是否号码隐藏
+     */
+    @JsonProperty("hide_mobile")
+    private Boolean hideMobile;
+
+    /**
+     * 分机号
+     */
+    private String telephone;
+
+    /**
+     * 员工工号
+     */
+    @JsonProperty("job_number")
+    private String jobNumber;
+
+    /**
+     * 职位
+     */
+    private String title;
+
+    /**
+     * 员工邮箱
+     */
+    private String email;
+
+    /**
+     * 办公地点
+     */
+    @JsonProperty("work_place")
+    private String workPlace;
+
+    /**
+     * 备注
+     */
+    private String remark;
+
+    /**
+     * 所属部门id列表
+     */
+    @JsonProperty("dept_id_list")
+    private List deptIdList;
+
+    /**
+     * 员工在部门中的排序
+     */
+    @JsonProperty("dept_order")
+    private Long deptOrder;
+
+    /**
+     * 扩展属性
+     */
+    private String extension;
+
+    /**
+     * 入职时间
+     */
+    @JsonProperty("hired_date")
+    private Long hiredDate;
+
+    /**
+     * 是否激活
+     */
+    private Boolean active;
+
+    /**
+     * 是否实名认证
+     */
+    @JsonProperty("real_authed")
+    private Boolean realAuthed;
+
+    /**
+     * 是否高管
+     */
+    private Boolean senior;
+
+    /**
+     * 是否管理员
+     */
+    private Boolean admin;
+
+    /**
+     * 是否老板
+     */
+    private Boolean boss;
+
+    /**
+     * 是否是部门主管
+     */
+    private Boolean leader;
+}
\ No newline at end of file
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/DepartmentDetailsResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/DepartmentDetailsResponse.java
new file mode 100644
index 0000000..7658f78
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/DepartmentDetailsResponse.java
@@ -0,0 +1,9 @@
+package cn.cordys.crm.integration.dingtalk.response;
+
+import cn.cordys.crm.integration.dingtalk.dto.DingTalkDepartment;
+import lombok.Data;
+
+@Data
+public class DepartmentDetailsResponse extends DingTalkResponseEntity {
+    private DingTalkDepartment result;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/DingTalkOrgDataResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/DingTalkOrgDataResponse.java
new file mode 100644
index 0000000..60b25e0
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/DingTalkOrgDataResponse.java
@@ -0,0 +1,25 @@
+package cn.cordys.crm.integration.dingtalk.response;
+
+import cn.cordys.crm.integration.dingtalk.dto.DingTalkDepartment;
+import cn.cordys.crm.integration.dingtalk.dto.DingTalkUser;
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 钉钉组织架构和用户数据响应
+ */
+@Data
+public class DingTalkOrgDataResponse {
+
+    /**
+     * 部门列表
+     */
+    private List departments;
+
+    /**
+     * 用户列表
+     */
+    private Map> users;
+}
\ No newline at end of file
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/DingTalkResponseEntity.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/DingTalkResponseEntity.java
new file mode 100644
index 0000000..b27cd96
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/DingTalkResponseEntity.java
@@ -0,0 +1,21 @@
+package cn.cordys.crm.integration.dingtalk.response;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class DingTalkResponseEntity {
+
+    /**
+     * 错误码
+     */
+    @JsonProperty("errcode")
+    private Integer errCode;
+
+    /**
+     * 错误消息
+     */
+    @JsonProperty("errmsg")
+    private String errMsg;
+
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/DingTalkUserListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/DingTalkUserListResponse.java
new file mode 100644
index 0000000..693bc6f
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/DingTalkUserListResponse.java
@@ -0,0 +1,19 @@
+package cn.cordys.crm.integration.dingtalk.response;
+
+import cn.cordys.crm.integration.dingtalk.dto.DingTalkUser;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 钉钉用户列表响应
+ */
+@Data
+public class DingTalkUserListResponse {
+
+    @JsonProperty("request_id")
+    private String requestId;
+
+    private List result;
+}
\ No newline at end of file
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/DingTalkUserResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/DingTalkUserResponse.java
new file mode 100644
index 0000000..a4c421c
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/DingTalkUserResponse.java
@@ -0,0 +1,47 @@
+package cn.cordys.crm.integration.dingtalk.response;
+
+import lombok.Data;
+
+@Data
+public class DingTalkUserResponse {
+
+    /**
+     * 用户的钉钉昵称。
+     */
+    private String nick;
+
+    /**
+     * 头像URL。。
+     */
+    private String avatarUrl;
+
+    /**
+     * 用户的手机号
+     */
+    private String mobile;
+
+
+    /**
+     * 用户的openId
+     */
+    private String openId;
+
+
+    /**
+     * 用户的unionId
+     */
+    private String unionId;
+
+    /**
+     * 用户的个人邮箱
+     */
+    private String email;
+
+
+    /**
+     * 手机号对应的国家号
+     */
+    private String stateCode;
+
+
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/SubDeptIdListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/SubDeptIdListResponse.java
new file mode 100644
index 0000000..73d5d27
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/SubDeptIdListResponse.java
@@ -0,0 +1,9 @@
+package cn.cordys.crm.integration.dingtalk.response;
+
+import lombok.Data;
+
+@Data
+public class SubDeptIdListResponse extends DingTalkResponseEntity {
+
+    private SubDeptIdListResult result;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/SubDeptIdListResult.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/SubDeptIdListResult.java
new file mode 100644
index 0000000..8f3f1f6
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/SubDeptIdListResult.java
@@ -0,0 +1,12 @@
+package cn.cordys.crm.integration.dingtalk.response;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class SubDeptIdListResult {
+    @JsonProperty("dept_id_list")
+    private List deptIdList;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/UserListPage.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/UserListPage.java
new file mode 100644
index 0000000..97d6b7d
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/UserListPage.java
@@ -0,0 +1,19 @@
+package cn.cordys.crm.integration.dingtalk.response;
+
+import cn.cordys.crm.integration.dingtalk.dto.DingTalkUser;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 钉钉用户列表分页
+ */
+@Data
+public class UserListPage {
+    @JsonProperty("has_more")
+    private Boolean hasMore;
+    @JsonProperty("next_cursor")
+    private Long nextCursor;
+    private List list;
+}
\ No newline at end of file
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/UserListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/UserListResponse.java
new file mode 100644
index 0000000..3e24313
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/response/UserListResponse.java
@@ -0,0 +1,13 @@
+package cn.cordys.crm.integration.dingtalk.response;
+
+import lombok.Data;
+
+/**
+ * 钉钉用户列表响应
+ */
+@Data
+public class UserListResponse {
+    private Integer errcode;
+    private String errmsg;
+    private UserListPage result;
+}
\ No newline at end of file
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/service/DingTalkDepartmentService.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/service/DingTalkDepartmentService.java
new file mode 100644
index 0000000..313593f
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/service/DingTalkDepartmentService.java
@@ -0,0 +1,319 @@
+package cn.cordys.crm.integration.dingtalk.service;
+
+import cn.cordys.common.util.JSON;
+import cn.cordys.crm.integration.common.utils.HttpClientUtils;
+import cn.cordys.crm.integration.dingtalk.constant.DingTalkApiPaths;
+import cn.cordys.crm.integration.dingtalk.dto.DingTalkDepartment;
+import cn.cordys.crm.integration.dingtalk.dto.DingTalkUser;
+import cn.cordys.crm.integration.dingtalk.response.DepartmentDetailsResponse;
+import cn.cordys.crm.integration.dingtalk.response.DingTalkOrgDataResponse;
+import cn.cordys.crm.integration.dingtalk.response.SubDeptIdListResponse;
+import cn.cordys.crm.integration.dingtalk.response.UserListResponse;
+import cn.cordys.crm.integration.sync.dto.ThirdDepartment;
+import cn.cordys.crm.integration.sync.dto.ThirdOrgDataDTO;
+import cn.cordys.crm.integration.sync.dto.ThirdUser;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@Service
+@Slf4j
+public class DingTalkDepartmentService {
+
+    // 钉钉API限流配置
+    private static final int DINGTALK_API_RATE_LIMIT = 20; // 每秒最多20次请求
+    private static final long MIN_REQUEST_INTERVAL = 1000L / DINGTALK_API_RATE_LIMIT; // 最小请求间隔(毫秒)
+
+    // 请求计数器(用于监控)
+    private final AtomicInteger apiRequestCount = new AtomicInteger(0);
+
+
+    public ThirdOrgDataDTO convertToThirdOrgDataDTO(String accessToken) {
+        DingTalkOrgDataResponse response = getOrganizationAndUsers(accessToken);
+        ThirdOrgDataDTO thirdOrgDataDTO = new ThirdOrgDataDTO();
+        thirdOrgDataDTO.setDepartments(Optional.ofNullable(response.getDepartments())
+                .orElse(Collections.emptyList())
+                .stream()
+                .map(this::convertToThirdDepartment)
+                .toList());
+        Map> thirdUsersByDept = Optional.ofNullable(response.getUsers())
+                .orElse(Collections.emptyMap())
+                .entrySet()
+                .stream()
+                .collect(java.util.stream.Collectors.toMap(
+                        entry -> entry.getKey().toString(),
+                        entry -> entry.getValue().stream()
+                                .map(this::convertToThirdUser)
+                                .collect(java.util.stream.Collectors.toList())
+                ));
+        thirdOrgDataDTO.setUsers(thirdUsersByDept);
+
+        if (thirdOrgDataDTO.getDepartments().isEmpty()) {
+            log.info("钉钉组织数据为空,请重新同步");
+        }
+        return thirdOrgDataDTO;
+    }
+
+    private ThirdUser convertToThirdUser(DingTalkUser dingTalkUser) {
+        return ThirdUser.builder()
+                .userId(dingTalkUser.getUserId())
+                .name(dingTalkUser.getName())
+                .mobile(dingTalkUser.getMobile())
+                .email(dingTalkUser.getEmail())
+                .isLeaderInDept(dingTalkUser.getLeader())
+                .avatar(dingTalkUser.getAvatar())
+                .position(dingTalkUser.getTitle())
+                .gender(1)
+                .build();
+    }
+
+
+    private ThirdDepartment convertToThirdDepartment(DingTalkDepartment dingTalkDepartment) {
+        var thirdDepartment = new ThirdDepartment();
+
+        thirdDepartment.setId(String.valueOf(dingTalkDepartment.getDeptId()));
+        thirdDepartment.setName(dingTalkDepartment.getName());
+
+        // 判断根部门
+        var isRoot = dingTalkDepartment.getDeptId() == 1;
+        thirdDepartment.setIsRoot(isRoot);
+
+        // 根部门和普通部门的 parentId 不同处理
+        thirdDepartment.setParentId(isRoot ? "NONE" : String.valueOf(dingTalkDepartment.getParentId()));
+
+        // 排序
+        thirdDepartment.setOrder(dingTalkDepartment.getOrder());
+
+        return thirdDepartment;
+    }
+
+    /**
+     * 获取所有钉钉组织架构和用户
+     *
+     * @param accessToken 访问令牌
+     *
+     * @return 组织架构和用户数据响应
+     */
+    public DingTalkOrgDataResponse getOrganizationAndUsers(String accessToken) {
+        List allDepartmentIds = getAllSubDepartmentIds(accessToken); // 从根部门(ID=1)开始
+        DingTalkOrgDataResponse response = new DingTalkOrgDataResponse();
+
+        try {
+            List departments = new ArrayList<>();
+            Map> usersByDept = new HashMap<>();
+
+            log.info("开始处理{}个部门的数据同步", allDepartmentIds.size());
+
+            // 批量处理部门信息,添加限流控制
+            for (int i = 0; i < allDepartmentIds.size(); i++) {
+                Long deptId = allDepartmentIds.get(i);
+
+                // 每批次请求后添加延迟,避免触发限流
+                if (i > 0) {
+                    try {
+                        // 根据请求频率动态调整延迟
+                        long delay = calculateDelay(i);
+                        if (delay > 0) {
+                            log.info("添加延迟:{}毫秒", delay);
+                            TimeUnit.MILLISECONDS.sleep(delay);
+                        }
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                        break;
+                    }
+                }
+
+                log.info("正在处理部门ID: {} ({}/{})", deptId, i + 1, allDepartmentIds.size());
+
+                // 获取部门详情
+                getDepartmentDetail(accessToken, deptId).ifPresent(departments::add);
+
+                // 获取部门用户
+                List users = getUsersByDepartment(accessToken, deptId);
+                List filteredUsers = new ArrayList<>();
+                for (DingTalkUser user : users) {
+                    //用户dept_id_list 获取第一个部门ID进行匹配(主部门)
+                    if (user.getDeptIdList() != null && !user.getDeptIdList().isEmpty()
+                            && user.getDeptIdList().getFirst().equals(deptId)) {
+                        filteredUsers.add(user);
+                    }
+                }
+                usersByDept.put(deptId, filteredUsers);
+            }
+
+            response.setDepartments(departments);
+            response.setUsers(usersByDept);
+
+            log.info("数据同步完成,共处理{}个部门,API请求数: {}",
+                    allDepartmentIds.size(), apiRequestCount.get());
+        } catch (Exception e) {
+            log.error(e.getMessage(), e);
+        }
+        return response;
+    }
+
+
+    /**
+     * 使用队列方式获取所有子部门ID(避免递归,更好的限流控制)
+     *
+     * @param accessToken 访问令牌
+     *
+     * @return 部门ID列表
+     */
+    private List getAllSubDepartmentIds(String accessToken) {
+        List departmentIds = new ArrayList<>();
+        Queue deptQueue = new LinkedList<>();
+        deptQueue.offer(1L);
+
+        while (!deptQueue.isEmpty()) {
+            Long currentDeptId = deptQueue.poll();
+            departmentIds.add(currentDeptId);
+
+            String url = HttpClientUtils.urlTransfer(DingTalkApiPaths.DING_DEPARTMENT_IDS, accessToken);
+            String requestBody = JSON.toJSONString(Collections.singletonMap("dept_id", currentDeptId));
+
+            try {
+                // 限流控制
+                applyRateLimit();
+
+                String responseBody = HttpClientUtils.sendPostRequest(url, requestBody, null);
+                SubDeptIdListResponse response = JSON.parseObject(responseBody, SubDeptIdListResponse.class);
+
+                if (response != null && response.getErrCode() == 0 && response.getResult() != null) {
+                    List subDeptIds = response.getResult().getDeptIdList();
+                    log.info("部门{}包含{}个子部门", currentDeptId, subDeptIds.size());
+
+                    // 将子部门加入队列继续处理
+                    for (Long subDeptId : subDeptIds) {
+                        deptQueue.offer(subDeptId);
+                    }
+                } else {
+                    if (response != null) {
+                        log.error("获取部门{}的子部门ID失败,错误码:{},错误信息:{}",
+                                currentDeptId, response.getErrCode(), response.getErrMsg());
+                    } else {
+                        log.info("获取部门{}的子部门ID失败,响应为空", currentDeptId);
+                    }
+                }
+            } catch (InterruptedException e) {
+                log.error("获取部门{}的子部门ID失败", currentDeptId, e);
+                Thread.currentThread().interrupt();
+                break;
+            }
+        }
+        return departmentIds;
+    }
+
+
+    /**
+     * 应用API限流控制
+     */
+    private void applyRateLimit() throws InterruptedException {
+        TimeUnit.MILLISECONDS.sleep(MIN_REQUEST_INTERVAL);
+        apiRequestCount.incrementAndGet();
+    }
+
+    /**
+     * 根据请求进度动态计算延迟时间
+     *
+     * @param requestIndex 请求索引
+     *
+     * @return 延迟时间(毫秒)
+     */
+    private long calculateDelay(int requestIndex) {
+        // 每10个请求增加额外延迟,确保不会触发限流
+        if (requestIndex % 10 == 0) {
+            return 200; // 额外200ms延迟
+        }
+        return MIN_REQUEST_INTERVAL;
+    }
+
+
+    /**
+     * 获取单个部门详情
+     *
+     * @param accessToken 访问令牌
+     * @param deptId      部门ID
+     *
+     * @return 部门详情Optional
+     */
+    private Optional getDepartmentDetail(String accessToken, Long deptId) {
+        String url = HttpClientUtils.urlTransfer(DingTalkApiPaths.DING_DEPARTMENT_DETAIL, accessToken);
+        String requestBody = JSON.toJSONString(Map.of("dept_id", deptId, "language", "zh_CN"));
+        try {
+            String responseBody = HttpClientUtils.sendPostRequest(url, requestBody, null);
+            DepartmentDetailsResponse response = JSON.parseObject(responseBody, DepartmentDetailsResponse.class);
+
+            if (response != null && response.getErrCode() == 0 && response.getResult() != null) {
+                return Optional.of(response.getResult());
+            } else {
+                if (response == null) {
+                    log.info("获取部门详情失败,响应为空");
+                } else {
+                    log.error("获取部门详情失败,错误码:{},错误信息:{}", response.getErrCode(), response.getErrMsg());
+                }
+            }
+
+        } catch (Exception e) {
+            log.error("获取部门详情失败", e);
+            // 适当处理异常,例如记录日志
+            Thread.currentThread().interrupt();
+        }
+        return Optional.empty();
+    }
+
+
+    /**
+     * 获取单个部门下的用户列表(处理分页)
+     *
+     * @param accessToken 访问令牌
+     * @param deptId      部门ID
+     *
+     * @return 用户列表
+     */
+    private List getUsersByDepartment(String accessToken, Long deptId) {
+        List users = new ArrayList<>();
+        long cursor = 0L;
+        boolean hasMore;
+
+        String url = HttpClientUtils.urlTransfer(DingTalkApiPaths.DING_DEPARTMENT_USER_DETAIL_LIST, accessToken);
+
+        do {
+            Map requestBodyMap = Map.of(
+                    "dept_id", deptId,
+                    "cursor", cursor,
+                    "size", 100 // 最大100
+            );
+            String requestBody = JSON.toJSONString(requestBodyMap);
+
+            try {
+                String responseBody = HttpClientUtils.sendPostRequest(url, requestBody, null);
+                UserListResponse response = JSON.parseObject(responseBody, UserListResponse.class);
+
+                if (response != null && response.getErrcode() == 0 && response.getResult() != null) {
+                    users.addAll(response.getResult().getList());
+                    hasMore = response.getResult().getHasMore();
+                    if (hasMore && response.getResult().getNextCursor() != null) {
+                        cursor = response.getResult().getNextCursor();
+                    }
+                } else {
+                    hasMore = false;
+                    if (response == null) {
+                        log.info("获取部门用户失败,响应为空");
+                    } else {
+                        log.error("获取部门用户失败,错误码:{},错误信息:{}", response.getErrcode(), response.getErrmsg());
+                    }
+                }
+            } catch (Exception e) {
+                log.error("获取部门用户失败", e);
+                Thread.currentThread().interrupt();
+                hasMore = false;
+            }
+        } while (hasMore);
+
+        return users;
+    }
+}
\ No newline at end of file
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/service/DingTalkNoticeSender.java b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/service/DingTalkNoticeSender.java
new file mode 100644
index 0000000..00fb0a1
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/dingtalk/service/DingTalkNoticeSender.java
@@ -0,0 +1,106 @@
+package cn.cordys.crm.integration.dingtalk.service;
+
+import cn.cordys.common.constants.ThirdDetailType;
+import cn.cordys.common.util.JSON;
+import cn.cordys.crm.integration.common.dto.ThirdConfigBaseDTO;
+import cn.cordys.crm.integration.common.request.DingTalkThirdConfigRequest;
+import cn.cordys.crm.integration.dingtalk.dto.DingTalkMsgDTO;
+import cn.cordys.crm.integration.dingtalk.dto.DingTalkSendDTO;
+import cn.cordys.crm.integration.dingtalk.dto.DingTalkTextDTO;
+import cn.cordys.crm.integration.sso.service.TokenService;
+import cn.cordys.crm.system.constants.OrganizationConfigConstants;
+import cn.cordys.crm.system.domain.OrganizationConfig;
+import cn.cordys.crm.system.domain.OrganizationConfigDetail;
+import cn.cordys.crm.system.dto.MessageDetailDTO;
+import cn.cordys.crm.system.mapper.ExtOrganizationConfigDetailMapper;
+import cn.cordys.crm.system.mapper.ExtOrganizationConfigMapper;
+import cn.cordys.crm.system.notice.common.NoticeModel;
+import cn.cordys.crm.system.notice.common.Receiver;
+import cn.cordys.crm.system.notice.sender.AbstractNoticeSender;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections.CollectionUtils;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Optional;
+
+@Component
+@Slf4j
+public class DingTalkNoticeSender extends AbstractNoticeSender {
+
+    @Resource
+    private TokenService tokenService;
+    @Resource
+    private ExtOrganizationConfigMapper extOrganizationConfigMapper;
+    @Resource
+    private ExtOrganizationConfigDetailMapper extOrganizationConfigDetailMapper;
+
+    @Override
+    public void send(MessageDetailDTO messageDetailDTO, NoticeModel noticeModel) {
+        String context = super.getContext(messageDetailDTO, noticeModel);
+        try {
+            sendDingTalk(context, noticeModel, messageDetailDTO.getOrganizationId());
+            log.debug("发送钉钉消息结束");
+        } catch (Exception e) {
+            log.error("钉钉消息通知失败:{}", String.valueOf(e));
+        }
+    }
+
+    public void sendDingTalk(MessageDetailDTO clonedMessageDetail, NoticeModel clonedNoticeModel) {
+        this.send(clonedMessageDetail, clonedNoticeModel);
+    }
+
+    private void sendDingTalk(String context, NoticeModel noticeModel, String organizationId) {
+        List receivers = super.getReceivers(noticeModel.getReceivers(), noticeModel.isExcludeSelf(), noticeModel.getOperator());
+        if (CollectionUtils.isEmpty(receivers)) {
+            return;
+        }
+        List userIds = receivers.stream()
+                .map(Receiver::getUserId)
+                .distinct()
+                .toList();
+        //查询同步过的resourceId,这里已经确定是否同步了钉钉,没同步,消息无法发送
+        List resourceUserIds = super.getResourceUserIds(userIds, organizationId);
+        if (CollectionUtils.isEmpty(resourceUserIds)) {
+            log.warn("没有同步钉钉用户,无法发送消息");
+            return;
+        }
+        //查询三方配置
+        OrganizationConfig organizationConfig = extOrganizationConfigMapper
+                .getOrganizationConfig(organizationId, OrganizationConfigConstants.ConfigType.THIRD.name());
+        if (organizationConfig == null) {
+            log.warn("没有配置钉钉信息,无法发送消息");
+            return;
+        }
+        //获取企业微信通知的配置数据
+        OrganizationConfigDetail orgConfigDetailByIdAndType = extOrganizationConfigDetailMapper.getOrgConfigDetailByIdAndType(organizationConfig.getId(), ThirdDetailType.DINGTALK_SYNC.name());
+        if (orgConfigDetailByIdAndType == null || orgConfigDetailByIdAndType.getContent() == null) {
+            log.warn("没有配置钉钉通知信息,无法发送消息");
+            return;
+        }
+        var thirdConfigurationDTO = JSON.parseObject(
+                new String(orgConfigDetailByIdAndType.getContent()), ThirdConfigBaseDTO.class
+        );
+
+        DingTalkThirdConfigRequest dingTalkThirdConfigRequest =
+                Optional.ofNullable(thirdConfigurationDTO.getConfig())
+                        .map(config -> JSON.MAPPER.convertValue(config, DingTalkThirdConfigRequest.class))
+                        .orElseGet(() -> JSON.parseObject(
+                                new String(orgConfigDetailByIdAndType.getContent()),
+                                DingTalkThirdConfigRequest.class
+                        ));
+
+        //构建钉钉消息
+        DingTalkSendDTO dingTalkSendDTO = new DingTalkSendDTO();
+        String result = String.join(",", resourceUserIds);
+        dingTalkSendDTO.setUserid_list(result);
+        dingTalkSendDTO.setTo_all_user("false");
+        dingTalkSendDTO.setAgent_id(dingTalkThirdConfigRequest.getAppId());
+        dingTalkSendDTO.setMsg(new DingTalkMsgDTO());
+        dingTalkSendDTO.getMsg().setText(new DingTalkTextDTO(context));
+        dingTalkSendDTO.getMsg().setMsgtype("text");
+
+        tokenService.sendDingNoticeByToken(dingTalkSendDTO, dingTalkThirdConfigRequest.getAgentId(), dingTalkThirdConfigRequest.getAppSecret());
+    }
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/lark/constant/LarkApiPaths.java b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/constant/LarkApiPaths.java
new file mode 100644
index 0000000..cfe84b4
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/constant/LarkApiPaths.java
@@ -0,0 +1,41 @@
+package cn.cordys.crm.integration.lark.constant;
+
+public interface LarkApiPaths {
+
+    String LARK_APP_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal";
+    String LARK_USER_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token";
+    String LARK_USER_INFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info";
+
+
+    String LARK_SUITE_APP_TOKEN_URL = "https://open.larksuite.com/open-apis/auth/v3/app_access_token/internal";
+    String LARK_SUITE_USER_TOKEN_URL = "https://open.larksuite.com/open-apis/authen/v1/oidc/access_token";
+    String LARK_SUITE_USER_INFO_URL = "https://open.larksuite.com/open-apis/authen/v1/user_info";
+
+    /**
+     * 获取子部门列表
+     * GET
+     * https://open.feishu.cn/open-apis/contact/v3/departments/{department_id}/children
+     */
+    String LARK_CHILDREN_DEPARTMENT_URL = "https://open.feishu.cn/open-apis/contact/v3/departments/{0}/children";
+
+    /**
+     * 获取部门直属用户
+     * GET
+     * https://open.feishu.cn/open-apis/contact/v3/users/find_by_department
+     */
+    String LARK_DEPARTMENT_USERS_URL = "https://open.feishu.cn/open-apis/contact/v3/users/find_by_department";
+
+    /**
+     * 发送消息给用户
+     * POST
+     * https://open.feishu.cn/open-apis/im/v1/messages
+     */
+    String LARK_SEND_MESSAGE_URL = "https://open.feishu.cn/open-apis/im/v1/messages";
+
+    /**
+     * 获取企业信息
+     * GET
+     * https://open.feishu.cn/open-apis/tenant/v2/tenant/query
+     */
+    String LARK_TENANT_INFO_URL = "https://open.feishu.cn/open-apis/tenant/v2/tenant/query";
+}
\ No newline at end of file
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkBaseParamDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkBaseParamDTO.java
new file mode 100644
index 0000000..6cc926e
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkBaseParamDTO.java
@@ -0,0 +1,13 @@
+package cn.cordys.crm.integration.lark.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Schema(description = "飞书基础信息")
+@Data
+public class LarkBaseParamDTO {
+    @Schema(description = "应用ID", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String app_id;
+    @Schema(description = "应用密钥", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String app_secret;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkDepartment.java b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkDepartment.java
new file mode 100644
index 0000000..3b7fabc
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkDepartment.java
@@ -0,0 +1,62 @@
+package cn.cordys.crm.integration.lark.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class LarkDepartment {
+
+    /**
+     * 部门名称
+     */
+    private String name;
+
+    /**
+     * 父部门的 department_id
+     */
+    @JsonProperty("parent_department_id")
+    private String parentDepartmentId;
+
+    /**
+     * 部门的 department_id
+     */
+    @JsonProperty("department_id")
+    private String departmentId;
+
+    /**
+     * 部门的 open_department_id
+     */
+    @JsonProperty("open_department_id")
+    private String openDepartmentId;
+
+    /**
+     * 部门主管的用户 ID
+     */
+    @JsonProperty("leader_user_id")
+    private String leaderUserId;
+
+    /**
+     * 部门的群 ID
+     */
+    @JsonProperty("chat_id")
+    private String chatId;
+
+    /**
+     * 部门的排序
+     */
+    private Integer order;
+
+    /**
+     * 部门的状态
+     */
+    private Status status;
+
+    @Data
+    public static class Status {
+        /**
+         * 是否已删除
+         */
+        @JsonProperty("is_deleted")
+        private boolean isDeleted;
+    }
+}
\ No newline at end of file
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkDepartmentData.java b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkDepartmentData.java
new file mode 100644
index 0000000..902b2fb
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkDepartmentData.java
@@ -0,0 +1,19 @@
+package cn.cordys.crm.integration.lark.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class LarkDepartmentData {
+
+    @JsonProperty("items")
+    private List items;
+
+    @JsonProperty("has_more")
+    private boolean hasMore;
+
+    @JsonProperty("page_token")
+    private String pageToken;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkSendMessageDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkSendMessageDTO.java
new file mode 100644
index 0000000..c0d86c8
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkSendMessageDTO.java
@@ -0,0 +1,12 @@
+package cn.cordys.crm.integration.lark.dto;
+
+import lombok.Data;
+
+@Data
+public class LarkSendMessageDTO {
+
+    private String receive_id;
+    private String msg_type;
+    private String content;
+
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkTenant.java b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkTenant.java
new file mode 100644
index 0000000..fd82139
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkTenant.java
@@ -0,0 +1,36 @@
+package cn.cordys.crm.integration.lark.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/**
+ * @author guoyuqi
+ * @date 2024-07-26
+ */
+@Data
+public class LarkTenant {
+
+    @JsonProperty("name")
+    private String name;
+
+    @JsonProperty("display_id")
+    private String displayId;
+
+    @JsonProperty("tenant_key")
+    private String tenantKey;
+
+    @JsonProperty("avatar")
+    private LarkTenantAvatar avatar;
+
+    @Data
+    public static class LarkTenantAvatar {
+        @JsonProperty("avatar_origin")
+        private String avatarOrigin;
+        @JsonProperty("avatar_72")
+        private String avatar72;
+        @JsonProperty("avatar_240")
+        private String avatar240;
+        @JsonProperty("avatar_640")
+        private String avatar640;
+    }
+}
\ No newline at end of file
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkTextDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkTextDTO.java
new file mode 100644
index 0000000..9cc2ce6
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkTextDTO.java
@@ -0,0 +1,8 @@
+package cn.cordys.crm.integration.lark.dto;
+
+import lombok.Data;
+
+@Data
+public class LarkTextDTO {
+    private String text;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkToken.java b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkToken.java
new file mode 100644
index 0000000..1946ecf
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkToken.java
@@ -0,0 +1,27 @@
+package cn.cordys.crm.integration.lark.dto;
+
+import cn.cordys.crm.integration.lark.response.LarkResponseEntity;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class LarkToken extends LarkResponseEntity {
+
+    /**
+     * 自建应用生成的accessToken。
+     */
+    @JsonProperty("tenant_access_token")
+    private String tenantAccessToken;
+
+    /**
+     * 获取 user_access_token
+     */
+    @JsonProperty("access_token")
+    private String accessToken;
+
+    /**
+     * accessToken的过期时间,单位秒。
+     */
+    @JsonProperty("expire")
+    private String expire;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkTokenParamDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkTokenParamDTO.java
new file mode 100644
index 0000000..11bba0a
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkTokenParamDTO.java
@@ -0,0 +1,20 @@
+package cn.cordys.crm.integration.lark.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+public class LarkTokenParamDTO extends LarkBaseParamDTO {
+
+    @Schema(description = "code")
+    public String code;
+    @Schema(description = "grantType")
+    public String grant_type;
+    @Schema(description = "clientId")
+    public String client_id;
+    @Schema(description = "clientSecret")
+    public String client_secret;
+    @Schema(description = "redirect_uri")
+    public String redirect_uri;
+
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkUser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkUser.java
new file mode 100644
index 0000000..1adc051
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/dto/LarkUser.java
@@ -0,0 +1,123 @@
+package cn.cordys.crm.integration.lark.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * @author guoyuqi
+ * @date 2024-07-26
+ */
+@Data
+public class LarkUser {
+
+    @JsonProperty("union_id")
+    private String unionId;
+
+    @JsonProperty("user_id")
+    private String userId;
+
+    @JsonProperty("open_id")
+    private String openId;
+
+    @JsonProperty("name")
+    private String name;
+
+    @JsonProperty("en_name")
+    private String enName;
+
+    @JsonProperty("email")
+    private String email;
+
+    @JsonProperty("mobile")
+    private String mobile;
+
+    /**
+     * 性别
+     * 1: 男
+     * 2: 女
+     * 0: 未知
+     */
+    @JsonProperty("gender")
+    private Integer gender;
+
+    @JsonProperty("avatar")
+    private LarkUserAvatar avatar;
+
+    @JsonProperty("status")
+    private LarkUserStatus status;
+
+    @JsonProperty("department_ids")
+    private List departmentIds;
+
+    @JsonProperty("leader_user_id")
+    private String leaderUserId;
+
+    @JsonProperty("city")
+    private String city;
+
+    @JsonProperty("country")
+    private String country;
+
+    @JsonProperty("work_station")
+    private String workStation;
+
+    @JsonProperty("join_time")
+    private Long joinTime;
+
+    @JsonProperty("is_tenant_manager")
+    private Boolean isTenantManager;
+
+    @JsonProperty("employee_no")
+    private String employeeNo;
+
+    /**
+     * 员工类型
+     * 1: 正式员工
+     * 2: 实习生
+     * 3: 顾问
+     * 4: 外部人员
+     */
+    @JsonProperty("employee_type")
+    private Integer employeeType;
+
+    @JsonProperty("orders")
+    private List orders;
+
+    @Data
+    public static class LarkUserAvatar {
+        @JsonProperty("avatar_72")
+        private String avatar72;
+        @JsonProperty("avatar_240")
+        private String avatar240;
+        @JsonProperty("avatar_640")
+        private String avatar640;
+        @JsonProperty("avatar_original")
+        private String avatarOriginal;
+    }
+
+    @Data
+    public static class LarkUserStatus {
+        @JsonProperty("is_activated")
+        private Boolean isActivated;
+        @JsonProperty("is_frozen")
+        private Boolean isFrozen;
+        @JsonProperty("is_resigned")
+        private Boolean isResigned;
+        @JsonProperty("is_unjoined")
+        private Boolean isUnjoined;
+    }
+
+    @Data
+    public static class LarkUserOrder {
+        @JsonProperty("department_id")
+        private String departmentId;
+        @JsonProperty("user_order")
+        private String userOrder;
+        @JsonProperty("department_order")
+        private String departmentOrder;
+        @JsonProperty("is_primary_dept")
+        private Boolean isPrimaryDept;
+    }
+}
\ No newline at end of file
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/lark/response/LarkDepartmentResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/response/LarkDepartmentResponse.java
new file mode 100644
index 0000000..0ef262d
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/response/LarkDepartmentResponse.java
@@ -0,0 +1,12 @@
+package cn.cordys.crm.integration.lark.response;
+
+import cn.cordys.crm.integration.lark.dto.LarkDepartmentData;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class LarkDepartmentResponse extends LarkResponseEntity {
+
+    @JsonProperty("data")
+    private LarkDepartmentData data;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/lark/response/LarkResponseEntity.java b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/response/LarkResponseEntity.java
new file mode 100644
index 0000000..10a1a19
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/response/LarkResponseEntity.java
@@ -0,0 +1,21 @@
+package cn.cordys.crm.integration.lark.response;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class LarkResponseEntity {
+
+    /**
+     * 错误码
+     */
+    @JsonProperty("code")
+    private int code;
+
+    /**
+     * 错误消息
+     */
+    @JsonProperty("msg")
+    private String msg;
+
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/lark/response/LarkTenantResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/response/LarkTenantResponse.java
new file mode 100644
index 0000000..7885fdf
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/response/LarkTenantResponse.java
@@ -0,0 +1,24 @@
+package cn.cordys.crm.integration.lark.response;
+
+import cn.cordys.crm.integration.lark.dto.LarkTenant;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * @author guoyuqi
+ * @date 2024-07-26
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LarkTenantResponse extends LarkResponseEntity {
+
+    @JsonProperty("data")
+    private LarkTenantData data;
+
+    @Data
+    public static class LarkTenantData {
+        @JsonProperty("tenant")
+        private LarkTenant tenant;
+    }
+}
\ No newline at end of file
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/lark/response/LarkUserResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/response/LarkUserResponse.java
new file mode 100644
index 0000000..bd8b234
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/response/LarkUserResponse.java
@@ -0,0 +1,32 @@
+package cn.cordys.crm.integration.lark.response;
+
+import cn.cordys.crm.integration.lark.dto.LarkUser;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.List;
+
+/**
+ * @author guoyuqi
+ * @date 2024-07-26
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LarkUserResponse extends LarkResponseEntity {
+
+    @JsonProperty("data")
+    private LarkUserData data;
+
+    @Data
+    public static class LarkUserData {
+        @JsonProperty("items")
+        private List items;
+
+        @JsonProperty("has_more")
+        private boolean hasMore;
+
+        @JsonProperty("page_token")
+        private String pageToken;
+    }
+}
\ No newline at end of file
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/lark/service/LarkDepartmentService.java b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/service/LarkDepartmentService.java
new file mode 100644
index 0000000..8976876
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/service/LarkDepartmentService.java
@@ -0,0 +1,186 @@
+package cn.cordys.crm.integration.lark.service;
+
+import cn.cordys.common.util.JSON;
+import cn.cordys.crm.integration.common.client.QrCodeClient;
+import cn.cordys.crm.integration.lark.constant.LarkApiPaths;
+import cn.cordys.crm.integration.lark.dto.LarkDepartment;
+import cn.cordys.crm.integration.lark.dto.LarkDepartmentData;
+import cn.cordys.crm.integration.lark.dto.LarkTenant;
+import cn.cordys.crm.integration.lark.dto.LarkUser;
+import cn.cordys.crm.integration.lark.response.LarkDepartmentResponse;
+import cn.cordys.crm.integration.lark.response.LarkTenantResponse;
+import cn.cordys.crm.integration.lark.response.LarkUserResponse;
+import cn.cordys.crm.integration.sync.dto.ThirdDepartment;
+import cn.cordys.crm.integration.sync.dto.ThirdUser;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.Strings;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@Slf4j
+public class LarkDepartmentService {
+
+    private static final int DEPARTMENT_USER_PAGE_SIZE = 50;
+    private static final int maxLoop = 1000;
+    @Resource
+    private QrCodeClient qrCodeClient;
+
+    public List getAllSubDepartments(String tenantAccessToken, String departmentId) {
+        List allDepartments = new ArrayList<>();
+        String pageToken = null;
+        boolean hasMore = true;
+        int loop = 0;
+
+        while (hasMore && loop++ < maxLoop) {
+            StringBuilder urlBuilder = new StringBuilder(LarkApiPaths.LARK_CHILDREN_DEPARTMENT_URL.replace("{0}", departmentId));
+            urlBuilder.append("?fetch_child=true");
+            if (StringUtils.isNotBlank(pageToken)) {
+                urlBuilder.append("&page_token=").append(pageToken);
+            }
+
+            LarkDepartmentResponse larkResponse = safeRequest(urlBuilder.toString(), tenantAccessToken, LarkDepartmentResponse.class);
+            hasMore = false;
+            if (larkResponse != null && larkResponse.getCode() == 0) {
+                LarkDepartmentData data = larkResponse.getData();
+                if (data != null) {
+                    allDepartments.addAll(Optional.ofNullable(data.getItems()).orElse(Collections.emptyList()));
+                    hasMore = data.isHasMore();
+                    pageToken = data.getPageToken();
+                }
+            } else if (larkResponse != null) {
+                log.error("获取飞书子部门信息失败: {}:{}", larkResponse.getCode(), larkResponse.getMsg());
+            }
+        }
+
+        return allDepartments;
+    }
+
+    public List getDepartmentUsers(String tenantAccessToken, String departmentId) {
+        List allUsers = new ArrayList<>();
+        String pageToken = null;
+        boolean hasMore = true;
+        int loop = 0;
+
+        while (hasMore && loop++ < maxLoop) {
+            StringBuilder urlBuilder = new StringBuilder(LarkApiPaths.LARK_DEPARTMENT_USERS_URL);
+            urlBuilder.append("?department_id=").append(departmentId)
+                    .append("&page_size=").append(DEPARTMENT_USER_PAGE_SIZE);
+            if (StringUtils.isNotBlank(pageToken)) {
+                urlBuilder.append("&page_token=").append(pageToken);
+            }
+
+            LarkUserResponse larkUserResponse = safeRequest(urlBuilder.toString(), tenantAccessToken, LarkUserResponse.class);
+            hasMore = false;
+            if (larkUserResponse != null && larkUserResponse.getCode() == 0) {
+                LarkUserResponse.LarkUserData data = larkUserResponse.getData();
+                if (data != null) {
+                    allUsers.addAll(Optional.ofNullable(data.getItems()).orElse(Collections.emptyList()));
+                    hasMore = data.isHasMore();
+                    pageToken = data.getPageToken();
+                }
+            } else if (larkUserResponse != null) {
+                log.error("获取飞书部门用户失败: {}:{}", larkUserResponse.getCode(), larkUserResponse.getMsg());
+            }
+        }
+
+        return allUsers;
+    }
+
+    public List getDepartmentList(String tenantAccessToken) {
+        List thirdDepartments = getAllSubDepartments(tenantAccessToken, "0").stream()
+                .map(this::toThirdDepartment)
+                .collect(Collectors.toList());
+        thirdDepartments.add(buildRootDepartment(getTenantInfo(tenantAccessToken)));
+        return thirdDepartments;
+    }
+
+    public Map> getDepartmentUserList(String tenantAccessToken, List departmentIds) {
+        return departmentIds.stream().collect(Collectors.toMap(Function.identity(), departmentId ->
+                getDepartmentUsers(tenantAccessToken, departmentId).stream()
+                        .filter(user -> isPrimaryDepartmentUser(user, departmentId))
+                        .map(this::toThirdUser)
+                        .collect(Collectors.toList())
+        ));
+    }
+
+    public LarkTenant getTenantInfo(String tenantAccessToken) {
+        try {
+            LarkTenantResponse larkTenantResponse = safeRequest(LarkApiPaths.LARK_TENANT_INFO_URL, tenantAccessToken, LarkTenantResponse.class);
+            if (larkTenantResponse != null && larkTenantResponse.getCode() == 0) {
+                return Optional.ofNullable(larkTenantResponse.getData()).map(LarkTenantResponse.LarkTenantData::getTenant).orElse(null);
+            }
+            if (larkTenantResponse != null) {
+                log.error("获取企业信息失败: {}:{}", larkTenantResponse.getCode(), larkTenantResponse.getMsg());
+            }
+        } catch (Exception e) {
+            log.error("获取飞书企业信息失败", e);
+        }
+        return null;
+    }
+
+    private  T safeRequest(String url, String tenantAccessToken, Class responseClass) {
+        String response = qrCodeClient.exchange(
+                url,
+                "Bearer " + tenantAccessToken,
+                "Authorization",
+                MediaType.APPLICATION_JSON,
+                MediaType.APPLICATION_JSON
+        );
+        return JSON.parseObject(response, responseClass);
+    }
+
+    private ThirdDepartment toThirdDepartment(LarkDepartment larkDepartment) {
+        ThirdDepartment thirdDepartment = new ThirdDepartment();
+        thirdDepartment.setId(larkDepartment.getOpenDepartmentId());
+        thirdDepartment.setName(larkDepartment.getName());
+        thirdDepartment.setParentId(larkDepartment.getParentDepartmentId());
+        thirdDepartment.setOrder(Long.valueOf(larkDepartment.getOrder()));
+        thirdDepartment.setIsRoot("0".equals(larkDepartment.getOpenDepartmentId()));
+        return thirdDepartment;
+    }
+
+    private ThirdDepartment buildRootDepartment(LarkTenant tenant) {
+        ThirdDepartment rootDept = new ThirdDepartment();
+        rootDept.setId("0");
+        rootDept.setName(Optional.ofNullable(tenant).map(LarkTenant::getName).orElse("公司名称"));
+        rootDept.setParentId("NONE");
+        rootDept.setIsRoot(true);
+        rootDept.setOrder(0L);
+        return rootDept;
+    }
+
+    private ThirdUser toThirdUser(LarkUser larkUser) {
+        return ThirdUser.builder()
+                .userId(larkUser.getOpenId())
+                .name(larkUser.getName())
+                .email(larkUser.getEmail())
+                .mobile(formatMobile(larkUser.getMobile()))
+                .position(larkUser.getWorkStation())
+                .gender(larkUser.getGender())
+                .avatar(Optional.ofNullable(larkUser.getAvatar())
+                        .map(LarkUser.LarkUserAvatar::getAvatar240)
+                        .filter(StringUtils::isNotBlank)
+                        .orElse(null))
+                .isLeaderInDept(Strings.CI.equals(larkUser.getLeaderUserId(), larkUser.getUserId()))
+                .build();
+    }
+
+    private boolean isPrimaryDepartmentUser(LarkUser user, String departmentId) {
+        return Optional.ofNullable(user.getOrders()).orElse(Collections.emptyList()).stream()
+                .anyMatch(order -> order.getIsPrimaryDept() && Strings.CS.equals(order.getDepartmentId(), departmentId));
+    }
+
+    private String formatMobile(String mobile) {
+        if (StringUtils.isBlank(mobile)) {
+            return null;
+        }
+        return mobile.length() > 11 ? mobile.substring(mobile.length() - 11) : mobile;
+    }
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/lark/service/LarkNoticeSender.java b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/service/LarkNoticeSender.java
new file mode 100644
index 0000000..9d5d8bd
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/lark/service/LarkNoticeSender.java
@@ -0,0 +1,105 @@
+package cn.cordys.crm.integration.lark.service;
+
+import cn.cordys.common.constants.ThirdDetailType;
+import cn.cordys.common.util.JSON;
+import cn.cordys.crm.integration.common.dto.ThirdConfigBaseDTO;
+import cn.cordys.crm.integration.common.request.LarkThirdConfigRequest;
+import cn.cordys.crm.integration.lark.dto.LarkSendMessageDTO;
+import cn.cordys.crm.integration.lark.dto.LarkTextDTO;
+import cn.cordys.crm.integration.sso.service.TokenService;
+import cn.cordys.crm.system.constants.OrganizationConfigConstants;
+import cn.cordys.crm.system.domain.OrganizationConfig;
+import cn.cordys.crm.system.domain.OrganizationConfigDetail;
+import cn.cordys.crm.system.dto.MessageDetailDTO;
+import cn.cordys.crm.system.mapper.ExtOrganizationConfigDetailMapper;
+import cn.cordys.crm.system.mapper.ExtOrganizationConfigMapper;
+import cn.cordys.crm.system.notice.common.NoticeModel;
+import cn.cordys.crm.system.notice.common.Receiver;
+import cn.cordys.crm.system.notice.sender.AbstractNoticeSender;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections.CollectionUtils;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Optional;
+
+@Component
+@Slf4j
+public class LarkNoticeSender extends AbstractNoticeSender {
+
+    @Resource
+    private TokenService tokenService;
+    @Resource
+    private ExtOrganizationConfigMapper extOrganizationConfigMapper;
+    @Resource
+    private ExtOrganizationConfigDetailMapper extOrganizationConfigDetailMapper;
+
+    @Override
+    public void send(MessageDetailDTO messageDetailDTO, NoticeModel noticeModel) {
+        try {
+            String context = super.getContext(messageDetailDTO, noticeModel);
+            sendLark(context, noticeModel, messageDetailDTO.getOrganizationId());
+            log.debug("发送飞书消息结束");
+        } catch (Exception e) {
+            log.error("飞书消息通知失败:{}", String.valueOf(e));
+        }
+    }
+
+    public void sendLark(MessageDetailDTO clonedMessageDetail, NoticeModel clonedNoticeModel) {
+        this.send(clonedMessageDetail, clonedNoticeModel);
+    }
+
+    private void sendLark(String context, NoticeModel noticeModel, String organizationId) {
+        List receivers = super.getReceivers(noticeModel.getReceivers(), noticeModel.isExcludeSelf(), noticeModel.getOperator());
+        if (CollectionUtils.isEmpty(receivers)) {
+            return;
+        }
+        List userIds = receivers.stream()
+                .map(Receiver::getUserId)
+                .distinct()
+                .toList();
+        //查询同步过的resourceId,这里已经确定是否同步了飞书,没同步,消息无法发送
+        List resourceUserIds = super.getResourceUserIds(userIds, organizationId);
+        if (CollectionUtils.isEmpty(resourceUserIds)) {
+            log.warn("没有同步飞书用户,无法发送消息");
+            return;
+        }
+        //查询三方配置
+        OrganizationConfig organizationConfig = extOrganizationConfigMapper
+                .getOrganizationConfig(organizationId, OrganizationConfigConstants.ConfigType.THIRD.name());
+        if (organizationConfig == null) {
+            log.warn("没有配置飞书信息,无法发送消息");
+            return;
+        }
+        //获取通知的配置数据
+        OrganizationConfigDetail orgConfigDetailByIdAndType = extOrganizationConfigDetailMapper.getOrgConfigDetailByIdAndType(organizationConfig.getId(), ThirdDetailType.LARK_SYNC.name());
+        if (orgConfigDetailByIdAndType == null || orgConfigDetailByIdAndType.getContent() == null) {
+            log.warn("没有配置飞书通知信息,无法发送消息");
+            return;
+        }
+        ThirdConfigBaseDTO thirdConfigurationDTO = JSON.parseObject(
+                new String(orgConfigDetailByIdAndType.getContent()), ThirdConfigBaseDTO.class
+        );
+
+        LarkThirdConfigRequest larkThirdConfigRequest =
+                Optional.ofNullable(thirdConfigurationDTO.getConfig())
+                        .map(cfg -> JSON.MAPPER.convertValue(cfg, LarkThirdConfigRequest.class))
+                        .orElseGet(() -> JSON.parseObject(
+                                new String(orgConfigDetailByIdAndType.getContent()),
+                                LarkThirdConfigRequest.class
+                        ));
+
+        for (String resourceUserId : resourceUserIds) {
+            log.info("发送飞书消息,飞书用户ID:{}", resourceUserId);
+            LarkSendMessageDTO larkSendMessageDTO = new LarkSendMessageDTO();
+            larkSendMessageDTO.setReceive_id(resourceUserId);
+            larkSendMessageDTO.setMsg_type("text");
+            LarkTextDTO larkTextDTO = new LarkTextDTO();
+            larkTextDTO.setText(context);
+            larkSendMessageDTO.setContent(JSON.toJSONString(larkTextDTO));
+            tokenService.sendLarkNoticeByToken(larkSendMessageDTO, larkThirdConfigRequest.getAgentId(), larkThirdConfigRequest.getAppSecret());
+        }
+
+    }
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/constant/QccApiPaths.java b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/constant/QccApiPaths.java
new file mode 100644
index 0000000..8b9b761
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/constant/QccApiPaths.java
@@ -0,0 +1,10 @@
+package cn.cordys.crm.integration.qcc.constant;
+
+public interface QccApiPaths {
+
+    String FUZZY_SEARCH_API = "/FuzzySearch/GetList?key={0}&searchKey={1}";
+    String ENTERPRISE_INFO_VERIFY_API = "/EnterpriseInfo/Verify?key={0}&searchKey={1}";
+
+    String FUZZY_SEARCH_LIST_API = "/FuzzySearch/GetList?key={0}&searchKey={1}&pageIndex={2}";
+    
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/Area.java b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/Area.java
new file mode 100644
index 0000000..d186093
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/Area.java
@@ -0,0 +1,20 @@
+package cn.cordys.crm.integration.qcc.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class Area {
+
+    /**
+     * 省份
+     */
+    @JsonProperty("Province")
+    private String province;
+
+    /**
+     * 城市
+     */
+    @JsonProperty("City")
+    private String city;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/BankInfo.java b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/BankInfo.java
new file mode 100644
index 0000000..e0cd283
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/BankInfo.java
@@ -0,0 +1,23 @@
+package cn.cordys.crm.integration.qcc.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class BankInfo {
+
+
+    /**
+     * 开户银行
+     */
+    @JsonProperty("Bank")
+    private String bank;
+
+    /**
+     * 开户行账号
+     */
+    @JsonProperty("BankAccount")
+    private String bankAccount;
+
+
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/ContactInfo.java b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/ContactInfo.java
new file mode 100644
index 0000000..d5616a9
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/ContactInfo.java
@@ -0,0 +1,14 @@
+package cn.cordys.crm.integration.qcc.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class ContactInfo {
+
+    /**
+     * 注册电话
+     */
+    @JsonProperty("Tel")
+    private String tel;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/EnterpriseInfo.java b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/EnterpriseInfo.java
new file mode 100644
index 0000000..f6d77e4
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/EnterpriseInfo.java
@@ -0,0 +1,21 @@
+package cn.cordys.crm.integration.qcc.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class EnterpriseInfo {
+
+    /**
+     * 数据是否存在(1-存在,0-不存在)
+     */
+    @JsonProperty("VerifyResult")
+    private Integer verifyResult;
+
+
+    /**
+     * 数据信息
+     */
+    @JsonProperty("Data")
+    private InfoData data;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/Industry.java b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/Industry.java
new file mode 100644
index 0000000..ae55f70
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/Industry.java
@@ -0,0 +1,14 @@
+package cn.cordys.crm.integration.qcc.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class Industry {
+
+    /**
+     * 行业大类描述
+     */
+    @JsonProperty("SubIndustry")
+    private String subIndustry;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/InfoData.java b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/InfoData.java
new file mode 100644
index 0000000..6b43e4b
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/InfoData.java
@@ -0,0 +1,80 @@
+package cn.cordys.crm.integration.qcc.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class InfoData {
+
+    /**
+     * 企业名称
+     */
+    @JsonProperty("Name")
+    private String name;
+
+    /**
+     * 纳税人识别号
+     */
+    @JsonProperty("TaxNo")
+    private String taxNo;
+
+
+    /**
+     * 开票信息
+     */
+    @JsonProperty("BankInfo")
+    private BankInfo bankInfo;
+
+
+    /**
+     * 注册地址
+     */
+    @JsonProperty("Address")
+    private String address;
+
+    /**
+     * 联系信息
+     */
+    @JsonProperty("ContactInfo")
+    private ContactInfo contactInfo;
+
+
+    /**
+     * 注册资本
+     */
+    @JsonProperty("RegistCapi")
+    private String registerCapi;
+
+
+    /**
+     * 公司规模
+     */
+    @JsonProperty("PersonScope")
+    private String personScope;
+
+
+    /**
+     * 工商注册号
+     */
+    @JsonProperty("No")
+    private String no;
+
+    /**
+     * 所属地区
+     */
+    @JsonProperty("Area")
+    private Area area;
+
+    /**
+     * 企业规模
+     */
+    @JsonProperty("Scale")
+    private String scale;
+
+    /**
+     * 国标行业
+     */
+    @JsonProperty("Industry")
+    private Industry industry;
+}
+
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/NameInfo.java b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/NameInfo.java
new file mode 100644
index 0000000..f733d66
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/NameInfo.java
@@ -0,0 +1,11 @@
+package cn.cordys.crm.integration.qcc.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class NameInfo {
+
+    @JsonProperty("Name")
+    private String name;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/Paging.java b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/Paging.java
new file mode 100644
index 0000000..efedd37
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/Paging.java
@@ -0,0 +1,17 @@
+package cn.cordys.crm.integration.qcc.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class Paging {
+
+    @JsonProperty("PageSize")
+    private int pageSize;
+
+    @JsonProperty("PageIndex")
+    private int pageIndex;
+
+    @JsonProperty("TotalRecords")
+    private int totalRecords;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/QccDetailDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/QccDetailDTO.java
new file mode 100644
index 0000000..725dd0e
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/QccDetailDTO.java
@@ -0,0 +1,20 @@
+package cn.cordys.crm.integration.qcc.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+public class QccDetailDTO {
+
+    @Schema(description = "企查查地址", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String qccAddress;
+
+    @Schema(description = "是否验证通过")
+    private Boolean verify;
+
+    @Schema(description = "企查查AccessKey", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String qccAccessKey;
+
+    @Schema(description = "企查查SecretKey", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String qccSecretKey;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/QccEnterpriseInfo.java b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/QccEnterpriseInfo.java
new file mode 100644
index 0000000..94c60da
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/QccEnterpriseInfo.java
@@ -0,0 +1,12 @@
+package cn.cordys.crm.integration.qcc.dto;
+
+import cn.cordys.crm.integration.qcc.response.QccBaseResponse;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class QccEnterpriseInfo extends QccBaseResponse {
+
+    @JsonProperty("Result")
+    private EnterpriseInfo result;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/QccFuzzyQueryInfo.java b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/QccFuzzyQueryInfo.java
new file mode 100644
index 0000000..94c52e4
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/dto/QccFuzzyQueryInfo.java
@@ -0,0 +1,17 @@
+package cn.cordys.crm.integration.qcc.dto;
+
+import cn.cordys.crm.integration.qcc.response.QccBaseResponse;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class QccFuzzyQueryInfo extends QccBaseResponse {
+
+    @JsonProperty("Result")
+    private List result;
+
+    @JsonProperty("Paging")
+    private Paging paging;
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/response/QccBaseResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/response/QccBaseResponse.java
new file mode 100644
index 0000000..5dbd4fa
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/qcc/response/QccBaseResponse.java
@@ -0,0 +1,16 @@
+package cn.cordys.crm.integration.qcc.response;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class QccBaseResponse {
+
+
+    @JsonProperty("Status")
+    private String status;
+
+    @JsonProperty("Message")
+    private String message;
+
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/constant/SQLBotTable.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/constant/SQLBotTable.java
new file mode 100644
index 0000000..4491678
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/constant/SQLBotTable.java
@@ -0,0 +1,76 @@
+package cn.cordys.crm.integration.sqlbot.constant;
+
+import cn.cordys.common.constants.PermissionConstants;
+import lombok.Getter;
+
+import java.util.Arrays;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * SQLBot支持的表枚举类
+ */
+@Getter
+public enum SQLBotTable {
+    PRODUCT("product", "产品表", null, false),
+    SYS_DEPARTMENT("sys_department", "部门表", null, false),
+    SYS_USER("sys_user", "用户表", null, false),
+
+    CUSTOMER("customer", "客户表", PermissionConstants.CUSTOMER_MANAGEMENT_READ, true),
+    CLUE("clue", "线索表", PermissionConstants.CLUE_MANAGEMENT_READ, true),
+    OPPORTUNITY("opportunity", "商机表", PermissionConstants.OPPORTUNITY_MANAGEMENT_READ, true),
+    CONTACT("customer_contact", "联系人表", PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ, true),
+
+    POOL_CUSTOMER("pool_customer", "公海中的客户表", PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ, true, CUSTOMER.getTableName()),
+    POOL_CLUE("pool_clue", "线索池中的线索表", PermissionConstants.CLUE_MANAGEMENT_POOL_READ, true, CLUE.getTableName()),
+    ;
+
+    /**
+     * 表名,可能是虚拟表
+     */
+    private final String tableName;
+    /**
+     * 复制的表名,如果是虚拟表,则表示从哪个表复制数据
+     */
+    private final String copyFromTableName;
+    /**
+     * 表描述
+     */
+    private final String description;
+    /**
+     * 查询需要的权限,null 表示不需要权限
+     */
+    private final String permission;
+    /**
+     * 是否有数据权限
+     */
+    private final boolean isDataScope;
+    /**
+     * 禁用的字段列表
+     */
+    private final Set disableFields;
+
+    SQLBotTable(String tableName, String description, String permission, boolean isDataScope) {
+        this(tableName, description, permission, isDataScope, null, Set.of());
+    }
+
+    SQLBotTable(String tableName, String description, String permission, boolean isDataScope, String copyFromTableName) {
+        this(tableName, description, permission, isDataScope, copyFromTableName, Set.of());
+    }
+
+    SQLBotTable(String tableName, String description, String permission, boolean isDataScope, String copyFromTableName, Set disableFields) {
+        this.tableName = tableName;
+        this.description = description;
+        this.permission = permission;
+        this.isDataScope = isDataScope;
+        this.disableFields = disableFields;
+        this.copyFromTableName = copyFromTableName;
+    }
+
+    public static Set getDataScopePermissions() {
+        return Arrays.stream(values())
+                .filter(SQLBotTable::isDataScope)
+                .map(SQLBotTable::getPermission)
+                .collect(Collectors.toSet());
+    }
+}
diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/controller/DataSourceController.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/controller/DataSourceController.java
new file mode 100644
index 0000000..07122c6
--- /dev/null
+++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/controller/DataSourceController.java
@@ -0,0 +1,36 @@
+package cn.cordys.crm.integration.sqlbot.controller;
+
+import cn.cordys.common.response.handler.NoResultHolder;
+import cn.cordys.context.OrganizationContext;
+import cn.cordys.crm.integration.sqlbot.dto.SQLBotDTO;
+import cn.cordys.crm.integration.sqlbot.service.DataSourceService;
+import cn.cordys.security.SessionUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import jakarta.annotation.Resource;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping
+public class DataSourceController {
+
+    @Resource
+    private DataSourceService dataSourceService;
+
+    /**
+     * 获取数据库结构。
+     * 

+ * 该方法用于获取当前系统的数据库结构信息。 + *

+ * 这个 API 风险较高,可能会泄露数据库结构信息,因此需要谨慎使用。 + * + * @return 数据库结构信息。 + */ + @GetMapping("/db/structure") + @Operation(summary = "获取数据库结构") + @NoResultHolder + public SQLBotDTO getDBSchema() { + return dataSourceService.getDatabaseSchema(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/DataSourceDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/DataSourceDTO.java new file mode 100644 index 0000000..f7507a6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/DataSourceDTO.java @@ -0,0 +1,86 @@ +package cn.cordys.crm.integration.sqlbot.dto; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 数据源数据传输对象 + *

+ * 用于在系统中传递数据库连接配置及结构信息,包括数据库类型、连接参数、认证信息以及数据库表结构。 + * 主要用于数据源服务中表示一个完整的数据库连接及其包含的表结构。 + *

+ */ +@Data +public class DataSourceDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + /** + * 数据源名称 + *

标识数据源的唯一名称

+ */ + private String name; + + /** + * 数据源类型 + *

数据库类型,如MySQL、Oracle等

+ */ + private String type; + + /** + * 主机地址 + *

数据库服务器的主机名或IP地址

+ */ + private String host; + + /** + * 端口号 + *

数据库服务端口,如MySQL默认3306

+ */ + private int port; + + /** + * 数据库名称 + *

要连接的具体数据库实例名称

+ */ + private String dataBase; + + /** + * 额外参数 + *

JDBC URL中的额外连接参数

+ */ + private String extraParams; + + /** + * 用户名 + *

数据库连接用户名

+ */ + private String user; + + /** + * 密码 + *

数据库连接密码

+ */ + private String password; + + /** + * 架构名称 + *

数据库架构名称,用于特定数据库系统

+ */ + private String schema; + + /** + * 注释说明 + *

数据源的描述信息

+ */ + private String comment; + + /** + * 数据库表列表 + *

数据源中包含的所有表结构信息

+ */ + private List tables; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/FieldDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/FieldDTO.java new file mode 100644 index 0000000..745866f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/FieldDTO.java @@ -0,0 +1,41 @@ +package cn.cordys.crm.integration.sqlbot.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 数据库字段数据传输对象 + *

+ * 用于在系统中传递数据库表字段的结构信息,包括字段名称、注释说明和数据类型。 + * 主要用于表示数据库表结构中的单个字段属性。 + *

+ */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class FieldDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 字段数据类型 + *

数据库中定义的字段类型,如VARCHAR、INT等

+ */ + private String type; + /** + * 字段名称 + *

数据库中的实际字段名

+ */ + private String name; + + /** + * 字段注释 + *

数据库字段的描述信息

+ */ + private String comment; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/SQLBotDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/SQLBotDTO.java new file mode 100644 index 0000000..5000f4a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/SQLBotDTO.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.integration.sqlbot.dto; + +import lombok.Builder; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +@Data +@Builder +public class SQLBotDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + private int code; + private String message; + private List data; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/SqlBotConfigDetailDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/SqlBotConfigDetailDTO.java new file mode 100644 index 0000000..f5e31bb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/SqlBotConfigDetailDTO.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.integration.sqlbot.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class SqlBotConfigDetailDTO { + + @Schema(description = "嵌入脚本", requiredMode = Schema.RequiredMode.REQUIRED) + private String appSecret; + + @Schema(description = "是否验证通过") + private Boolean verify; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/SqlBotConfigDetailLogDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/SqlBotConfigDetailLogDTO.java new file mode 100644 index 0000000..ad8d626 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/SqlBotConfigDetailLogDTO.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.integration.sqlbot.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class SqlBotConfigDetailLogDTO { + + @Schema(description = "嵌入脚本", requiredMode = Schema.RequiredMode.REQUIRED) + private String sqlBotAppSecret; + + @Schema(description = "sqlBot仪表板开启") + private Boolean sqlBotBoardEnable; + + @Schema(description = "sqlBot问数开启") + private Boolean sqlBotChatEnable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/TableDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/TableDTO.java new file mode 100644 index 0000000..db91e43 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/TableDTO.java @@ -0,0 +1,53 @@ +package cn.cordys.crm.integration.sqlbot.dto; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 数据库表结构数据传输对象 + *

+ * 用于在系统中传递数据库表的结构信息,包括表名、注释、规则定义、SQL定义以及字段列表。 + * 主要用于数据源服务中获取数据库模式信息时使用。 + *

+ */ +@Data +public class TableDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + /** + * select * from (select * from test ) tx where + * 表名称 + *

数据库中的实际表名

+ */ + private String name; + + /** + * 表注释 + *

数据库表的描述信息

+ */ + private String comment; + + /** + * 表规则 + * name = name like '%${name}%' and age = ${age} and time = '${time}' + *

执行在 WHERE 条件中

+ */ + private String rule; + + /** + * 自定义表SQL定义 + * select * from `test` where name='${name}' and name like '%${name}%' and age = ${age} and time = '${time} + *

类似虚拟一个数据集,编写自定义的SQL

+ */ + private String sql; + + /** + * 表字段列表 + *

包含表中所有字段的详细信息

+ */ + private List fields; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/TableHandleParam.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/TableHandleParam.java new file mode 100644 index 0000000..a4a861b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/dto/TableHandleParam.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.integration.sqlbot.dto; + +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.crm.integration.sqlbot.constant.SQLBotTable; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-05 15:04 + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class TableHandleParam { + private String userId; + private String orgId; + private SQLBotTable tableInfo; + private DeptDataPermissionDTO dataPermission; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/CluePermissionHandler.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/CluePermissionHandler.java new file mode 100644 index 0000000..f8193da --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/CluePermissionHandler.java @@ -0,0 +1,61 @@ +package cn.cordys.crm.integration.sqlbot.handler; + + +import cn.cordys.common.constants.FormKey; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.clue.constants.ClueStatus; +import cn.cordys.crm.integration.sqlbot.constant.SQLBotTable; +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableHandleParam; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +@Component +public class CluePermissionHandler extends DataScopeTablePermissionHandler { + + @Resource + private ModuleFormCacheService moduleFormCacheService; + + @PostConstruct + public void registerHandler() { + TablePermissionHandlerFactory.registerTableHandler(SQLBotTable.CLUE, this); + } + + @Override + public void handleTable(TableDTO table, TableHandleParam tableHandleParam) { + ModuleFormConfigDTO formConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CLUE.getKey(), OrganizationContext.getOrganizationId()); + List filterFields = filterSystemFields(table.getFields(), + Set.of("pool_id", "in_shared_pool", "organization_id", "reason_id", "transition_type", "transition_id", "last_stage")); + table.setFields(filterFields); + + super.handleTable(table, tableHandleParam, formConfig); + + String sql = table.getSql() + " and in_shared_pool is false and (c.transition_id is null or c.transition_id = '')"; + table.setSql(sql); + } + + + @Override + protected String getSelectSystemFileSql(FieldDTO sqlBotField) { + String fieldName = sqlBotField.getName(); + if (Strings.CS.equals(fieldName, "stage")) { + return getSystemOptionFileSql(Arrays.stream(ClueStatus.values()), + ClueStatus::getKey, + ClueStatus::getName, + fieldName); + } else if (Strings.CS.equals(fieldName, "products")) { + return getProductsFieldSql(); + } else { + return getDefaultFieldSql(sqlBotField); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/ContactPermissionHandler.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/ContactPermissionHandler.java new file mode 100644 index 0000000..ce7107e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/ContactPermissionHandler.java @@ -0,0 +1,64 @@ +package cn.cordys.crm.integration.sqlbot.handler; + + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.integration.sqlbot.constant.SQLBotTable; +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableHandleParam; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; + +@Component +public class ContactPermissionHandler extends DataScopeTablePermissionHandler { + + @Resource + private ModuleFormCacheService moduleFormCacheService; + + @PostConstruct + public void registerHandler() { + TablePermissionHandlerFactory.registerTableHandler(SQLBotTable.CONTACT, this); + } + + @Override + public void handleTable(TableDTO table, TableHandleParam tableHandleParam) { + ModuleFormConfigDTO formConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CONTACT.getKey(), OrganizationContext.getOrganizationId()); + List filterFields = filterSystemFields(table.getFields(), + Set.of("organization_id", "disable_reason")); + + filterFields.add(new FieldDTO("varchar(255)", "customer_name", "客户名称")); + + table.setFields(filterFields); + + super.handleTable(table, tableHandleParam, formConfig); + } + + @Override + protected String getSelectSystemFileSql(FieldDTO sqlBotField) { + String fieldName = sqlBotField.getName(); + if (Strings.CS.equals(fieldName, "enable")) { + List options = List.of(new OptionDTO("1", "启用"), new OptionDTO("2", "停用")); + return getSystemOptionFileSql(options.stream(), + OptionDTO::getId, + OptionDTO::getName, + fieldName); + } else if (Strings.CS.equals(fieldName, "customer_name")) { + return getCustomerNameFieldSql(); + } else { + return getDefaultFieldSql(sqlBotField); + } + } + + protected String getCustomerNameFieldSql() { + return "(select customer.name from customer where c.customer_id = customer.id limit 1) as customer_name"; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/CustomerPermissionHandler.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/CustomerPermissionHandler.java new file mode 100644 index 0000000..03145a7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/CustomerPermissionHandler.java @@ -0,0 +1,45 @@ +package cn.cordys.crm.integration.sqlbot.handler; + + +import cn.cordys.common.constants.FormKey; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.integration.sqlbot.constant.SQLBotTable; +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableHandleParam; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; + +/** + * 处理有数据权限的表 + */ +@Component +public class CustomerPermissionHandler extends DataScopeTablePermissionHandler { + + @Resource + private ModuleFormCacheService moduleFormCacheService; + + @PostConstruct + public void registerHandler() { + TablePermissionHandlerFactory.registerTableHandler(SQLBotTable.CUSTOMER, this); + } + + @Override + public void handleTable(TableDTO table, TableHandleParam tableHandleParam) { + ModuleFormConfigDTO formConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CUSTOMER.getKey(), OrganizationContext.getOrganizationId()); + List filterFields = filterSystemFields(table.getFields(), Set.of("pool_id", "in_shared_pool", "organization_id", "reason_id")); + table.setFields(filterFields); + + super.handleTable(table, tableHandleParam, formConfig); + + String sql = table.getSql(); + sql += " and in_shared_pool is false"; + table.setSql(sql); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/DataScopeTablePermissionHandler.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/DataScopeTablePermissionHandler.java new file mode 100644 index 0000000..2a28284 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/DataScopeTablePermissionHandler.java @@ -0,0 +1,87 @@ +package cn.cordys.crm.integration.sqlbot.handler; + + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableHandleParam; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.Strings; + +import java.text.MessageFormat; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 处理有数据权限的表 + */ +public abstract class DataScopeTablePermissionHandler extends ModuleFieldTablePermissionHandler { + + public static final String DATA_SCOPE_SQL_TEMPLATE = """ + select {0}, + sys_department.name as department_name, + sys_user.name as owner + {1} + from {2} c + left join sys_user on sys_user.id = c.owner + left join sys_organization_user sou on sou.user_id = sys_user.id + left join sys_department sys_department on sys_department.id = sou.department_id + where c.organization_id = ''{3}'' + """; + + @Override + public void handleTable(TableDTO table, TableHandleParam tableHandleParam, ModuleFormConfigDTO formConfig) { + List moduleFields = filterFields(formConfig.getFields()); + List tableFields = table.getFields(); + String sql = getTableSql(tableFields, tableHandleParam, moduleFields); + table.setSql(sql); + + List appendFields = parse2SQLBotFields(moduleFields); + tableFields.addAll(appendFields); + tableFields.add(getDepartmentNameField()); + } + + protected String getProductsFieldSql() { + return "(select JSON_ARRAYAGG(p.name) from product p where c.products like concat('%', p.id, '%')) as products"; + } + + protected String getTableSql(List sqlBotFields, TableHandleParam tableHandleParam, List moduleFields) { + String fieldsSql = parseFieldsSql(tableHandleParam.getTableInfo().getTableName() + "_field", moduleFields); + sqlBotFields = sqlBotFields.stream() + .filter(field -> !Strings.CS.equals(field.getName(), "owner")) + .collect(Collectors.toList()); + String dataScopeSql = MessageFormat.format(DATA_SCOPE_SQL_TEMPLATE, + getSelectSystemFileSql(sqlBotFields), + fieldsSql, + tableHandleParam.getTableInfo().getTableName(), + tableHandleParam.getOrgId() + ); + + DeptDataPermissionDTO dataPermission = tableHandleParam.getDataPermission(); + if (Strings.CS.equals(tableHandleParam.getUserId(), InternalUser.ADMIN.getValue()) || dataPermission.getAll()) { + return dataScopeSql; + } else if (CollectionUtils.isNotEmpty(dataPermission.getDeptIds())) { + String deptIdStr = getInConditionStr(dataPermission.getDeptIds()); + return dataScopeSql + String.format(" and (sou.department_id in (%s) or sou.user_id = '%s')", deptIdStr, tableHandleParam.getUserId()); + } else { + return dataScopeSql + String.format(" and c.owner = '%s'", tableHandleParam.getUserId()); + } + } + + /** + * 将数组转成IN的条件字符串 + * + * @param ids + * + * @return + */ + protected String getInConditionStr(Collection ids) { + return "'" + + String.join("','", ids) + + "'"; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/ModuleFieldTablePermissionHandler.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/ModuleFieldTablePermissionHandler.java new file mode 100644 index 0000000..07e3fc2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/ModuleFieldTablePermissionHandler.java @@ -0,0 +1,101 @@ +package cn.cordys.crm.integration.sqlbot.handler; + +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableHandleParam; +import cn.cordys.crm.integration.sqlbot.handler.field.api.ModuleFieldParser; +import cn.cordys.crm.integration.sqlbot.handler.field.api.ModuleFieldParserFactory; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.text.MessageFormat; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 处理组织下的表 + */ +public class ModuleFieldTablePermissionHandler extends OrgTablePermissionHandler { + public static final String MODULE_FIELD_TABLE_SQL_TEMPLATE = """ + select + {0} + {1} + from {2} c + where c.organization_id = ''{3}'' + """; + + public void handleTable(TableDTO table, TableHandleParam tableHandleParam) { + String sql = getOrgTableSql(tableHandleParam, table.getFields()); + table.setSql(sql); + } + + public void handleTable(TableDTO table, TableHandleParam tableHandleParam, ModuleFormConfigDTO formConfig) { + List moduleFields = filterFields(formConfig.getFields()); + List tableFields = table.getFields(); + String sql = getTableSql(tableFields, tableHandleParam, moduleFields); + table.setSql(sql); + + List appendFields = parse2SQLBotFields(moduleFields); + tableFields.addAll(appendFields); + } + + protected String getTableSql(List sqlBotFields, TableHandleParam tableHandleParam, List moduleFields) { + String fieldsSql = parseFieldsSql(tableHandleParam.getTableInfo().getTableName() + "_field", moduleFields); + return MessageFormat.format(MODULE_FIELD_TABLE_SQL_TEMPLATE, + getSelectSystemFileSql(sqlBotFields), + fieldsSql, + tableHandleParam.getTableInfo().getTableName(), + tableHandleParam.getOrgId() + ); + } + + /** + * 解析模块字段SQL + * + * @param fields 字段列表 + * + * @return 拼接的SQL字符串 + */ + protected String parseFieldsSql(String filedValueTableName, List fields) { + StringBuilder fieldSqlBuilder = new StringBuilder(); + for (BaseField field : fields) { + fieldSqlBuilder.append(","); + + ModuleFieldParser fieldParser = ModuleFieldParserFactory.getFieldParser(field.getType()); + // 拼接字段的SQL + fieldSqlBuilder.append(fieldParser.parseSql(filedValueTableName, field)); + } + return fieldSqlBuilder.toString(); + } + + /** + * 将字段列表转换为SQLBot的FieldDTO对象列表 + * + * @param fields 字段列表 + * + * @return 转换后的FieldDTO对象列表 + */ + protected List parse2SQLBotFields(List fields) { + return fields.stream() + .map(field -> { + ModuleFieldParser fieldParser = ModuleFieldParserFactory.getFieldParser(field.getType()); + return fieldParser.parseToSQLBotField(field); + }).collect(Collectors.toList()); + } + + protected List filterFields(List fields) { + return fields + .stream() + .filter(field -> + // 分割线、图片、位置等字段不支持 + // 不可见的字段不处理 + // 业务字段不处理 + !Strings.CS.equalsAny(field.getType(), FieldType.DIVIDER.name(), FieldType.PICTURE.name(), FieldType.LOCATION.name(), FieldType.TEXTAREA.name()) + && field.getReadable() + && StringUtils.isBlank(field.getBusinessKey()) + ).toList(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/OpportunityPermissionHandler.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/OpportunityPermissionHandler.java new file mode 100644 index 0000000..f7bb77d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/OpportunityPermissionHandler.java @@ -0,0 +1,72 @@ +package cn.cordys.crm.integration.sqlbot.handler; + + +import cn.cordys.common.constants.FormKey; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.integration.sqlbot.constant.SQLBotTable; +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableHandleParam; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; + +@Component +public class OpportunityPermissionHandler extends DataScopeTablePermissionHandler { + + @Resource + private ModuleFormCacheService moduleFormCacheService; + + @PostConstruct + public void registerHandler() { + TablePermissionHandlerFactory.registerTableHandler(SQLBotTable.OPPORTUNITY, this); + } + + @Override + public void handleTable(TableDTO table, TableHandleParam tableHandleParam) { + ModuleFormConfigDTO formConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.OPPORTUNITY.getKey(), OrganizationContext.getOrganizationId()); + List filterFields = filterSystemFields(table.getFields(), + Set.of("organization_id", "last_stage", "status", "failure_reason")); + + filterFields.add(new FieldDTO("varchar(255)", "customer_name", "客户名称")); + filterFields.add(new FieldDTO("varchar(255)", "contact_name", "联系人名称")); + + table.setFields(filterFields); + + super.handleTable(table, tableHandleParam, formConfig); + } + + @Override + protected String getSelectSystemFileSql(FieldDTO sqlBotField) { + String fieldName = sqlBotField.getName(); + if (Strings.CS.equals(fieldName, "stage")) { + return getStageFieldSql(); + } else if (Strings.CS.equals(fieldName, "customer_name")) { + return getCustomerNameFieldSql(); + } else if (Strings.CS.equals(fieldName, "contact_name")) { + return getContactNameFieldSql(); + } else if (Strings.CS.equals(fieldName, "products")) { + return getProductsFieldSql(); + } else { + return getDefaultFieldSql(sqlBotField); + } + } + + protected String getStageFieldSql() { + return "(select osc.name from opportunity_stage_config osc where c.stage = osc.id limit 1) as stage"; + } + + protected String getCustomerNameFieldSql() { + return "(select customer.name from customer where c.customer_id = customer.id limit 1) as customer_name"; + } + + protected String getContactNameFieldSql() { + return "(select customer_contact.name from customer_contact where c.contact_id = customer_contact.id limit 1) as contact_name"; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/OrgTablePermissionHandler.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/OrgTablePermissionHandler.java new file mode 100644 index 0000000..bb4b504 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/OrgTablePermissionHandler.java @@ -0,0 +1,179 @@ +package cn.cordys.crm.integration.sqlbot.handler; + +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableHandleParam; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.text.MessageFormat; +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 处理组织下的表 + */ +public class OrgTablePermissionHandler implements TablePermissionHandler { + public static final String ORG_TABLE_SQL_TEMPLATE = """ + select {0} + from {1} c + where c.organization_id = ''{2}'' + """; + + public void handleTable(TableDTO table, TableHandleParam tableHandleParam) { + String sql = getOrgTableSql(tableHandleParam, table.getFields()); + table.setSql(sql); + } + + protected String getOrgTableSql(TableHandleParam tableHandleParam, List sqlBotFields) { + return MessageFormat.format(ORG_TABLE_SQL_TEMPLATE, + getSelectSystemFileSql(sqlBotFields), + tableHandleParam.getTableInfo().getTableName(), + tableHandleParam.getOrgId()); + } + + protected String getSelectSystemFileSql(List sqlBotFields) { + StringBuilder selectSqlBuilder = new StringBuilder(); + for (int i = 0; i < sqlBotFields.size(); i++) { + FieldDTO sqlBotField = sqlBotFields.get(i); + String sql = getSelectSystemFileSql(sqlBotField); + if (i != 0) { + // 不是第一个字段,添加逗号 + selectSqlBuilder.append(","); + } + selectSqlBuilder.append("\n"); + selectSqlBuilder.append(sql); + } + return selectSqlBuilder.toString(); + } + + protected String getSelectSystemFileSql(FieldDTO sqlBotField) { + return getDefaultFieldSql(sqlBotField, null); + } + + protected String getSelectSystemFileSql(FieldDTO sqlBotField, String prefix) { + return getDefaultFieldSql(sqlBotField, prefix); + } + + protected String getDefaultFieldSql(FieldDTO sqlBotField) { + return getDefaultFieldSql(sqlBotField, null); + } + + protected String getDefaultFieldSql(FieldDTO sqlBotField, String prefix) { + String fieldName = sqlBotField.getName(); + if (Strings.CS.endsWith(fieldName, "_time")) { + // long 类型替换为 datetime 类型 + sqlBotField.setType("varchar(50)"); + sqlBotField.setComment(sqlBotField.getComment() + ",时间格式:%Y-%m-%d %H:%i:%s"); + return getTimeFieldSql(fieldName, prefix); + } else if (Strings.CS.endsWith(fieldName, "_date")) { + // long 类型替换为 date 类型 + sqlBotField.setType("varchar(50)"); + sqlBotField.setComment(sqlBotField.getComment() + ",日期格式:%Y-%m-%d"); + return getDateFieldSql(fieldName, prefix); + } else if (Strings.CS.endsWith(fieldName, "_user") || Strings.CS.equalsAny(fieldName, "follower")) { + return getUserFieldSql(fieldName, prefix); + } else { + if (StringUtils.isBlank(prefix)) { + return "c." + fieldName; + } else { + return prefix + "." + fieldName; + } + } + } + + private String getUserFieldSql(String fieldName, String prefix) { + if (StringUtils.isBlank(prefix)) { + return String.format("(select su.name from sys_user su where su.id = c.%s limit 1) as %s", fieldName, fieldName); + } else { + return String.format("(select su.name from sys_user su where su.id = %s.%s limit 1) as %s", prefix, fieldName, fieldName); + } + } + + private String getTimeFieldSql(String fieldName, String prefix) { + if (StringUtils.isBlank(prefix)) { + return MessageFormat.format("DATE_FORMAT(FROM_UNIXTIME(c.{0} / 1000),''%Y-%m-%d %H:%i:%s'') as {1}", fieldName, fieldName); + } else { + return MessageFormat.format("DATE_FORMAT(FROM_UNIXTIME({0}.{1} / 1000),''%Y-%m-%d %H:%i:%s'') as {2}", prefix, fieldName, fieldName); + } + } + + private String getDateFieldSql(String fieldName, String prefix) { + if (StringUtils.isBlank(prefix)) { + return MessageFormat.format("DATE_FORMAT(FROM_UNIXTIME(c.{0} / 1000),''%Y-%m-%d'') as {1}", fieldName, fieldName); + } else { + return MessageFormat.format("DATE_FORMAT(FROM_UNIXTIME({0}.{1} / 1000),''%Y-%m-%d'') as {2}", prefix, fieldName, fieldName); + } + } + + /** + * 过滤系统字段 + * + * @param fields + * @param disabledFieldNames + * + * @return + */ + protected List filterSystemFields(List fields, Set disabledFieldNames) { + return fields + .stream() + .filter(field -> !disabledFieldNames.contains(field.getName())) + .collect(Collectors.toList()); + } + + /** + * 获取选项字段查询的SQL + * 示例: + * CASE c.stage + * WHEN 'SUCCESS' THEN '成功' + * WHEN 'FAIL' THEN '失败' + * ELSE '' + * END AS stage_name + * + * @param stream + * @param fieldName + * + * @return + */ + protected String getSystemOptionFileSql(Stream stream, Function getKeyFunc, Function getNameFunc, String fieldName) { + return getSystemOptionFileSql(stream, getKeyFunc, getNameFunc, fieldName, null); + } + + /** + * 获取选项字段查询的SQL + * 示例: + * CASE c.stage + * WHEN 'SUCCESS' THEN '成功' + * WHEN 'FAIL' THEN '失败' + * ELSE '' + * END AS stage_name + * + * @param stream + * @param fieldName + * @param prefix + * + * @return + */ + protected String getSystemOptionFileSql(Stream stream, Function getKeyFunc, Function getNameFunc, String fieldName, String prefix) { + StringBuilder sqlBuilder = new StringBuilder(); + if (StringUtils.isBlank(prefix)) { + sqlBuilder.append("case c.").append(fieldName); + } else { + sqlBuilder.append("case ").append(prefix).append(".").append(fieldName); + } + stream.forEach(clueStatus -> sqlBuilder.append(String.format(" when '%s' then '%s'", getKeyFunc.apply(clueStatus), getNameFunc.apply(clueStatus)))); + sqlBuilder.append(" else '' end as ").append(fieldName); + return sqlBuilder.toString(); + } + + protected FieldDTO getDepartmentNameField() { + FieldDTO fieldDTO = new FieldDTO(); + fieldDTO.setType("varchar(255)"); + fieldDTO.setComment("部门名称"); + fieldDTO.setName("department_name"); + return fieldDTO; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/PoolCluePermissionHandler.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/PoolCluePermissionHandler.java new file mode 100644 index 0000000..2ecebe0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/PoolCluePermissionHandler.java @@ -0,0 +1,120 @@ +package cn.cordys.crm.integration.sqlbot.handler; + + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.clue.constants.ClueStatus; +import cn.cordys.crm.clue.domain.CluePool; +import cn.cordys.crm.integration.sqlbot.constant.SQLBotTable; +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableHandleParam; +import cn.cordys.crm.system.service.UserExtendService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Component; + +import java.text.MessageFormat; +import java.util.*; + +@Component +public class PoolCluePermissionHandler extends DataScopeTablePermissionHandler { + + public static final String POOL_CLUE_SCOPE_SQL_TEMPLATE = """ + select {0} + from clue c + where c.organization_id = ''{1}'' + and in_shared_pool is true + and pool_id in ({2}) + """; + @Resource + private BaseMapper poolMapper; + @Resource + private UserExtendService userExtendService; + + @PostConstruct + public void registerHandler() { + TablePermissionHandlerFactory.registerTableHandler(SQLBotTable.POOL_CLUE, this); + } + + @Override + public void handleTable(TableDTO table, TableHandleParam tableHandleParam) { + List filterFields = filterSystemFields(table.getFields(), + Set.of("owner", "collection_time", "contact", "phone", "follower", "follow_time", + "in_shared_pool", "organization_id", "reason_id", "transition_type", "transition_id", "last_stage")); + + table.setFields(filterFields); + + String sql = getTableSql(filterFields, tableHandleParam); + table.setSql(sql); + } + + protected String getTableSql(List sqlBotFields, TableHandleParam tableHandleParam) { + List clueIds = getClueIds(tableHandleParam.getUserId(), tableHandleParam.getOrgId()); + if (clueIds.isEmpty()) { + return "select 1 where 1=0"; // 返回空结果集 + } + return MessageFormat.format(POOL_CLUE_SCOPE_SQL_TEMPLATE, + getSelectSystemFileSql(sqlBotFields), + tableHandleParam.getOrgId(), + getInConditionStr(clueIds) + + ); + } + + @Override + protected String getSelectSystemFileSql(FieldDTO sqlBotField) { + String fieldName = sqlBotField.getName(); + if (Strings.CS.equals(fieldName, "stage")) { + return getSystemOptionFileSql(Arrays.stream(ClueStatus.values()), + ClueStatus::getKey, + ClueStatus::getName, + fieldName); + } else if (Strings.CS.equals(fieldName, "products")) { + return getProductsFieldSql(); + } else if (Strings.CS.equals(fieldName, "pool_id")) { + sqlBotField.setName("pool_name"); + sqlBotField.setComment("线索池名称"); + return "(select pool.name from clue_pool pool where c.pool_id = pool.id limit 1) as pool_name"; + } else { + return getDefaultFieldSql(sqlBotField); + } + } + + private List getClueIds(String currentUser, String currentOrgId) { + LambdaQueryWrapper query = new LambdaQueryWrapper() + .eq(CluePool::getEnable, true) + .eq(CluePool::getOrganizationId, currentOrgId) + .orderByDesc(CluePool::getUpdateTime); + + List cluePools = poolMapper.selectListByLambda(query); + if (cluePools == null || cluePools.isEmpty()) { + return Collections.emptyList(); + } + + return cluePools.stream() + .filter(pool -> { + List scopeIds = Optional.ofNullable(pool.getScopeId()) + .filter(StringUtils::isNotBlank) + .map(json -> JSON.parseArray(json, String.class)) + .map(ids -> userExtendService.getScopeOwnerIds(ids, currentOrgId)) + .orElse(Collections.emptyList()); + + List ownerIds = Optional.ofNullable(pool.getOwnerId()) + .filter(StringUtils::isNotBlank) + .map(json -> JSON.parseArray(json, String.class)) + .map(ids -> userExtendService.getScopeOwnerIds(ids, currentOrgId)) + .orElse(Collections.emptyList()); + + return scopeIds.contains(currentUser) + || ownerIds.contains(currentUser) + || Strings.CS.equals(currentUser, InternalUser.ADMIN.getValue()); + }) + .map(CluePool::getId) + .toList(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/PoolCustomerPermissionHandler.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/PoolCustomerPermissionHandler.java new file mode 100644 index 0000000..7484ac3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/PoolCustomerPermissionHandler.java @@ -0,0 +1,111 @@ +package cn.cordys.crm.integration.sqlbot.handler; + + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.customer.domain.CustomerPool; +import cn.cordys.crm.integration.sqlbot.constant.SQLBotTable; +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableHandleParam; +import cn.cordys.crm.system.service.UserExtendService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Component; + +import java.text.MessageFormat; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Component +public class PoolCustomerPermissionHandler extends DataScopeTablePermissionHandler { + + public static final String POOL_CUSTOMER_SCOPE_SQL_TEMPLATE = """ + select {0} + from customer c + where c.organization_id = ''{1}'' + and in_shared_pool is true + and pool_id in ({2}) + """; + @Resource + private BaseMapper poolMapper; + @Resource + private UserExtendService userExtendService; + + @PostConstruct + public void registerHandler() { + TablePermissionHandlerFactory.registerTableHandler(SQLBotTable.POOL_CUSTOMER, this); + } + + @Override + public void handleTable(TableDTO table, TableHandleParam tableHandleParam) { + List filterFields = filterSystemFields(table.getFields(), + Set.of("owner", "collection_time", "follower", "follow_time", + "in_shared_pool", "organization_id", "reason_id")); + + table.setFields(filterFields); + + String sql = getTableSql(filterFields, tableHandleParam); + table.setSql(sql); + } + + protected String getTableSql(List sqlBotFields, TableHandleParam tableHandleParam) { + List clueIds = getClueIds(tableHandleParam.getUserId(), tableHandleParam.getOrgId()); + if (clueIds.isEmpty()) { + return "select 1 where 1=0"; // 返回空结果集 + } + return MessageFormat.format(POOL_CUSTOMER_SCOPE_SQL_TEMPLATE, + getSelectSystemFileSql(sqlBotFields), + tableHandleParam.getOrgId(), + getInConditionStr(clueIds) + + ); + } + + @Override + protected String getSelectSystemFileSql(FieldDTO sqlBotField) { + String fieldName = sqlBotField.getName(); + if (Strings.CS.equals(fieldName, "pool_id")) { + sqlBotField.setName("pool_name"); + sqlBotField.setComment("公海名称"); + return "(select pool.name from customer_pool pool where c.pool_id = pool.id limit 1) as pool_name"; + } else { + return getDefaultFieldSql(sqlBotField); + } + } + + private List getClueIds(String currentUser, String currentOrgId) { + // 构建查询条件 + var poolWrapper = new LambdaQueryWrapper() + .eq(CustomerPool::getEnable, true) + .eq(CustomerPool::getOrganizationId, currentOrgId) + .orderByDesc(CustomerPool::getUpdateTime); + + var pools = poolMapper.selectListByLambda(poolWrapper); + if (pools == null || pools.isEmpty()) { + return List.of(); + } + + // 管理员直接拥有所有客户池 + boolean isAdmin = Strings.CS.equals(currentUser, InternalUser.ADMIN.getValue()); + + return pools.stream() + .filter(pool -> { + var scopeIds = Optional.ofNullable(pool.getScopeId()) + .map(ids -> userExtendService.getScopeOwnerIds(JSON.parseArray(ids, String.class), currentOrgId)) + .orElse(List.of()); + + var ownerIds = Optional.ofNullable(pool.getOwnerId()) + .map(ids -> userExtendService.getScopeOwnerIds(JSON.parseArray(ids, String.class), currentOrgId)) + .orElse(List.of()); + + return isAdmin || scopeIds.contains(currentUser) || ownerIds.contains(currentUser); + }) + .map(CustomerPool::getId) + .toList(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/ProductPermissionHandler.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/ProductPermissionHandler.java new file mode 100644 index 0000000..6e9af21 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/ProductPermissionHandler.java @@ -0,0 +1,57 @@ +package cn.cordys.crm.integration.sqlbot.handler; + + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.integration.sqlbot.constant.SQLBotTable; +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableHandleParam; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; + +/** + * 处理有数据权限的表 + */ +@Component +public class ProductPermissionHandler extends ModuleFieldTablePermissionHandler { + + @Resource + private ModuleFormCacheService moduleFormCacheService; + + @PostConstruct + public void registerHandler() { + TablePermissionHandlerFactory.registerTableHandler(SQLBotTable.PRODUCT, this); + } + + @Override + public void handleTable(TableDTO table, TableHandleParam tableHandleParam) { + ModuleFormConfigDTO formConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.PRODUCT.getKey(), OrganizationContext.getOrganizationId()); + List filterFields = filterSystemFields(table.getFields(), Set.of("organization_id", "pos")); + table.setFields(filterFields); + super.handleTable(table, tableHandleParam, formConfig); + + } + + @Override + protected String getSelectSystemFileSql(FieldDTO sqlBotField) { + String fieldName = sqlBotField.getName(); + if (Strings.CS.equals(fieldName, "status")) { + List options = List.of(new OptionDTO("1", "上架"), new OptionDTO("2", "下架")); + return getSystemOptionFileSql(options.stream(), + OptionDTO::getId, + OptionDTO::getName, + fieldName); + } else { + return getDefaultFieldSql(sqlBotField); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/SysDepartmentPermissionHandler.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/SysDepartmentPermissionHandler.java new file mode 100644 index 0000000..f1f281d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/SysDepartmentPermissionHandler.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.integration.sqlbot.handler; + + +import cn.cordys.crm.integration.sqlbot.constant.SQLBotTable; +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableHandleParam; +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; + +/** + * 处理有数据权限的表 + */ +@Component +public class SysDepartmentPermissionHandler extends OrgTablePermissionHandler { + + @PostConstruct + public void registerHandler() { + TablePermissionHandlerFactory.registerTableHandler(SQLBotTable.SYS_DEPARTMENT, this); + } + + @Override + public void handleTable(TableDTO table, TableHandleParam tableHandleParam) { + List filterFields = filterSystemFields(table.getFields(), Set.of("organization_id", "pos", "resource", "resource_id")); + table.setFields(filterFields); + super.handleTable(table, tableHandleParam); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/SysUserPermissionHandler.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/SysUserPermissionHandler.java new file mode 100644 index 0000000..dde0eb4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/SysUserPermissionHandler.java @@ -0,0 +1,111 @@ +package cn.cordys.crm.integration.sqlbot.handler; + + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.integration.sqlbot.constant.SQLBotTable; +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableHandleParam; +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Component; + +import java.text.MessageFormat; +import java.util.List; +import java.util.Set; + +/** + * 处理有数据权限的表 + */ +@Component +public class SysUserPermissionHandler extends OrgTablePermissionHandler { + + private static final String SYS_USER_SQL_TEMPLATE = """ + select {0} + from sys_user c + join sys_organization_user sou on c.id = sou.user_id + join sys_department sd on sd.id = sou.department_id + where sou.organization_id = ''{1}'' and sou.enable is true + """; + + private static final String SYS_ORGANIZATION_USER_PREFIX = "sou"; + + @PostConstruct + public void registerHandler() { + TablePermissionHandlerFactory.registerTableHandler(SQLBotTable.SYS_USER, this); + } + + @Override + public void handleTable(TableDTO table, TableHandleParam tableHandleParam) { + List filterFields = filterSystemFields(table.getFields(), + Set.of("organization_id", "pos", "password", "last_organization_id", "language")); + filterFields.add(getDepartmentNameField()); + filterFields.add(getPositionField()); + filterFields.add(getEmployeeIdField()); + filterFields.add(getEmployeeTypeField()); + filterFields.add(getOnboardingDateField()); + table.setFields(filterFields); + + String sql = getSysUserTableSql(tableHandleParam, table.getFields()); + table.setSql(sql); + } + + protected String getSysUserTableSql(TableHandleParam tableHandleParam, List sqlBotFields) { + return MessageFormat.format(SYS_USER_SQL_TEMPLATE, + getSelectSystemFileSql(sqlBotFields), + tableHandleParam.getOrgId()); + } + + protected FieldDTO getPositionField() { + FieldDTO fieldDTO = new FieldDTO(); + fieldDTO.setType("varchar(255)"); + fieldDTO.setComment("职位"); + fieldDTO.setName("position"); + return fieldDTO; + } + + protected FieldDTO getEmployeeIdField() { + FieldDTO fieldDTO = new FieldDTO(); + fieldDTO.setType("varchar(255)"); + fieldDTO.setComment("工号"); + fieldDTO.setName("employee_id"); + return fieldDTO; + } + + protected FieldDTO getEmployeeTypeField() { + FieldDTO fieldDTO = new FieldDTO(); + fieldDTO.setType("varchar(255)"); + fieldDTO.setComment("员工类型"); + fieldDTO.setName("employee_type"); + return fieldDTO; + } + + protected FieldDTO getOnboardingDateField() { + FieldDTO fieldDTO = new FieldDTO(); + fieldDTO.setType("varchar(500)"); + fieldDTO.setComment("入职时间,时间格式:%Y-%m-%d"); + fieldDTO.setName("onboarding_date"); + return fieldDTO; + } + + @Override + protected String getSelectSystemFileSql(FieldDTO sqlBotField) { + String fieldName = sqlBotField.getName(); + if (Strings.CS.equals(fieldName, "employee_type")) { + List options = List.of(new OptionDTO("formal", "正式"), + new OptionDTO("internship", "实习"), new OptionDTO("outsourcing", "外包")); + return getSystemOptionFileSql(options.stream(), + OptionDTO::getId, + OptionDTO::getName, + fieldName, SYS_ORGANIZATION_USER_PREFIX); + } else if (Strings.CS.equals(fieldName, "gender")) { + return "if(c.gender, '女', '男') as '性别'"; + } else if (Strings.CS.equals(fieldName, "department_name")) { + return "sd.name as department_name"; + } else if (Strings.CS.equalsAny(fieldName, "onboarding_date", "position", "employee_id")) { + return getDefaultFieldSql(sqlBotField, SYS_ORGANIZATION_USER_PREFIX); + } else { + return getDefaultFieldSql(sqlBotField); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/TablePermissionHandler.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/TablePermissionHandler.java new file mode 100644 index 0000000..bfd156f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/TablePermissionHandler.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.integration.sqlbot.handler; + + +import cn.cordys.crm.integration.sqlbot.dto.TableDTO; +import cn.cordys.crm.integration.sqlbot.dto.TableHandleParam; + +/** + * 表的权限接口 + */ +public interface TablePermissionHandler { + /** + * 处理表 + * + * @param table 表信息 + * @param tableHandleParam 处理参数 + */ + void handleTable(TableDTO table, TableHandleParam tableHandleParam); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/TablePermissionHandlerFactory.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/TablePermissionHandlerFactory.java new file mode 100644 index 0000000..46bf6e5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/TablePermissionHandlerFactory.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.integration.sqlbot.handler; + +import cn.cordys.crm.integration.sqlbot.constant.SQLBotTable; + +import java.util.HashMap; +import java.util.Map; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-05 09:59 + */ +public class TablePermissionHandlerFactory { + private static final Map tableHandlerMap = new HashMap<>(); + + public static void registerTableHandler(SQLBotTable sqlBotTable, TablePermissionHandler tablePermissionHandler) { + tableHandlerMap.put(sqlBotTable, tablePermissionHandler); + } + + public static TablePermissionHandler getTableHandler(SQLBotTable sqlBotTable) { + return tableHandlerMap.get(sqlBotTable); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/CheckboxParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/CheckboxParser.java new file mode 100644 index 0000000..167cb75 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/CheckboxParser.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.integration.sqlbot.handler.field; + + +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.integration.sqlbot.handler.field.api.MultipleOptionFieldParser; +import cn.cordys.crm.system.dto.field.CheckBoxField; + +public class CheckboxParser extends MultipleOptionFieldParser { + + @Override + public String parseSql(String filedValueTableName, CheckBoxField field) { + return parseOptionFieldSql(filedValueTableName, field, field.getOptions()); + } + + @Override + public FieldDTO parseToSQLBotField(CheckBoxField field) { + return parse2SQLBotField(field, field.getOptions()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/DatasourceMultipleParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/DatasourceMultipleParser.java new file mode 100644 index 0000000..1a71040 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/DatasourceMultipleParser.java @@ -0,0 +1,39 @@ +package cn.cordys.crm.integration.sqlbot.handler.field; + + +import cn.cordys.crm.integration.sqlbot.handler.field.api.TextFieldParser; +import cn.cordys.crm.system.constants.FieldSourceType; +import cn.cordys.crm.system.dto.field.DatasourceMultipleField; +import org.apache.commons.lang3.Strings; + +import java.text.MessageFormat; + +public class DatasourceMultipleParser extends TextFieldParser { + + public static final String MEMBER_OPTION_FIELD_SQL_TEMPLATE = """ + ( + SELECT JSON_ARRAYAGG(ds.name) + FROM {0} f + join {1} ds on f.field_value like concat(''%'', ds.id, ''%'') + WHERE f.resource_id = c.id AND f.field_id = ''{2}'' + LIMIT 1 + ) AS ''{3}'' + """; + + @Override + public String parseSql(String fieldValueTable, DatasourceMultipleField field) { + String datasourceTableName = field.getDataSourceType().toLowerCase(); + if (Strings.CI.equals(datasourceTableName, FieldSourceType.CONTACT.name())) { + datasourceTableName = "customer_contact"; + } + if (Strings.CI.equals(datasourceTableName, FieldSourceType.QUOTATION.name())) { + datasourceTableName = "opportunity_quotation"; + } + return MessageFormat.format(MEMBER_OPTION_FIELD_SQL_TEMPLATE, + fieldValueTable + "_blob", + datasourceTableName, + field.getId(), + field.getId() + ); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/DatasourceParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/DatasourceParser.java new file mode 100644 index 0000000..fecd362 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/DatasourceParser.java @@ -0,0 +1,39 @@ +package cn.cordys.crm.integration.sqlbot.handler.field; + + +import cn.cordys.crm.integration.sqlbot.handler.field.api.TextFieldParser; +import cn.cordys.crm.system.constants.FieldSourceType; +import cn.cordys.crm.system.dto.field.DatasourceField; +import org.apache.commons.lang3.Strings; + +import java.text.MessageFormat; + +public class DatasourceParser extends TextFieldParser { + + public static final String MEMBER_OPTION_FIELD_SQL_TEMPLATE = """ + ( + SELECT ds.name + FROM {0} f + join {1} ds on ds.id = f.field_value + WHERE f.resource_id = c.id AND f.field_id = ''{2}'' + LIMIT 1 + ) AS ''{3}'' + """; + + @Override + public String parseSql(String fieldValueTable, DatasourceField field) { + String datasourceTableName = field.getDataSourceType().toLowerCase(); + if (Strings.CI.equals(datasourceTableName, FieldSourceType.CONTACT.name())) { + datasourceTableName = "customer_contact"; + } + if (Strings.CI.equals(datasourceTableName, FieldSourceType.QUOTATION.name())) { + datasourceTableName = "opportunity_quotation"; + } + return MessageFormat.format(MEMBER_OPTION_FIELD_SQL_TEMPLATE, + fieldValueTable, + datasourceTableName, + field.getId(), + field.getId() + ); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/DateTimeParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/DateTimeParser.java new file mode 100644 index 0000000..c701f36 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/DateTimeParser.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.integration.sqlbot.handler.field; + + +import cn.cordys.crm.integration.sqlbot.handler.field.api.TextFieldParser; +import cn.cordys.crm.system.dto.field.DateTimeField; + +import java.text.MessageFormat; + +public class DateTimeParser extends TextFieldParser { + public static final String DATE_TIME_FIELD_SQL_TEMPLATE = """ + ( + SELECT DATE_FORMAT(FROM_UNIXTIME(f.field_value / 1000),''%Y-%m-%d %H:%i:%s'') + FROM {0} f + WHERE f.resource_id = c.id AND f.field_id = ''{1}'' + LIMIT 1 + ) AS ''{2}'' + """; + + @Override + public String parseSql(String fieldValueTable, DateTimeField field) { + return MessageFormat.format(DATE_TIME_FIELD_SQL_TEMPLATE, + fieldValueTable, + field.getId(), + field.getId() + ); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/DepartmentMultipleParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/DepartmentMultipleParser.java new file mode 100644 index 0000000..5f5e0df --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/DepartmentMultipleParser.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.integration.sqlbot.handler.field; + + +import cn.cordys.crm.integration.sqlbot.handler.field.api.TextFieldParser; +import cn.cordys.crm.system.dto.field.DepartmentMultipleField; + +import java.text.MessageFormat; + +public class DepartmentMultipleParser extends TextFieldParser { + + public static final String MULTIPLE_DEPARTMENT_OPTION_FIELD_SQL_TEMPLATE = """ + ( + SELECT JSON_ARRAYAGG(sd.name) + FROM {0} f + join sys_department sd on f.field_value like concat(''%'', sd.id, ''%'') + WHERE f.resource_id = c.id AND f.field_id = ''{1}'' + LIMIT 1 + ) AS ''{2}'' + """; + + @Override + public String parseSql(String fieldValueTable, DepartmentMultipleField field) { + return MessageFormat.format(MULTIPLE_DEPARTMENT_OPTION_FIELD_SQL_TEMPLATE, + fieldValueTable + "_blob", + field.getId(), + field.getId() + ); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/DepartmentParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/DepartmentParser.java new file mode 100644 index 0000000..bd80f53 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/DepartmentParser.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.integration.sqlbot.handler.field; + + +import cn.cordys.crm.integration.sqlbot.handler.field.api.TextFieldParser; +import cn.cordys.crm.system.dto.field.DepartmentField; + +import java.text.MessageFormat; + +public class DepartmentParser extends TextFieldParser { + + public static final String DEPARTMENT_OPTION_FIELD_SQL_TEMPLATE = """ + ( + SELECT sd.name + FROM {0} f + join sys_department sd on sd.id = f.field_value + WHERE f.resource_id = c.id AND f.field_id = ''{1}'' + LIMIT 1 + ) AS ''{2}'' + """; + + @Override + public String parseSql(String fieldValueTable, DepartmentField field) { + return MessageFormat.format(DEPARTMENT_OPTION_FIELD_SQL_TEMPLATE, + fieldValueTable, + field.getId(), + field.getId() + ); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/InputMultipleParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/InputMultipleParser.java new file mode 100644 index 0000000..555df63 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/InputMultipleParser.java @@ -0,0 +1,8 @@ +package cn.cordys.crm.integration.sqlbot.handler.field; + + +import cn.cordys.crm.integration.sqlbot.handler.field.api.TextFieldParser; +import cn.cordys.crm.system.dto.field.InputMultipleField; + +public class InputMultipleParser extends TextFieldParser { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/InputParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/InputParser.java new file mode 100644 index 0000000..c6b9bc5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/InputParser.java @@ -0,0 +1,8 @@ +package cn.cordys.crm.integration.sqlbot.handler.field; + + +import cn.cordys.crm.integration.sqlbot.handler.field.api.TextFieldParser; +import cn.cordys.crm.system.dto.field.InputField; + +public class InputParser extends TextFieldParser { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/MemberMultipleParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/MemberMultipleParser.java new file mode 100644 index 0000000..f25e8d8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/MemberMultipleParser.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.integration.sqlbot.handler.field; + + +import cn.cordys.crm.integration.sqlbot.handler.field.api.TextFieldParser; +import cn.cordys.crm.system.dto.field.MemberMultipleField; + +import java.text.MessageFormat; + +public class MemberMultipleParser extends TextFieldParser { + + public static final String MULTIPLE_MEMBER_OPTION_FIELD_SQL_TEMPLATE = """ + ( + SELECT JSON_ARRAYAGG(su.name) + FROM {0} f + join sys_user su on f.field_value like concat(''%'', su.id, ''%'') + WHERE f.resource_id = c.id AND f.field_id = ''{1}'' + LIMIT 1 + ) AS ''{2}'' + """; + + @Override + public String parseSql(String fieldValueTable, MemberMultipleField field) { + return MessageFormat.format(MULTIPLE_MEMBER_OPTION_FIELD_SQL_TEMPLATE, + fieldValueTable + "_blob", + field.getId(), + field.getId() + ); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/MemberParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/MemberParser.java new file mode 100644 index 0000000..43b80a2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/MemberParser.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.integration.sqlbot.handler.field; + + +import cn.cordys.crm.integration.sqlbot.handler.field.api.TextFieldParser; +import cn.cordys.crm.system.dto.field.MemberField; + +import java.text.MessageFormat; + +public class MemberParser extends TextFieldParser { + + public static final String MEMBER_OPTION_FIELD_SQL_TEMPLATE = """ + ( + SELECT su.name + FROM {0} f + join sys_user su on su.id = f.field_value + WHERE f.resource_id = c.id AND f.field_id = ''{1}'' + LIMIT 1 + ) AS ''{2}'' + """; + + @Override + public String parseSql(String fieldValueTable, MemberField field) { + return MessageFormat.format(MEMBER_OPTION_FIELD_SQL_TEMPLATE, + fieldValueTable, + field.getId(), + field.getId() + ); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/PhoneParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/PhoneParser.java new file mode 100644 index 0000000..aa595db --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/PhoneParser.java @@ -0,0 +1,8 @@ +package cn.cordys.crm.integration.sqlbot.handler.field; + + +import cn.cordys.crm.integration.sqlbot.handler.field.api.TextFieldParser; +import cn.cordys.crm.system.dto.field.PhoneField; + +public class PhoneParser extends TextFieldParser { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/RadioParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/RadioParser.java new file mode 100644 index 0000000..e63535d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/RadioParser.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.integration.sqlbot.handler.field; + + +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.integration.sqlbot.handler.field.api.OptionFieldParser; +import cn.cordys.crm.system.dto.field.RadioField; + +public class RadioParser extends OptionFieldParser { + + @Override + public String parseSql(String filedValueTableName, RadioField field) { + return parseOptionFieldSql(filedValueTableName, field, field.getOptions()); + } + + @Override + public FieldDTO parseToSQLBotField(RadioField field) { + return parse2SQLBotField(field, field.getOptions()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/SelectMultipleParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/SelectMultipleParser.java new file mode 100644 index 0000000..5de39f4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/SelectMultipleParser.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.integration.sqlbot.handler.field; + + +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.integration.sqlbot.handler.field.api.MultipleOptionFieldParser; +import cn.cordys.crm.system.dto.field.SelectMultipleField; + +public class SelectMultipleParser extends MultipleOptionFieldParser { + + @Override + public String parseSql(String filedValueTableName, SelectMultipleField field) { + return parseOptionFieldSql(filedValueTableName, field, field.getOptions()); + } + + @Override + public FieldDTO parseToSQLBotField(SelectMultipleField field) { + return parse2SQLBotField(field, field.getOptions()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/SelectParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/SelectParser.java new file mode 100644 index 0000000..5a7f14b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/SelectParser.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.integration.sqlbot.handler.field; + + +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.integration.sqlbot.handler.field.api.OptionFieldParser; +import cn.cordys.crm.system.dto.field.SelectField; + +public class SelectParser extends OptionFieldParser { + + @Override + public String parseSql(String filedValueTableName, SelectField field) { + return parseOptionFieldSql(filedValueTableName, field, field.getOptions()); + } + + @Override + public FieldDTO parseToSQLBotField(SelectField field) { + return parse2SQLBotField(field, field.getOptions()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/SerialNumberParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/SerialNumberParser.java new file mode 100644 index 0000000..cd3f3bb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/SerialNumberParser.java @@ -0,0 +1,8 @@ +package cn.cordys.crm.integration.sqlbot.handler.field; + + +import cn.cordys.crm.integration.sqlbot.handler.field.api.TextFieldParser; +import cn.cordys.crm.system.dto.field.SerialNumberField; + +public class SerialNumberParser extends TextFieldParser { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/TextareaParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/TextareaParser.java new file mode 100644 index 0000000..a495a51 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/TextareaParser.java @@ -0,0 +1,8 @@ +package cn.cordys.crm.integration.sqlbot.handler.field; + + +import cn.cordys.crm.integration.sqlbot.handler.field.api.TextFieldParser; +import cn.cordys.crm.system.dto.field.TextAreaField; + +public class TextareaParser extends TextFieldParser { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/DefaultParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/DefaultParser.java new file mode 100644 index 0000000..5765b51 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/DefaultParser.java @@ -0,0 +1,30 @@ +package cn.cordys.crm.integration.sqlbot.handler.field.api; + + +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.system.dto.field.base.BaseField; + +import java.text.MessageFormat; + +public class DefaultParser implements ModuleFieldParser { + + public static final String DEFAULT_FIELD_SQL_TEMPLATE = """ + ''选项不存在'' AS ''{0}'' + """; + + @Override + public String parseSql(String filedValueTableName, BaseField field) { + return MessageFormat.format(DEFAULT_FIELD_SQL_TEMPLATE, + field.getId() + ); + } + + @Override + public FieldDTO parseToSQLBotField(BaseField field) { + FieldDTO fieldDTO = new FieldDTO(); + fieldDTO.setName(field.getId()); + fieldDTO.setType("text"); + fieldDTO.setComment(field.getName()); + return fieldDTO; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/ModuleFieldParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/ModuleFieldParser.java new file mode 100644 index 0000000..5290e42 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/ModuleFieldParser.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.integration.sqlbot.handler.field.api; + + +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.system.dto.field.base.BaseField; + +/** + * 将模块字段转换为待拼接的SQL语句 + */ +public interface ModuleFieldParser { + /** + * 编写sql模板 + * + * @param field 字段信息 + */ + String parseSql(String filedValueTableName, T field); + + /** + * 将字段转换为SQLBot的FieldDTO对象 + * + * @param field 字段信息 + * + * @return 转换后的FieldDTO对象 + */ + FieldDTO parseToSQLBotField(T field); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/ModuleFieldParserFactory.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/ModuleFieldParserFactory.java new file mode 100644 index 0000000..6beb853 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/ModuleFieldParserFactory.java @@ -0,0 +1,45 @@ +package cn.cordys.crm.integration.sqlbot.handler.field.api; + +import cn.cordys.crm.integration.sqlbot.handler.field.*; +import cn.cordys.crm.system.constants.FieldType; + +import java.util.HashMap; +import java.util.Map; + +/** + * @Author: jianxing + * @CreateTime: 2025-08-05 09:59 + */ +public class ModuleFieldParserFactory { + private static final Map> MODULE_FIELD_PARSER_MAP = new HashMap<>(); + private static final DefaultParser DEFAULT_PARSER = new DefaultParser(); + + static { + registerFieldParser(FieldType.SELECT, new SelectParser()); + registerFieldParser(FieldType.SELECT_MULTIPLE, new SelectMultipleParser()); + registerFieldParser(FieldType.RADIO, new RadioParser()); + registerFieldParser(FieldType.CHECKBOX, new CheckboxParser()); + + registerFieldParser(FieldType.INPUT, new InputParser()); + registerFieldParser(FieldType.INPUT_MULTIPLE, new InputMultipleParser()); + registerFieldParser(FieldType.TEXTAREA, new TextareaParser()); + registerFieldParser(FieldType.PHONE, new PhoneParser()); + registerFieldParser(FieldType.SERIAL_NUMBER, new SerialNumberParser()); + + registerFieldParser(FieldType.DATE_TIME, new DateTimeParser()); + registerFieldParser(FieldType.MEMBER, new MemberParser()); + registerFieldParser(FieldType.MEMBER_MULTIPLE, new MemberMultipleParser()); + registerFieldParser(FieldType.DEPARTMENT, new DepartmentParser()); + registerFieldParser(FieldType.DEPARTMENT_MULTIPLE, new DepartmentMultipleParser()); + registerFieldParser(FieldType.DATA_SOURCE, new DatasourceParser()); + registerFieldParser(FieldType.DATA_SOURCE_MULTIPLE, new DatasourceMultipleParser()); + } + + private static void registerFieldParser(FieldType fieldType, ModuleFieldParser parser) { + MODULE_FIELD_PARSER_MAP.put(fieldType.name(), parser); + } + + public static ModuleFieldParser getFieldParser(String fieldType) { + return MODULE_FIELD_PARSER_MAP.get(fieldType) != null ? MODULE_FIELD_PARSER_MAP.get(fieldType) : DEFAULT_PARSER; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/MultipleOptionFieldParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/MultipleOptionFieldParser.java new file mode 100644 index 0000000..e55778b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/MultipleOptionFieldParser.java @@ -0,0 +1,47 @@ +package cn.cordys.crm.integration.sqlbot.handler.field.api; + + +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import org.apache.commons.collections4.CollectionUtils; + +import java.text.MessageFormat; +import java.util.List; + +public abstract class MultipleOptionFieldParser extends OptionFieldParser { + + public static final String MULTIPLE_OPTION_FIELD_SQL_TEMPLATE = """ + ( + SELECT JSON_ARRAYAGG(option_t.label) + FROM {0} f + left join ( + {1} + ) option_t on f.field_value like concat(''%'', option_t.value, ''%'') + WHERE option_t.label is not null and f.resource_id = c.id AND f.field_id = ''{2}'' + LIMIT 1 + ) AS ''{3}'' + """; + + @Override + protected String parseOptionFieldSql(String fieldValueTable, BaseField field, List options) { + if (CollectionUtils.isEmpty(options)) { + return parseEmptyOptionSql(field); + } + return MessageFormat.format(MULTIPLE_OPTION_FIELD_SQL_TEMPLATE, + fieldValueTable + "_blob", + parseOptionSql(options), + field.getId(), + field.getId() + ); + } + + @Override + public FieldDTO parse2SQLBotField(BaseField field, List options) { + FieldDTO fieldDTO = new FieldDTO(); + fieldDTO.setName(field.getId()); + fieldDTO.setType("text"); + fieldDTO.setComment(field.getName() + getOptionLabelStr(options)); + return fieldDTO; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/OptionFieldParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/OptionFieldParser.java new file mode 100644 index 0000000..003505f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/OptionFieldParser.java @@ -0,0 +1,96 @@ +package cn.cordys.crm.integration.sqlbot.handler.field.api; + + +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import java.text.MessageFormat; +import java.util.List; +import java.util.stream.Collectors; + +public abstract class OptionFieldParser implements ModuleFieldParser { + + public static final String SINGLE_OPTION_FIELD_SQL_TEMPLATE = """ + ( + SELECT option_t.label + FROM {0} f + left join ( + {1} + ) option_t on option_t.value = f.field_value + WHERE f.resource_id = c.id AND f.field_id = ''{2}'' + LIMIT 1 + ) AS ''{3}'' + """; + + /** + * 将选项列表转换为SQL查询语句 + * 例如: + * SELECT '400电话' AS label, '1' AS value + * UNION ALL SELECT '官网咨询', '2' + * + * @param options + * + * @return + */ + protected static String parseOptionSql(List options) { + if (CollectionUtils.isEmpty(options)) { + return null; + } + + StringBuilder sqlBuffer = new StringBuilder(); + + for (int i = 0; i < options.size(); i++) { + OptionProp option = options.get(i); + if (option == null || StringUtils.isAnyBlank(option.getValue(), option.getLabel())) { + continue; + } + + String label = option.getLabel().replace("'", "''"); + String value = option.getValue().replace("'", "''"); + + if (i == 0) { + sqlBuffer.append(String.format("SELECT '%s' AS label, '%s' AS value", label, value)); + } else { + sqlBuffer.append(String.format(" UNION ALL SELECT '%s', '%s'", label, value)); + } + } + + return sqlBuffer.toString(); + } + + protected String parseOptionFieldSql(String fieldValueTable, BaseField field, List options) { + if (CollectionUtils.isEmpty(options)) { + return parseEmptyOptionSql(field); + } + return MessageFormat.format(SINGLE_OPTION_FIELD_SQL_TEMPLATE, + fieldValueTable, + parseOptionSql(options), + field.getId(), + field.getId() + ); + } + + public FieldDTO parse2SQLBotField(BaseField field, List options) { + FieldDTO fieldDTO = new FieldDTO(); + fieldDTO.setName(field.getId()); + fieldDTO.setType("varchar(50)"); + fieldDTO.setComment(field.getName() + getOptionLabelStr(options)); + return fieldDTO; + } + + protected String getOptionLabelStr(List options) { + return ",可选值:(" + + options + .stream() + .map(OptionProp::getLabel) + .collect(Collectors.joining(",")) + + ")"; + } + + protected String parseEmptyOptionSql(BaseField field) { + return "'' as " + field.getId(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/TextFieldParser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/TextFieldParser.java new file mode 100644 index 0000000..606a15b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/handler/field/api/TextFieldParser.java @@ -0,0 +1,43 @@ +package cn.cordys.crm.integration.sqlbot.handler.field.api; + + +import cn.cordys.crm.integration.sqlbot.dto.FieldDTO; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.dto.field.base.BaseField; +import org.apache.commons.lang3.Strings; + +import java.text.MessageFormat; + +public abstract class TextFieldParser implements ModuleFieldParser { + + public static final String TEXT_FIELD_SQL_TEMPLATE = """ + ( + SELECT f.field_value + FROM {0} f + WHERE f.resource_id = c.id AND f.field_id = ''{1}'' + LIMIT 1 + ) AS ''{2}'' + """; + + @Override + public String parseSql(String fieldValueTable, T field) { + return MessageFormat.format(TEXT_FIELD_SQL_TEMPLATE, + fieldValueTable, + field.getId(), + field.getId() + ); + } + + @Override + public FieldDTO parseToSQLBotField(BaseField field) { + FieldDTO fieldDTO = new FieldDTO(); + fieldDTO.setName(field.getId()); + if (field.multiple() || Strings.CS.equals(field.getType(), FieldType.TEXTAREA.name())) { + fieldDTO.setType("text"); + } else { + fieldDTO.setType("varchar(255)"); + } + fieldDTO.setComment(field.getName()); + return fieldDTO; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/mapper/ExtDataSourceMapper.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/mapper/ExtDataSourceMapper.java new file mode 100644 index 0000000..6979325 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/mapper/ExtDataSourceMapper.java @@ -0,0 +1,8 @@ +package cn.cordys.crm.integration.sqlbot.mapper; + +import org.apache.ibatis.annotations.Param; + + +public interface ExtDataSourceMapper { + String selectSchemaByDbName(@Param("dbName") String dbName); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/mapper/ExtDataSourceMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/mapper/ExtDataSourceMapper.xml new file mode 100644 index 0000000..455b844 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/mapper/ExtDataSourceMapper.xml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/service/DataSourceService.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/service/DataSourceService.java new file mode 100644 index 0000000..9a365af --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sqlbot/service/DataSourceService.java @@ -0,0 +1,410 @@ +package cn.cordys.crm.integration.sqlbot.service; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.RoleDataScope; +import cn.cordys.common.constants.ThirdDetailType; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.RoleDataScopeDTO; +import cn.cordys.common.dto.RolePermissionDTO; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.response.handler.RestControllerExceptionHandler; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.util.CodingUtils; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.integration.sqlbot.constant.SQLBotTable; +import cn.cordys.crm.integration.sqlbot.dto.*; +import cn.cordys.crm.integration.sqlbot.handler.TablePermissionHandler; +import cn.cordys.crm.integration.sqlbot.handler.TablePermissionHandlerFactory; +import cn.cordys.crm.integration.sqlbot.mapper.ExtDataSourceMapper; +import cn.cordys.crm.system.domain.OrganizationConfigDetail; +import cn.cordys.crm.system.domain.OrganizationUser; +import cn.cordys.crm.system.domain.RoleScopeDept; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.crm.system.service.RoleService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.function.Function; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * 数据源服务,用于处理数据库连接和表结构信息 + */ +@Service +@Slf4j +public class DataSourceService { + + private static final DatabaseConfig MYSQL_CONFIG = new DatabaseConfig( + "jdbc:mysql://", + List.of("sys_", "qrtz_"), + 3306 + ); + // MySQL JDBC URL解析正则 + private static final Pattern MYSQL_URL_PATTERN = + Pattern.compile("jdbc:mysql://([^:/]+)(?::(\\d+))?/([^?]+)(?:\\?(.*))?"); + private final String url; + private final String username; + private final String password; + @Resource + private DataScopeService dataScopeService; + @Resource + private PermissionCache permissionCache; + @Resource + private DepartmentService departmentService; + @Resource + private RoleService roleService; + @Value("${sqlbot.encrypt:false}") + private boolean encryptEnabled; + @Value("${sqlbot.aes-key:${random.value}}") + private String aesKey; + @Value("${sqlbot.aes-iv:${random.value}}") + private String aesIv; + @Resource + private ExtDataSourceMapper extDataSourceMapper; + @Resource + private BaseMapper organizationConfigDetailMapper; + + /** + * 构造函数依赖注入 + */ + public DataSourceService( + @Value("${spring.datasource.url}") String url, + @Value("${sqlbot.datasource.username:${spring.datasource.username}}") String username, + @Value("${sqlbot.datasource.password:${spring.datasource.password}}") String password) { + this.url = url; + this.username = username; + this.password = password; + } + + private void aesEncryptDataSource(DataSourceDTO dataSource) { + if (BooleanUtils.isTrue(encryptEnabled)) { + dataSource.setHost(aesEncrypt(dataSource.getHost())); + dataSource.setUser(aesEncrypt(dataSource.getUser())); + dataSource.setPassword(aesEncrypt(dataSource.getPassword())); + dataSource.setDataBase(aesEncrypt(dataSource.getDataBase())); + dataSource.setSchema(aesEncrypt(dataSource.getSchema())); + } + } + + private String aesEncrypt(String text) { + return CodingUtils.aesCBCEncrypt(text, aesKey, aesIv); + } + + /** + * 获取当前数据库模式信息 + * + * @return 数据源DTO列表 + */ + public SQLBotDTO getDatabaseSchema(String userId, String orgId) { + try { + // 验证是否启用了 SQL Bot 功能 + List organizationConfigDetails = organizationConfigDetailMapper.selectListByLambda( + new LambdaQueryWrapper() + .eq(OrganizationConfigDetail::getType, ThirdDetailType.SQLBOT_CHAT.name()) + .eq(OrganizationConfigDetail::getEnable, true) + ); + if (CollectionUtils.isEmpty(organizationConfigDetails)) { + throw new IllegalArgumentException("当前组织未配置 SQL Bot 功能"); + } + + var databaseName = extractDatabaseName(); + var allTables = Objects.requireNonNull(CommonBeanFactory.getBean(DataSourceService.class)).tableList(databaseName); + + Map sqlBotTableMap = Arrays.stream(SQLBotTable.values()) + .collect(Collectors.toMap(SQLBotTable::getTableName, Function.identity())); + + // 获取权限信息 + List rolePermissions = permissionCache.getRolePermissions(userId, orgId); + // 添加虚拟表 + appendVirtualTable(allTables, sqlBotTableMap.values()); + // 过滤符合条件的表 + var filteredTables = filterTable(userId, allTables, sqlBotTableMap, rolePermissions); + + Map deptDataPermissionMap = getDeptDataPermissionMap(userId, orgId, rolePermissions); + + filteredTables.forEach(table -> { + SQLBotTable tableInfo = sqlBotTableMap.get(table.getName()); + TablePermissionHandler tablePermissionHandler = TablePermissionHandlerFactory.getTableHandler(tableInfo); + if (tablePermissionHandler != null) { + // 权限处理器添加sql过滤 + String permission = tableInfo.getPermission(); + DeptDataPermissionDTO dataPermission = deptDataPermissionMap.get(permission); + TableHandleParam tableHandleParam = new TableHandleParam(userId, orgId, tableInfo, dataPermission); + tablePermissionHandler.handleTable(table, tableHandleParam); + } + if (StringUtils.isNotBlank(table.getSql())) { + table.setSql(table.getSql().replace("\n", " ")); + } + }); + + var dataSourceDTO = new DataSourceDTO(); + populateDataSourceProperties(dataSourceDTO); + dataSourceDTO.setTables(filteredTables); + aesEncryptDataSource(dataSourceDTO); + + return SQLBotDTO.builder().code(0).data(List.of(dataSourceDTO)).build(); + } catch (Exception e) { + log.error(e.getMessage(), e); + return SQLBotDTO.builder().code(1).message("获取数据库结构失败: " + RestControllerExceptionHandler.getStackTraceAsString(e)).build(); + } + } + + @Cacheable(value = "table_schema_cache", key = "#databaseName", unless = "#result == null") + public List tableList(String databaseName) { + var schemaJson = extDataSourceMapper.selectSchemaByDbName(databaseName); + return JSON.parseArray(schemaJson, TableDTO.class); + } + + private boolean isAdmin(String userId) { + return Strings.CS.equals(userId, InternalUser.ADMIN.getValue()); + } + + /** + * 过滤符合条件的表 + * + * @param filteredTables + * @param sqlBotTableMap + * @param rolePermissions + * @return + */ + private List filterTable(String userId, List filteredTables, Map sqlBotTableMap, List rolePermissions) { + Set permissionIds = rolePermissions.stream() + .map(RolePermissionDTO::getPermissions) + .flatMap(Set::stream) + .collect(Collectors.toSet()); + + Map tableMap = filteredTables.stream() + .collect(Collectors.toMap(TableDTO::getName, Function.identity())); + + // 过滤符合条件的表 + return filteredTables.stream() + .filter(table -> { + SQLBotTable sqlBotTable = sqlBotTableMap.get(table.getName()); + if (sqlBotTable == null) { + return false; + } + + if (StringUtils.isNotBlank(sqlBotTable.getCopyFromTableName())) { + table.setFields(tableMap.get(sqlBotTable.getCopyFromTableName()).getFields()); + } + + if (isAdmin(userId)) { + return true; + } + + // 替换表注释 + table.setComment(sqlBotTableMap.get(table.getName()).getDescription()); + + if (CollectionUtils.isNotEmpty(sqlBotTable.getDisableFields())) { + // 过滤禁用的字段 + List filterFields = table.getFields() + .stream() + .filter(field -> !sqlBotTable.getDisableFields().contains(field.getName())) + .collect(Collectors.toList()); + table.setFields(filterFields); + } + // 检查权限,null 表示不需要权限 + return sqlBotTable.getPermission() == null || permissionIds.contains(sqlBotTable.getPermission()); + }) + .collect(Collectors.toList()); + } + + /** + * 添加虚拟表 + * + * @param filteredTables + * @param sqlBotTables + */ + private void appendVirtualTable(List filteredTables, Collection sqlBotTables) { + Set tableNames = filteredTables.stream().map(TableDTO::getName) + .collect(Collectors.toSet()); + sqlBotTables.forEach( + sqlBotTable -> { + // 如果表不存在,则将虚拟表添加到结果集中 + if (!tableNames.contains(sqlBotTable.getTableName())) { + TableDTO addTable = new TableDTO(); + addTable.setName(sqlBotTable.getTableName()); + addTable.setComment(sqlBotTable.getDescription()); + filteredTables.add(addTable); + } + } + ); + } + + private Map getDeptDataPermissionMap(String userId, String orgId, List rolePermissions) { + Map deptDataPermissionMap = new HashMap<>(); + + // DataScope和角色的映射 + Map> dataScopeRoleMap = rolePermissions + .stream() + .collect(Collectors.groupingBy(RoleDataScopeDTO::getDataScope, Collectors.toList())); + + // 本部门角色 + List userDeptRoles = dataScopeRoleMap.get(RoleDataScope.DEPT_AND_CHILD.name()); + // 指定部门角色 + List customDeptRoles = dataScopeRoleMap.get(RoleDataScope.DEPT_CUSTOM.name()); + + if (isAdmin(userId)) { + return deptDataPermissionMap; + } + + List tree = List.of(); + if (CollectionUtils.isNotEmpty(userDeptRoles) || CollectionUtils.isNotEmpty(customDeptRoles)) { + // 查询部门树 + tree = departmentService.getTree(orgId); + } + + List currentUserDeptIds = List.of(); + if (CollectionUtils.isNotEmpty(userDeptRoles)) { + // 查看用户部门及其子部门数据 + OrganizationUser organizationUser = dataScopeService.getOrganizationUser(userId, orgId); + currentUserDeptIds = dataScopeService.getDeptIdsWithChild(tree, Set.of(organizationUser.getDepartmentId())); + } + + Map> customDeptRoleDeptMap = new HashMap<>(); + if (CollectionUtils.isNotEmpty(customDeptRoles)) { + // 查看指定部门及其子部门数据 + List customDeptRolesIds = customDeptRoles.stream() + .map(RoleDataScopeDTO::getId) + .toList(); + + List roleScopeDeptList = roleService.getRoleScopeDeptByRoleIds(customDeptRolesIds); + roleScopeDeptList.forEach(roleScopeDept -> { + customDeptRoleDeptMap.putIfAbsent(roleScopeDept.getRoleId(), new ArrayList<>()); + customDeptRoleDeptMap.get(roleScopeDept.getRoleId()).add(roleScopeDept.getDepartmentId()); + }); + } + + for (String dataScopePermission : SQLBotTable.getDataScopePermissions()) { + DeptDataPermissionDTO deptDataPermission = new DeptDataPermissionDTO(); + deptDataPermissionMap.put(dataScopePermission, deptDataPermission); + boolean hasAllPermission = dataScopeService.hasDataScopePermission(dataScopeRoleMap, RoleDataScope.ALL.name(), dataScopePermission); + if (hasAllPermission) { + deptDataPermission.setAll(true); + continue; + } + if (CollectionUtils.isNotEmpty(userDeptRoles)) { + // 添加当前用户的部门及子部门ID + deptDataPermission.getDeptIds().addAll(currentUserDeptIds); + } + if (CollectionUtils.isNotEmpty(customDeptRoles)) { + List parentDeptIds = new ArrayList<>(); + for (RolePermissionDTO customDeptRole : customDeptRoles) { + if (CollectionUtils.isNotEmpty(customDeptRoleDeptMap.get(customDeptRole.getId()))) { + // 获取指定部门角色中的部门Id + List deptIds = customDeptRoleDeptMap.get(customDeptRole.getId()); + parentDeptIds.addAll(deptIds); + } + } + // 获取部门及子部门ID + List deptIds = dataScopeService.getDeptIdsWithChild(tree, new HashSet<>(parentDeptIds)); + deptDataPermission.getDeptIds().addAll(deptIds); + } + } + return deptDataPermissionMap; + } + + /** + * 从数据库URL中提取数据库名称 + */ + private String extractDatabaseName() { + var matcher = MYSQL_URL_PATTERN.matcher(url); + if (matcher.find() && matcher.groupCount() >= 3) { + return matcher.group(3); + } + + // 回退方法 + var parts = url.split("/"); + var dbPart = parts[parts.length - 1]; + return dbPart.contains("?") + ? dbPart.substring(0, dbPart.indexOf("?")) + : dbPart; + } + + /** + * 填充数据源属性 + */ + private void populateDataSourceProperties(DataSourceDTO dataSourceDTO) { + var matcher = MYSQL_URL_PATTERN.matcher(url); + + if (matcher.find() && matcher.groupCount() >= 3) { + var host = matcher.group(1); + var port = matcher.group(2) != null + ? Integer.parseInt(matcher.group(2)) + : MYSQL_CONFIG.defaultPort(); + var database = matcher.group(3); + var params = matcher.groupCount() >= 4 && matcher.group(4) != null + ? matcher.group(4) + : ""; + dataSourceDTO.setHost(host); + dataSourceDTO.setPort(port); + dataSourceDTO.setUser(username); + dataSourceDTO.setPassword(password); + dataSourceDTO.setType("mysql"); + dataSourceDTO.setName(database); + dataSourceDTO.setDataBase(database); + dataSourceDTO.setExtraParams(params); + } else { + fallbackPopulateDataSourceProperties(dataSourceDTO); + } + } + + /** + * 回退方法:填充数据源属性 + */ + private void fallbackPopulateDataSourceProperties(DataSourceDTO dataSourceDTO) { + var strippedUrl = url.replace(MYSQL_CONFIG.jdbcPrefix(), ""); + var urlParts = strippedUrl.split("\\?", 2); + + var hostPortDb = urlParts[0]; + var extraParams = urlParts.length > 1 ? urlParts[1] : ""; + + var hostAndRest = hostPortDb.split("/", 2); + var hostPort = hostAndRest[0].split(":", 2); + + var host = hostPort[0]; + var port = hostPort.length > 1 + ? parsePortSafely(hostPort[1]) + : MYSQL_CONFIG.defaultPort(); + dataSourceDTO.setHost(host); + dataSourceDTO.setPort(port); + dataSourceDTO.setUser(username); + dataSourceDTO.setPassword(password); + dataSourceDTO.setDataBase(extractDatabaseName()); + dataSourceDTO.setExtraParams(extraParams); + } + + /** + * 安全解析端口号 + */ + private int parsePortSafely(String portStr) { + try { + return Integer.parseInt(portStr); + } catch (NumberFormatException e) { + return MYSQL_CONFIG.defaultPort(); + } + } + + /** + * MySQL数据库配置 + */ + private record DatabaseConfig( + String jdbcPrefix, + List excludedTablePrefixes, + int defaultPort + ) { + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sso/controller/SSOController.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sso/controller/SSOController.java new file mode 100644 index 0000000..2df5012 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sso/controller/SSOController.java @@ -0,0 +1,65 @@ +package cn.cordys.crm.integration.sso.controller; + +import cn.cordys.common.util.CodingUtils; +import cn.cordys.common.utils.IpUtils; +import cn.cordys.crm.integration.sso.service.SSOService; +import cn.cordys.security.SessionUser; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.ModelAndView; + +@RestController +@RequestMapping("/sso/callback") +public class SSOController { + @Resource + private SSOService ssoService; + + @GetMapping(value = "/wecom") + @Operation(summary = "获取企业微信登陆验证") + public SessionUser callbackWeCom(@RequestParam("code") String code, HttpServletRequest httpServletRequest) { + return ssoService.exchangeWeComCode(code, IpUtils.getClientIpAddress(httpServletRequest)); + } + + @GetMapping("/oauth/wecom") + public SessionUser callbackWeComOauth(@RequestParam("code") String code, HttpServletRequest httpServletRequest) { + return ssoService.exchangeWeComOauth2(code, IpUtils.getClientIpAddress(httpServletRequest)); + } + + @GetMapping(value = "/ding-talk") + @Operation(summary = "获取钉钉扫码登陆验证") + public SessionUser callbackDingTalk(@RequestParam("code") String code, HttpServletRequest httpServletRequest) { + return ssoService.exchangeDingTalkCode(code, IpUtils.getClientIpAddress(httpServletRequest)); + } + + @GetMapping("/oauth/ding-talk") + public SessionUser callbackDingTalkOauth(@RequestParam("code") String code, HttpServletRequest httpServletRequest) { + return ssoService.exchangeDingTalkOauth2(code, IpUtils.getClientIpAddress(httpServletRequest)); + } + + @GetMapping(value = "/lark") + @Operation(summary = "获取飞书扫码登陆验证") + public SessionUser callbackLark(@RequestParam("code") String code, HttpServletRequest httpServletRequest) { + return ssoService.exchangeLarkCode(code, IpUtils.getClientIpAddress(httpServletRequest)); + } + + @GetMapping("/oauth/lark") + public SessionUser callbackLarkOauth(@RequestParam("code") String code, HttpServletRequest httpServletRequest) { + return ssoService.exchangeLarkOauth2(code, false, IpUtils.getClientIpAddress(httpServletRequest)); + } + + @GetMapping("/oauth/lark-mobile") + public SessionUser callbackLarkOauthByMobile(@RequestParam("code") String code, HttpServletRequest httpServletRequest) { + return ssoService.exchangeLarkOauth2(code, true, IpUtils.getClientIpAddress(httpServletRequest)); + } + + @GetMapping("/oauth/github") + public ModelAndView callbackOauth(@RequestParam("code") String code, HttpServletRequest httpServletRequest) { + SessionUser sessionUser = ssoService.exchangeGitOauth2(code, IpUtils.getClientIpAddress(httpServletRequest)); + return new ModelAndView("redirect:/#/?_token=" + CodingUtils.base64Encoding(sessionUser.getSessionId()) + "&_csrf=" + sessionUser.getCsrfToken()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sso/service/SSOService.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sso/service/SSOService.java new file mode 100644 index 0000000..f356471 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sso/service/SSOService.java @@ -0,0 +1,460 @@ +package cn.cordys.crm.integration.sso.service; + +import cn.cordys.common.constants.ThirdConfigTypeConstants; +import cn.cordys.common.constants.ThirdDetailType; +import cn.cordys.common.constants.UserSource; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.request.LoginRequest; +import cn.cordys.common.util.CodingUtils; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.integration.common.dto.ThirdConfigBaseDTO; +import cn.cordys.crm.integration.common.request.DingTalkThirdConfigRequest; +import cn.cordys.crm.integration.common.request.LarkThirdConfigRequest; +import cn.cordys.crm.integration.common.request.WecomThirdConfigRequest; +import cn.cordys.crm.integration.common.service.OAuthUserService; +import cn.cordys.crm.integration.dingtalk.response.DingTalkUserResponse; +import cn.cordys.crm.integration.wecom.response.WeComUserResponse; +import cn.cordys.crm.system.constants.OrganizationConfigConstants; +import cn.cordys.crm.system.domain.OrganizationConfig; +import cn.cordys.crm.system.domain.OrganizationConfigDetail; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.domain.UserExtend; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigDetailMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationUserMapper; +import cn.cordys.crm.system.service.IntegrationConfigService; +import cn.cordys.crm.system.service.UserLoginService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.security.SessionUser; +import cn.cordys.security.UserDTO; +import com.fasterxml.jackson.core.type.TypeReference; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.authc.AuthenticationException; +import org.apache.shiro.authc.DisabledAccountException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Service +@Transactional(rollbackFor = Exception.class) +public class SSOService { + + private static final String DEFAULT_ORGANIZATION_ID = "100001"; + private static final String ERROR_CODE_NOT_EXIST = "code_not_exist"; + private static final String ERROR_AUTH_SETTING_NOT_EXISTS = "auth.setting.no.exists"; + private static final String ERROR_AUTH_GET_USER_ERROR = "auth.get.user.error"; + private static final String ERROR_USER_NOT_EXIST = "user_not_exist"; + private static final String ERROR_AUTH_LOGIN_DISABLE = "auth.login.un_enable"; + private static final String ERROR_THIRD_CONFIG_NOT_EXIST = "third.config.not.exist"; + private static final String ERROR_THIRD_CONFIG_DISABLE = "third.config.un.enable"; + @Resource + private ExtOrganizationUserMapper extOrganizationUserMapper; + @Resource + private ExtOrganizationConfigDetailMapper extOrganizationConfigDetailMapper; + @Resource + private ExtOrganizationConfigMapper extOrganizationConfigMapper; + @Resource + private BaseMapper userBaseMapper; + @Resource + private BaseMapper userExtendBaseMapper; + @Resource + private UserLoginService userLoginService; + @Resource + private OAuthUserService oauthUserService; + @Resource + private TokenService tokenService; + + public SessionUser exchangeGitOauth2(String code, String ip) { + validateCode(code); + + OrganizationConfigDetail configDetail = getAuthConfigDetail(UserSource.GITHUB_OAUTH2.toString()); + String content = new String(configDetail.getContent(), StandardCharsets.UTF_8); + Map config = JSON.parseObject(content, new TypeReference>() { + }); + + Map resultObj = getGitHubUserInfo(code, config); + + // 解析用户信息映射 + Map mapping = JSON.parseObject(config.get("mapping"), new TypeReference>() { + }); + String userId = (String) resultObj.get(mapping.get("id")); + String name = (String) resultObj.get(mapping.get("name")); + String email = (String) resultObj.get(mapping.get("email")); + String avatar = (String) resultObj.get(mapping.get("avatar_url")); + + // 查找并更新用户信息 + UserDTO enableUser = getUserAndValidate(userId); + updateUserInfo(enableUser.getId(), name, email, avatar); + + // 创建登录请求 + LoginRequest loginRequest = createLoginRequest( + name, + enableUser.getPassword(), + ThirdConfigTypeConstants.WECOM.name() + ); + loginRequest.setLoginAddress(ip); + return userLoginService.login(loginRequest); + } + + public SessionUser exchangeWeComOauth2(String code, String ip) { + validateCode(code); + + OrganizationConfigDetail configDetail = getAuthConfigDetail(ThirdDetailType.WECOM_SYNC.toString()); + String content = new String(configDetail.getContent(), StandardCharsets.UTF_8); + ThirdConfigBaseDTO config = JSON.parseObject(content, ThirdConfigBaseDTO.class); + + if (config == null) { + throw new AuthenticationException(Translator.get(ERROR_AUTH_SETTING_NOT_EXISTS)); + } + WecomThirdConfigRequest weComConfig; + if (config.getConfig() == null) { + weComConfig = JSON.parseObject(content, WecomThirdConfigRequest.class); + } else { + weComConfig = JSON.MAPPER.convertValue(config.getConfig(), WecomThirdConfigRequest.class); + } + return getWeComSessionUser(code, weComConfig, UserSource.WECOM_OAUTH2.toString(), ip); + } + + public SessionUser exchangeWeComCode(String code, String ip) { + validateCode(code); + + ThirdConfigBaseDTO config = getThirdPartyConfig(ThirdConfigTypeConstants.WECOM.name()); + WecomThirdConfigRequest weComConfig = JSON.MAPPER.convertValue(config.getConfig(), WecomThirdConfigRequest.class); + validateQrCodeEnabled(weComConfig.getStartEnable()); + + return getWeComSessionUser(code, weComConfig, UserSource.QR_CODE.toString(), ip); + } + + public SessionUser exchangeDingTalkCode(String code, String ip) { + validateCode(code); + + ThirdConfigBaseDTO config = getThirdPartyConfig(ThirdConfigTypeConstants.DINGTALK.name()); + DingTalkThirdConfigRequest dingTalkConfig = JSON.MAPPER.convertValue(config.getConfig(), DingTalkThirdConfigRequest.class); + validateQrCodeEnabled(dingTalkConfig.getStartEnable()); + + return getDingTalkSessionUser(code, dingTalkConfig, UserSource.QR_CODE.toString(), ip); + } + + private SessionUser getWeComSessionUser(String code, WecomThirdConfigRequest weComConfig, String authenticateType, String ip) { + // 获取assess_token + String assessToken = tokenService.getAssessToken(weComConfig.getCorpId(), weComConfig.getAppSecret()); + if (StringUtils.isBlank(assessToken)) { + throw new AuthenticationException(Translator.get(ERROR_AUTH_GET_USER_ERROR)); + } + + // 读取用户信息 + WeComUserResponse weComUser = oauthUserService.getWeComUser(assessToken, code); + String email = StringUtils.isNotBlank(weComUser.getBizMail()) ? weComUser.getBizMail() : weComUser.getEmail(); + + // 查找并验证用户 + UserDTO enableUser = getUserAndValidateEnable(weComUser.getUserId()); + + // 只有OAuth2登录时才更新用户信息 + if (Strings.CI.equals(authenticateType, UserSource.WECOM_OAUTH2.toString())) { + updateWeComUserInfo(enableUser, weComUser, email); + } + + // 创建登录请求并登录 + LoginRequest loginRequest = createThirdPartyLoginRequest( + enableUser, + ThirdConfigTypeConstants.WECOM.name(), + authenticateType + ); + loginRequest.setLoginAddress(ip); + SecurityUtils.getSubject().getSession().setAttribute("authenticate", authenticateType); + return userLoginService.login(loginRequest); + } + + private SessionUser getDingTalkSessionUser(String code, DingTalkThirdConfigRequest dingTalkConfig, String authenticateType, String ip) { + // 获取用户assess_token + String assessToken = tokenService.getDingTalkUserToken(dingTalkConfig.getAgentId(), dingTalkConfig.getAppSecret(), code); + if (StringUtils.isBlank(assessToken)) { + throw new GenericException(Translator.get(ERROR_AUTH_GET_USER_ERROR)); + } + + // 读取用户信息 + DingTalkUserResponse dingTalkUserResponse = oauthUserService.getDingTalkUser(assessToken); + + String dingTalkToken = tokenService.getDingTalkToken(dingTalkConfig.getAgentId(), dingTalkConfig.getAppSecret()); + //根据unionid查询用户信息 + String userIdByUnionId = oauthUserService.getUserIdByUnionId(dingTalkToken, dingTalkUserResponse.getUnionId()); + + // 查找并验证用户 + UserDTO enableUser = getUserAndValidateEnable(userIdByUnionId); + + // 只有OAuth2登录时才更新用户信息 + if (Strings.CI.equals(authenticateType, UserSource.DINGTALK_OAUTH2.toString())) { + updateDingTalkUserInfo(enableUser, dingTalkUserResponse); + } + + // 创建登录请求并登录 + LoginRequest loginRequest = createThirdPartyLoginRequest( + enableUser, + ThirdConfigTypeConstants.DINGTALK.name(), + authenticateType + ); + + loginRequest.setLoginAddress(ip); + SecurityUtils.getSubject().getSession().setAttribute("authenticate", authenticateType); + return userLoginService.login(loginRequest); + } + + // 辅助方法 + private void validateCode(String code) { + if (StringUtils.isBlank(code)) { + throw new GenericException(Translator.get(ERROR_CODE_NOT_EXIST)); + } + } + + private OrganizationConfigDetail getAuthConfigDetail(String source) { + // 获取组织配置 + OrganizationConfig organizationConfig = extOrganizationConfigMapper.getOrganizationConfig( + DEFAULT_ORGANIZATION_ID, + OrganizationConfigConstants.ConfigType.THIRD.name() + ); + if (organizationConfig == null) { + throw new GenericException(Translator.get(ERROR_AUTH_SETTING_NOT_EXISTS)); + } + + // 获取指定认证源的配置明细 + List enableOrganizationConfigDetails = + extOrganizationConfigDetailMapper.getEnableOrganizationConfigDetails( + organizationConfig.getId(), + List.of(source) + ); + if (CollectionUtils.isEmpty(enableOrganizationConfigDetails)) { + throw new GenericException(Translator.get(ERROR_AUTH_SETTING_NOT_EXISTS)); + } + + return enableOrganizationConfigDetails.getFirst(); + } + + private Map getGitHubUserInfo(String code, Map config) { + try { + String accessToken = tokenService.getGitHubOAuth2Token(code, config); + String userInfoUrl = config.get("userInfoUrl"); + return oauthUserService.getGitHubUser(userInfoUrl, accessToken); + } catch (Exception e) { + throw new GenericException(Translator.get(ERROR_AUTH_GET_USER_ERROR)); + } + } + + private UserDTO getUserAndValidate(String userId) { + UserDTO user = extOrganizationUserMapper.getEnableUser(userId); + if (user == null) { + throw new GenericException(Translator.get(ERROR_USER_NOT_EXIST)); + } + if (!user.getEnable()) { + throw new GenericException(Translator.get(ERROR_AUTH_LOGIN_DISABLE)); + } + return user; + } + + private UserDTO getUserAndValidateEnable(String userId) { + UserDTO user = extOrganizationUserMapper.getEnableUser(userId); + if (user == null) { + throw new AuthenticationException(Translator.get(ERROR_USER_NOT_EXIST)); + } + if (!user.getEnable()) { + throw new DisabledAccountException(Translator.get(ERROR_AUTH_LOGIN_DISABLE)); + } + return user; + } + + private void updateUserInfo(String userId, String name, String email, String avatar) { + User user = new User(); + user.setId(userId); + user.setName(name); + user.setEmail(email); + user.setUpdateTime(System.currentTimeMillis()); + userBaseMapper.updateById(user); + if (StringUtils.isNotBlank(avatar)) { + UserExtend userExtend = new UserExtend(); + userExtend.setId(userId); + userExtend.setAvatar(avatar); + userExtendBaseMapper.update(userExtend); + } + } + + private void updateWeComUserInfo(UserDTO user, WeComUserResponse weComUser, String email) { + User userUpdate = new User(); + userUpdate.setId(user.getId()); + if (StringUtils.isBlank(user.getEmail())) { + userUpdate.setEmail(email); + } + if (StringUtils.isNotBlank(weComUser.getMobile())) { + userUpdate.setPhone(weComUser.getMobile()); + } + userUpdate.setGender(weComUser.getGender() != null && weComUser.getGender() == 2); + userUpdate.setUpdateTime(System.currentTimeMillis()); + userBaseMapper.updateById(userUpdate); + + if (StringUtils.isNotBlank(weComUser.getAvatar())) { + UserExtend userExtend = new UserExtend(); + userExtend.setId(user.getId()); + userExtend.setAvatar(weComUser.getAvatar()); + userExtendBaseMapper.update(userExtend); + } + } + + private void updateDingTalkUserInfo(UserDTO user, DingTalkUserResponse dingTalkUserResponse) { + User userUpdate = new User(); + userUpdate.setId(user.getId()); + if (StringUtils.isBlank(user.getEmail())) { + userUpdate.setEmail(dingTalkUserResponse.getEmail()); + } + if (StringUtils.isNotBlank(dingTalkUserResponse.getMobile())) { + userUpdate.setPhone(dingTalkUserResponse.getMobile()); + } + userUpdate.setUpdateTime(System.currentTimeMillis()); + userBaseMapper.updateById(userUpdate); + + if (StringUtils.isNotBlank(dingTalkUserResponse.getAvatarUrl())) { + UserExtend userExtend = new UserExtend(); + userExtend.setId(user.getId()); + userExtend.setAvatar(dingTalkUserResponse.getAvatarUrl()); + userExtendBaseMapper.update(userExtend); + } + } + + private LoginRequest createLoginRequest(String username, String password, String platform) { + LoginRequest request = new LoginRequest(); + request.setUsername(username); + request.setPassword(password); + request.setPlatform(platform); + return request; + } + + private LoginRequest createThirdPartyLoginRequest(UserDTO user, String platform, String authenticateType) { + LoginRequest request = new LoginRequest(); + request.setUsername(user.getId()); + request.setPassword(generateDefaultPassword(user)); + request.setPlatform(platform); + request.setAuthenticate(authenticateType); + return request; + } + + private String generateDefaultPassword(UserDTO user) { + return Optional.ofNullable(user.getPhone()) + .filter(StringUtils::isNotBlank) + .map(phone -> { + try { + return CodingUtils.md5(phone.length() >= 6 + ? phone.substring(phone.length() - 6) + : phone); + } catch (Exception e) { + return CodingUtils.md5(phone); + } + }) + .orElseGet(() -> CodingUtils.md5(user.getLastOrganizationId() + user.getId())); + } + + private ThirdConfigBaseDTO getThirdPartyConfig(String type) { + IntegrationConfigService integrationConfigService = CommonBeanFactory.getBean(IntegrationConfigService.class); + assert integrationConfigService != null; + List> synOrganizationConfigs = integrationConfigService.getThirdConfig(DEFAULT_ORGANIZATION_ID); + + if (CollectionUtils.isEmpty(synOrganizationConfigs)) { + throw new GenericException(Translator.get(ERROR_THIRD_CONFIG_NOT_EXIST)); + } + + ThirdConfigBaseDTO config = synOrganizationConfigs.stream() + .filter(c -> Strings.CI.equals(c.getType(), type)) + .findFirst() + .orElse(null); + + if (config == null) { + throw new GenericException(Translator.get(ERROR_THIRD_CONFIG_NOT_EXIST)); + } + + return config; + } + + private void validateQrCodeEnabled(Boolean enable) { + if (!enable) { + throw new GenericException(Translator.get(ERROR_THIRD_CONFIG_DISABLE)); + } + } + + public SessionUser exchangeDingTalkOauth2(String code, String ip) { + validateCode(code); + + OrganizationConfigDetail configDetail = getAuthConfigDetail(ThirdDetailType.DINGTALK_SYNC.name()); + String content = new String(configDetail.getContent(), StandardCharsets.UTF_8); + ThirdConfigBaseDTO config = JSON.parseObject(content, ThirdConfigBaseDTO.class); + + if (config == null) { + throw new AuthenticationException(Translator.get(ERROR_AUTH_SETTING_NOT_EXISTS)); + } + DingTalkThirdConfigRequest dingTalkConfig; + if (config.getConfig() == null) { + dingTalkConfig = JSON.parseObject(content, DingTalkThirdConfigRequest.class); + } else { + dingTalkConfig = JSON.MAPPER.convertValue(config.getConfig(), DingTalkThirdConfigRequest.class); + } + return getDingTalkSessionUser(code, dingTalkConfig, UserSource.DINGTALK_OAUTH2.toString(), ip); + } + + public SessionUser exchangeLarkCode(String code, String ip) { + validateCode(code); + + ThirdConfigBaseDTO config = getThirdPartyConfig(ThirdConfigTypeConstants.LARK.name()); + LarkThirdConfigRequest larkConfig = JSON.MAPPER.convertValue(config.getConfig(), LarkThirdConfigRequest.class); + validateQrCodeEnabled(larkConfig.getStartEnable()); + + return getLarkSessionUser(code, larkConfig, UserSource.QR_CODE.toString(), false, ip); + } + + private SessionUser getLarkSessionUser(String code, LarkThirdConfigRequest larkConfig, String loginType, Boolean isMobile, String ip) { + if (isMobile) { + // 移动端需要变更域名 + larkConfig.setRedirectUrl(larkConfig.getRedirectUrl() + "/mobile"); + } + // 获取用户assess_token + String assessToken = tokenService.getLarkUserAccessToken(larkConfig.getAgentId(), larkConfig.getAppSecret(), larkConfig.getRedirectUrl(), code); + if (StringUtils.isBlank(assessToken)) { + throw new GenericException(Translator.get(ERROR_AUTH_GET_USER_ERROR)); + } + + // 读取用户信息 + Map larkUser = oauthUserService.getLarkUser(assessToken); + + //获取larkUser中data 对象 + Map data = (Map) larkUser.get("data"); + String userId = (String) data.get("open_id"); + // 查找并验证用户 + UserDTO enableUser = getUserAndValidateEnable(userId); + + // 创建登录请求并登录 + LoginRequest loginRequest = createThirdPartyLoginRequest( + enableUser, + ThirdConfigTypeConstants.LARK.name(), + loginType + ); + + loginRequest.setLoginAddress(ip); + SecurityUtils.getSubject().getSession().setAttribute("authenticate", loginType); + return userLoginService.login(loginRequest); + } + + public SessionUser exchangeLarkOauth2(String code, Boolean isMobile, String ip) { + validateCode(code); + + ThirdConfigBaseDTO config = getThirdPartyConfig(ThirdConfigTypeConstants.LARK.name()); + LarkThirdConfigRequest larkConfig = JSON.MAPPER.convertValue(config.getConfig(), LarkThirdConfigRequest.class); + validateQrCodeEnabled(larkConfig.getStartEnable()); + + return getLarkSessionUser(code, larkConfig, UserSource.LARK_OAUTH2.toString(), isMobile, ip); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sso/service/TokenService.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sso/service/TokenService.java new file mode 100644 index 0000000..55d7343 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sso/service/TokenService.java @@ -0,0 +1,404 @@ +package cn.cordys.crm.integration.sso.service; + +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.service.SSRFValidationService; +import cn.cordys.common.util.CodingUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.integration.agent.constant.MaxKBApiPaths; +import cn.cordys.crm.integration.agent.response.MaxKBResponseEntity; +import cn.cordys.crm.integration.common.client.QrCodeClient; +import cn.cordys.crm.integration.common.utils.HttpClientUtils; +import cn.cordys.crm.integration.dingtalk.constant.DingTalkApiPaths; +import cn.cordys.crm.integration.dingtalk.dto.DingTalkBaseParamDTO; +import cn.cordys.crm.integration.dingtalk.dto.DingTalkSendDTO; +import cn.cordys.crm.integration.dingtalk.dto.DingTalkToken; +import cn.cordys.crm.integration.dingtalk.dto.DingTalkTokenParamDTO; +import cn.cordys.crm.integration.lark.constant.LarkApiPaths; +import cn.cordys.crm.integration.lark.dto.LarkBaseParamDTO; +import cn.cordys.crm.integration.lark.dto.LarkSendMessageDTO; +import cn.cordys.crm.integration.lark.dto.LarkToken; +import cn.cordys.crm.integration.lark.dto.LarkTokenParamDTO; +import cn.cordys.crm.integration.qcc.constant.QccApiPaths; +import cn.cordys.crm.integration.qcc.response.QccBaseResponse; +import cn.cordys.crm.integration.tender.constant.TenderApiPaths; +import cn.cordys.crm.integration.wecom.constant.WeComApiPaths; +import cn.cordys.crm.integration.wecom.dto.WeComDetail; +import cn.cordys.crm.integration.wecom.dto.WeComSendDTO; +import cn.cordys.crm.integration.wecom.dto.WeComToken; +import com.fasterxml.jackson.core.type.TypeReference; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.net.HttpURLConnection; +import java.net.Socket; +import java.net.URI; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static cn.cordys.crm.integration.dingtalk.constant.DingTalkApiPaths.DING_USER_TOKEN_URL; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class TokenService { + + @Resource + private QrCodeClient qrCodeClient; + @Resource + private SSRFValidationService ssrfValidationService; + /** + * 获取assess_Token + * + * @param corpId 企业ID + * @param corpSecret 企业应用 密钥 + * @return String token + */ + public String getAssessToken(String corpId, String corpSecret) { + String url = HttpClientUtils.urlTransfer(WeComApiPaths.GET_TOKEN, corpId, corpSecret); + WeComToken weComToken; + try { + String response = HttpClientUtils.sendGetRequest(url, null); + weComToken = JSON.parseObject(response, WeComToken.class); + } catch (Exception e) { + log.error(Translator.get("auth.get.token.error"), e); + return null; + } + + if (weComToken.getErrCode() == null) { + log.error(Translator.get("auth.get.token.res.error")); + return null; + } + + if (weComToken.getErrCode() != 0) { + log.error("{}:{}", Translator.get("auth.get.token.res.error"), weComToken.getErrMsg()); + return null; + } + return weComToken.getAccessToken(); + } + + + /** + * 获取DingTalk assess_Token + * + * @param appKey 企业应用 ID ClientId + * @param appSecret 企业应用 密钥 + * @return String token + */ + public String getDingTalkToken(String appKey, String appSecret) { + + DingTalkBaseParamDTO dingTalkTokenParamDTO = new DingTalkBaseParamDTO(); + dingTalkTokenParamDTO.setAppKey(appKey); + dingTalkTokenParamDTO.setAppSecret(appSecret); + DingTalkToken dingTalkToken = null; + try { + String response = qrCodeClient.postExchange( + DingTalkApiPaths.DING_TALK_GET_TOKEN, null, null, dingTalkTokenParamDTO, MediaType.APPLICATION_JSON, MediaType.APPLICATION_JSON + ); + dingTalkToken = JSON.parseObject(response, DingTalkToken.class); + } catch (Exception e) { + log.error(Translator.get("auth.get.token.error"), e); + } + + return dingTalkToken != null ? dingTalkToken.getAccessToken() : null; + } + + + /** + * 获取DingTalk用户 assess_Token + * + * @param appKey 企业应用 ID ClientId 应用id。可使用扫码登录应用或者第三方个人小程序的appId。 + * @param appSecret 企业应用 密钥 + * @param code 授权码OAuth 2.0 临时授权码 + * @return String token + */ + public String getDingTalkUserToken(String appKey, String appSecret, String code) { + + DingTalkTokenParamDTO dingTalkTokenParamDTO = new DingTalkTokenParamDTO(); + dingTalkTokenParamDTO.setClientId(appKey); + dingTalkTokenParamDTO.setClientSecret(appSecret); + dingTalkTokenParamDTO.setCode(code); + dingTalkTokenParamDTO.setGrantType("authorization_code"); + DingTalkToken dingTalkToken = null; + try { + String response = qrCodeClient.postExchange(DING_USER_TOKEN_URL, null, null, dingTalkTokenParamDTO, MediaType.APPLICATION_JSON, MediaType.APPLICATION_JSON); + dingTalkToken = JSON.parseObject(response, DingTalkToken.class); + } catch (Exception e) { + log.error(Translator.get("auth.get.token.error"), e); + } + + return dingTalkToken != null ? dingTalkToken.getAccessToken() : null; + } + + + /** + * 获取Lark token + * + * @param agentId appId 飞书自建应用凭证 + * @param appSecret appSecret + * @return tenantAccessToken + */ + public String getLarkToken(String agentId, String appSecret) { + + LarkBaseParamDTO larkBaseParamDTO = new LarkBaseParamDTO(); + larkBaseParamDTO.setApp_id(agentId); + larkBaseParamDTO.setApp_secret(appSecret); + + LarkToken larkToken = null; + try { + String response = qrCodeClient.postExchange( + LarkApiPaths.LARK_APP_TOKEN_URL, null, null, larkBaseParamDTO, MediaType.APPLICATION_JSON, MediaType.APPLICATION_JSON + ); + larkToken = JSON.parseObject(response, LarkToken.class); + } catch (Exception e) { + log.error(Translator.get("auth.get.token.error"), e); + } + + if (larkToken != null && larkToken.getCode() != 0) { + log.error("{}:{}", Translator.get("auth.get.token.res.error"), larkToken.getMsg()); + } + + return larkToken != null ? larkToken.getTenantAccessToken() : null; + } + + /** + * IP + 端口 是否连通 + * + * @param fullUrl 完整的URL地址 + * @return bool + */ + public boolean pingDeUrl(String fullUrl) { + try { + // 禁用系统代理设置 + System.setProperty("java.net.useSystemProxies", "false"); + + URL url = URI.create(fullUrl).toURL(); + String host = url.getHost(); + // 根据协议选择默认端口 + int defaultPort = "https".equalsIgnoreCase(url.getProtocol()) ? 443 : 80; + int port = url.getPort() != -1 ? url.getPort() : defaultPort; + + // 直接测试连接 + try (Socket socket = new Socket()) { + socket.connect(new java.net.InetSocketAddress(host, port), 3000); + } + } catch (Exception e) { + log.error("de embedded url connect error: {}", e.getMessage()); + return false; + } + + return true; + } + + /** + * 获取sqlBot的src 是否能连通 + */ + public boolean getSqlBotSrc(String scriptCode) { + Pattern pattern = Pattern.compile("src\\s*=\\s*\"([^\"]+)\""); + Matcher matcher = pattern.matcher(scriptCode); + + String jsUrl; + if (matcher.find()) { + jsUrl = matcher.group(1); + } else { + return false; + } + try { + // SSRF 校验 + ssrfValidationService.validate(jsUrl); + URL url = URI.create(jsUrl).toURL(); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("HEAD"); // 更轻量,只请求头部 + connection.setConnectTimeout(3000); + connection.setReadTimeout(3000); + int responseCode = connection.getResponseCode(); + return responseCode == HttpURLConnection.HTTP_OK; + } catch (Exception e) { + log.error(e.getMessage(), e); + return false; + } + + } + + /** + * @param code code + * @param config 认证配置的map + * @return access_token + */ + public String getGitHubOAuth2Token(String code, Map config) { + String url = config.get("tokenUrl") + + "?client_id=" + config.get("clientId") + + "&client_secret=" + config.get("secret") + + "&redirect_uri=" + config.get("redirectUrl") + + "&code=" + code + + "&grant_type=authorization_code"; + + Map resultObj = null; + try { + String credentials = CodingUtils.base64Encoding(config.get("clientId") + ":" + config.get("secret")); + String content = qrCodeClient.postExchange(url, "Basic " + credentials, HttpHeaders.AUTHORIZATION, HttpEntity.EMPTY, MediaType.APPLICATION_JSON, MediaType.APPLICATION_JSON); + resultObj = JSON.parseObject(content, new TypeReference>() { + }); + } catch (Exception e) { + log.error(Translator.get("auth.token.error"), e); + } + String accessToken = null; + if (MapUtils.isEmpty(resultObj)) { + log.error(Translator.get("auth.token.error")); + } else { + accessToken = resultObj.get("access_token"); + } + if (StringUtils.isBlank(accessToken)) { + log.error(Translator.get("auth.token.error")); + } + + return accessToken; + } + + public void sendNoticeByToken(WeComSendDTO weComSendDTO, String corpId, String appSecret) { + String assessToken = getAssessToken(corpId, appSecret); + String detailUrl = HttpClientUtils.urlTransfer(WeComApiPaths.SEND_INFO, assessToken); + qrCodeClient.postExchange(detailUrl, null, null, weComSendDTO, MediaType.APPLICATION_JSON, MediaType.APPLICATION_JSON); + } + + public void sendDingNoticeByToken(DingTalkSendDTO dingTalkSendDTO, String agentId, String appSecret) { + String assessToken = getDingTalkToken(agentId, appSecret); + String detailUrl = HttpClientUtils.urlTransfer(DingTalkApiPaths.DING_NOTICE_URL, assessToken); + qrCodeClient.postExchange(detailUrl, null, null, dingTalkSendDTO, MediaType.APPLICATION_JSON, MediaType.APPLICATION_JSON); + } + + /** + * 发送Lark通知通过token + * + * @param agentId appId 飞书自建应用凭证 + * @param appSecret appSecret 飞书自建应用密钥 + * @param larkSendMessageDTO 消息体 + */ + public void sendLarkNoticeByToken(LarkSendMessageDTO larkSendMessageDTO, String agentId, String appSecret) { + String assessToken = getLarkToken(agentId, appSecret); + qrCodeClient.postExchange(LarkApiPaths.LARK_SEND_MESSAGE_URL + "?receive_id_type=open_id", "Bearer " + assessToken, + "Authorization", larkSendMessageDTO, MediaType.APPLICATION_JSON, MediaType.APPLICATION_JSON); + } + + + public String getLarkUserAccessToken(String agentId, String appSecret, String redirectUrl, String code) { + LarkTokenParamDTO larkTokenParamDTO = new LarkTokenParamDTO(); + larkTokenParamDTO.setClient_id(agentId); + larkTokenParamDTO.setClient_secret(appSecret); + larkTokenParamDTO.setCode(code); + larkTokenParamDTO.setGrant_type("authorization_code"); + larkTokenParamDTO.setRedirect_uri(redirectUrl); + LarkToken larkToken = null; + try { + String response = qrCodeClient.postExchange( + LarkApiPaths.LARK_USER_TOKEN_URL, null, null, larkTokenParamDTO, MediaType.APPLICATION_JSON, MediaType.APPLICATION_JSON + ); + larkToken = JSON.parseObject(response, LarkToken.class); + } catch (Exception e) { + log.error(Translator.get("auth.get.token.error"), e); + } + + if (larkToken != null && larkToken.getCode() != 0) { + log.error("{}:{}", Translator.get("auth.get.token.res.error"), larkToken.getMsg()); + } + + return larkToken != null ? larkToken.getAccessToken() : null; + } + + + /** + * 测试连接maxkb + * + * @param mkAddress + * @param apiKey + * @return + */ + public Boolean getMaxKBToken(String mkAddress, String apiKey) { + String urlTransfer = HttpClientUtils.urlTransfer(mkAddress.concat(MaxKBApiPaths.APPLICATION), "default"); + ssrfValidationService.validate(urlTransfer); + + String body = qrCodeClient.exchange( + urlTransfer, + "Bearer " + apiKey, + HttpHeaders.AUTHORIZATION, + MediaType.APPLICATION_JSON, + MediaType.APPLICATION_JSON + ); + MaxKBResponseEntity entity = JSON.parseObject(body, MaxKBResponseEntity.class); + return entity != null && entity.getCode() == 200; + } + + + public Boolean getTender() { + try { + URL url = URI.create(TenderApiPaths.TENDER_API).toURL(); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(3000); + connection.setReadTimeout(3000); + connection.connect(); + return true; + } catch (Exception e) { + log.error(e.getMessage(), e); + return false; + } + + } + + public boolean getQcc(String qccAddress, String qccAccessKey, String qccSecretKey) { + + long time = System.currentTimeMillis() / 1000; + String token = CodingUtils.md5(qccAccessKey + time + qccSecretKey).toUpperCase(); + + Map headers = new HashMap<>(); + headers.put("Token", token); + headers.put("Timespan", String.valueOf(time)); + + String url = HttpClientUtils.urlTransfer(qccAddress.concat(QccApiPaths.FUZZY_SEARCH_API), qccAccessKey, qccSecretKey); + String body; + try { + body = HttpClientUtils.sendGetRequest(url, headers); + } catch (Exception e) { + log.error("测试连接失败", e); + return false; + } + QccBaseResponse qccBaseResponse = JSON.parseObject(body, QccBaseResponse.class); + if (Strings.CI.equals("201", qccBaseResponse.getStatus())) { + return true; + } + throw new GenericException("测试连接失败:" + qccBaseResponse.getMessage()); + } + + public boolean checkWeComAgentAvailable(String accessToken, String agentId) { + String url = HttpClientUtils.urlTransfer( + WeComApiPaths.GET_AGENT, accessToken, agentId + ); + + try { + String response = HttpClientUtils.sendGetRequest(url, null); + WeComDetail weComDetail = JSON.parseObject(response, WeComDetail.class); + + log.info( + "企业微信 Agent 连接测试结果:errCode={}, errMsg={}", + weComDetail.getErrCode(), + weComDetail.getErrMsg() + ); + + return weComDetail.getErrCode() != null && weComDetail.getErrCode() == 0; + } catch (Exception e) { + log.error("企业微信 Agent 连接测试异常", e); + return false; + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sync/dto/ThirdDepartment.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sync/dto/ThirdDepartment.java new file mode 100644 index 0000000..7e543cb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sync/dto/ThirdDepartment.java @@ -0,0 +1,129 @@ +package cn.cordys.crm.integration.sync.dto; + +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.crm.system.domain.Department; +import lombok.Data; +import org.apache.commons.lang3.Strings; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Data +public class ThirdDepartment { + /** + * 创建的部门id + */ + private String id; + + /** + * 部门名称 + */ + private String name; + + /** + * 父部门id + */ + private String parentId; + + /** + * 是否是根部门 + */ + private Boolean isRoot; + + /** + * 在父部门中的次序值。order值大的排序靠前。值范围是[0, 2^32) + */ + private Long order; + + + /** + * 子节点 + */ + private List children = new ArrayList<>(); + private String crmId; + private String crmParentId; + + + public static List buildDepartmentTree(String internalId, List departments) { + Map departmentMap = new HashMap<>(); + List rootDepartments = new ArrayList<>(); + // 1. 生成新的ID并存储到部门对象中 + for (ThirdDepartment department : departments) { + departmentMap.put(department.getId(), department); + department.setCrmId(department.getIsRoot() ? internalId : IDGenerator.nextStr()); // 生成新的 ID + department.setCrmParentId(department.getIsRoot() ? internalId : department.getCrmParentId()); // 生成新的 ParentId + } + + //1. 生成新的ID并存储到部门对象中 + for (ThirdDepartment department : departments) { + if (department.getIsRoot()) { + // 如果新的 parentId 是 0,表示根部门 + rootDepartments.add(department); + } else { + // 否则,将它添加到父部门的 children 中 + ThirdDepartment parentDepartment = departmentMap.get(department.getParentId()); + if (parentDepartment != null) { + department.setCrmParentId(parentDepartment.getCrmId()); + parentDepartment.getChildren().add(department); + } + } + } + + return rootDepartments; + } + + + public static List buildDepartmentTreeMultiple(String internalId, List currentDepartmentList, List departments) { + Map departmentMap = new HashMap<>(); + List rootDepartments = new ArrayList<>(); + // 1. 生成新的ID并存储到部门对象中 + for (ThirdDepartment department : departments) { + departmentMap.put(department.getId(), department); + + currentDepartmentList.stream() + .filter(dept -> Strings.CI.equalsAny(dept.getResourceId(), department.getId())) + .findFirst() + .ifPresentOrElse(dept -> { + department.setCrmId(dept.getId()); + department.setCrmParentId(dept.getParentId()); + }, () -> { + if (department.getIsRoot()) { + department.setCrmId(internalId); + department.setCrmParentId(internalId); + } else { + currentDepartmentList.stream() + .filter(dept -> Strings.CI.equalsAny(dept.getResourceId(), department.getParentId())) + .findFirst() + .ifPresentOrElse(parent -> { + department.setCrmId(IDGenerator.nextStr()); + department.setCrmParentId(parent.getId()); + }, () -> { + department.setCrmId(IDGenerator.nextStr()); + department.setCrmParentId(department.getCrmParentId()); + }); + } + }); + } + + // 2. 按照 parentId 构建树形结构 + for (ThirdDepartment department : departments) { + if (department.getIsRoot()) { + // 如果新的 parentId 是 0,表示根部门 + rootDepartments.add(department); + } else { + // 否则,将它添加到父部门的 children 中 + ThirdDepartment parentDepartment = departmentMap.get(department.getParentId()); + if (parentDepartment != null) { + if (!parentDepartment.getChildren().contains(department)) { + department.setCrmParentId(parentDepartment.getCrmId()); + parentDepartment.getChildren().add(department); + } + } + } + } + + return rootDepartments; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sync/dto/ThirdOrgDataDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sync/dto/ThirdOrgDataDTO.java new file mode 100644 index 0000000..0d02231 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sync/dto/ThirdOrgDataDTO.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.sync.dto; + +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class ThirdOrgDataDTO { + private List departments; + private Map> users; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sync/dto/ThirdSwitchLogDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sync/dto/ThirdSwitchLogDTO.java new file mode 100644 index 0000000..9380230 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sync/dto/ThirdSwitchLogDTO.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.sync.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class ThirdSwitchLogDTO { + + @Schema(description = "应用类型") + private String thirdType; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sync/dto/ThirdUser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sync/dto/ThirdUser.java new file mode 100644 index 0000000..f3e9701 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sync/dto/ThirdUser.java @@ -0,0 +1,50 @@ +package cn.cordys.crm.integration.sync.dto; + +import lombok.Builder; +import lombok.Data; + +@Builder +@Data +public class ThirdUser { + + /** + * 成员UserID。对应管理端的帐号,企业内必须唯一。 + */ + private String userId; + + /** + * 成员名称。 + */ + private String name; + + /** + * 手机号码。代开发自建应用需要管理员授权且成员oauth2授权获取;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段。 + */ + private String mobile; + + /** + * 职务信息。 + */ + private String position; + + /** + * 性别。0表示未定义,1表示男性,2表示女性。 + */ + private Integer gender; + + /** + * 邮箱。 + */ + private String email; + + /** + * 表示在所在的部门内是否为部门负责人,数量与department一致;第三方通讯录应用或者授权了“组织架构信息-应用可获取企业的部门组织架构信息-部门负责人”权限的第三方应用可获取;对于非第三方创建的成员,第三方通讯录应用不可获取;上游企业不可获取下游企业成员该字段。 + */ + private Boolean isLeaderInDept; + + /** + * 头像url。 代开发自建应用需要管理员授权且成员oauth2授权获取;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段。 + */ + private String avatar; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/sync/service/ThirdDepartmentService.java b/backend/crm/src/main/java/cn/cordys/crm/integration/sync/service/ThirdDepartmentService.java new file mode 100644 index 0000000..b15df9a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/sync/service/ThirdDepartmentService.java @@ -0,0 +1,385 @@ +package cn.cordys.crm.integration.sync.service; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.ThirdConfigTypeConstants; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.integration.common.dto.ThirdConfigBaseDTO; +import cn.cordys.crm.integration.common.request.DingTalkThirdConfigRequest; +import cn.cordys.crm.integration.common.request.LarkThirdConfigRequest; +import cn.cordys.crm.integration.common.request.WecomThirdConfigRequest; +import cn.cordys.crm.integration.common.utils.DataHandleUtils; +import cn.cordys.crm.integration.dingtalk.service.DingTalkDepartmentService; +import cn.cordys.crm.integration.lark.service.LarkDepartmentService; +import cn.cordys.crm.integration.sso.service.TokenService; +import cn.cordys.crm.integration.sync.dto.ThirdDepartment; +import cn.cordys.crm.integration.sync.dto.ThirdUser; +import cn.cordys.crm.integration.wecom.service.WeComDepartmentService; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.constants.OrganizationConfigConstants; +import cn.cordys.crm.system.notice.CommonNoticeSendService; +import cn.cordys.crm.system.service.IntegrationConfigService; +import cn.cordys.crm.system.service.LogService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.redisson.Redisson; +import org.redisson.api.RLock; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Service +@Slf4j +public class ThirdDepartmentService { + + private static final String LOCK_PREFIX = "orgId_sync_"; + private static final String SYNC_STATUS_PREFIX = "org_sync_status:"; + private static final String DEPT_TREE_CACHE_KEY = "dept_tree_cache::"; + private static final String PERMISSION_CACHE_PATTERN = "permission_cache*%s"; + private static final int SCAN_COUNT = 100; + private static final long SYNC_STATUS_EXPIRE_SECONDS = 3600; // 同步状态过期时间1小时 + + @Resource + private TokenService tokenService; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + @Resource + private WeComDepartmentService weComDepartmentService; + + @Resource + private DingTalkDepartmentService dingTalkDepartmentService; + + @Resource + private LarkDepartmentService larkDepartmentService; + + @Resource + private IntegrationConfigService integrationConfigService; + + @Resource + private CommonNoticeSendService commonNoticeSendService; + + /** + * 同步组织架构(异步执行) + * + * @param operatorId 操作人ID + * @param orgId 组织ID + * @param type 同步类型(企业微信,钉钉,飞书) + */ + @Async + public void syncUser(String operatorId, String orgId, String type, Locale locale) { + Redisson redisson = CommonBeanFactory.getBean(Redisson.class); + assert redisson != null; + RLock lock = redisson.getLock(LOCK_PREFIX + orgId); + + // 尝试获取锁,避免并发同步 + if (!lock.tryLock()) { + throw new GenericException("当前正在执行同步任务!"); + } + + // 同步开始时存入Redis状态 + String syncStatusKey = getSyncStatusKey(orgId); + setSyncStatus(syncStatusKey, operatorId); + + try { + LocaleContextHolder.setLocale(locale); + performSync(operatorId, orgId, type); + clearCaches(orgId); + } catch (Exception e) { + log.error("同步组织架构失败", e); + } finally { + // 同步完成后删除Redis状态 + deleteSyncStatus(syncStatusKey); + lock.unlock(); + } + } + + /** + * 获取同步状态 + * + * @param orgId 组织ID + * + * @return 同步状态信息,如果没有正在同步则返回null + */ + public Boolean getSyncStatus(String orgId) { + try { + String syncStatusKey = getSyncStatusKey(orgId); + String statusJson = stringRedisTemplate.opsForValue().get(syncStatusKey); + if (StringUtils.isNotBlank(statusJson)) { + return true; + } + } catch (Exception e) { + log.error("获取同步状态失败", e); + } + return false; + } + + /** + * 执行同步操作 + */ + private void performSync(String operatorId, String orgId, String type) { + var logService = CommonBeanFactory.getBean(LogService.class); + log.info("开始同步组织架构,同步类型:{}", type); + + // 获取三方配置信息 + var thirdConfig = getThirdConfig(orgId, type); + var syncStatus = integrationConfigService.getSyncStatus( + orgId, OrganizationConfigConstants.ConfigType.THIRD.name(), type); + + // 获取访问令牌 + var accessToken = getToken(type, thirdConfig); + if (StringUtils.isBlank(accessToken)) { + throw new GenericException("获取访问令牌失败"); + } + log.info("获取访问令牌成功,同步类型:{}", type); + + // 将字符串类型安全转换为枚举 + var deptType = parseDepartmentType(type); + + List departments; + Map> departmentUserMap; + + switch (deptType) { + case WECOM -> { + departments = weComDepartmentService.getDepartmentList(accessToken); + log.info("企业微信部门数:{}", departments.size()); + + var departmentIds = departments.stream() + .map(ThirdDepartment::getId) + .map(Long::parseLong) + .toList(); + departmentUserMap = weComDepartmentService.getDepartmentUser(accessToken, departmentIds); + log.info("企业微信部门用户数:{}", departmentUserMap.values().stream().mapToLong(List::size).sum()); + } + case DINGTALK -> { + var thirdOrgDataDTO = dingTalkDepartmentService.convertToThirdOrgDataDTO(accessToken); + if (thirdOrgDataDTO == null) { + throw new GenericException("钉钉组织数据为空"); + } + departments = thirdOrgDataDTO.getDepartments(); + departmentUserMap = thirdOrgDataDTO.getUsers(); + log.info("钉钉部门数:{},部门用户数:{}", departments.size(), departmentUserMap.values().stream().mapToLong(List::size).sum()); + } + case LARK -> { + departments = larkDepartmentService.getDepartmentList(accessToken); + log.info("飞书部门数:{}", departments.size()); + + var departmentIds = departments.stream().map(ThirdDepartment::getId).toList(); + departmentUserMap = larkDepartmentService.getDepartmentUserList(accessToken, departmentIds); + + log.info("飞书部门用户数:{}", departmentUserMap.values().stream().mapToLong(List::size).sum()); + } + default -> throw new GenericException("不支持的同步类型:" + type); + } + + if (CollectionUtils.isEmpty(departments)) { + throw new GenericException("获取部门列表为空"); + } + + syncDepartmentsAndUsers(orgId, departments, departmentUserMap, syncStatus, operatorId, type); + + Objects.requireNonNull(logService, "LogService 未注入"); + log.info("同步组织架构完成,同步类型:{}", type); + logSyncOperation(logService, orgId, operatorId); + sendSyncSuccessNotice(operatorId, orgId, type); + } + + private ThirdConfigTypeConstants parseDepartmentType(String type) { + return Arrays.stream(ThirdConfigTypeConstants.values()) + .filter(e -> e.name().equalsIgnoreCase(type)) + .findFirst() + .orElseThrow(() -> new GenericException("未知的同步类型:" + type)); + } + + /** + * 获取第三方访问令牌 + */ + private String getToken(String type, ThirdConfigBaseDTO thirdConfig) { + Objects.requireNonNull(thirdConfig, "第三方配置信息不能为空"); + + var deptType = parseDepartmentType(type); + var accessToken = switch (deptType) { + case WECOM -> { + WecomThirdConfigRequest config = JSON.MAPPER.convertValue(thirdConfig.getConfig(), WecomThirdConfigRequest.class); + yield tokenService.getAssessToken(config.getCorpId(), config.getAppSecret()); + } + case DINGTALK -> { + DingTalkThirdConfigRequest dingTalkConfig = JSON.MAPPER.convertValue(thirdConfig.getConfig(), DingTalkThirdConfigRequest.class); + yield tokenService.getDingTalkToken(dingTalkConfig.getAgentId(), dingTalkConfig.getAppSecret()); + } + case LARK -> { + LarkThirdConfigRequest larkConfig = JSON.MAPPER.convertValue(thirdConfig.getConfig(), LarkThirdConfigRequest.class); + yield tokenService.getLarkToken(larkConfig.getAgentId(), larkConfig.getAppSecret()); + } + default -> throw new GenericException("不支持的同步类型:" + type); + }; + + log.debug("获取访问令牌成功,类型:{}", deptType.name()); + return accessToken; + } + + /** + * 获取第三方配置 + */ + private ThirdConfigBaseDTO getThirdConfig(String orgId, String type) { + List> configs = integrationConfigService.getThirdConfig(orgId); + if (CollectionUtils.isEmpty(configs)) { + throw new GenericException("未配置企业信息"); + } + + return configs.stream() + .filter(config -> Strings.CI.equals(config.getType(), type)) + .findFirst() + .orElseThrow(() -> new GenericException("未配置企业微信信息")); + } + + /** + * 同步部门和用户数据 + */ + private void syncDepartmentsAndUsers(String orgId, List departments, + Map> departmentUserMap, + boolean isUpdate, String operatorId, String type) { + DataHandleUtils dataHandleUtils = new DataHandleUtils(orgId, departmentUserMap, type); + + if (isUpdate) { + log.info("开始多次同步更新数据,同步类型:{}, 更新部门用户数量:{}", type, departments.size()); + // 多次同步 更新 + dataHandleUtils.handleUpdateData(departments, operatorId); + } else { + // 首次同步 新增 + log.info("开始首次同步新增数据,同步类型:{}, 新增部门用户数量:{}", type, departments.size()); + dataHandleUtils.handleAddData(departments, operatorId, orgId, type); + } + } + + /** + * 记录同步操作日志 + */ + private void logSyncOperation(LogService logService, String orgId, String operatorId) { + String detail = Translator.get("log.syncOrganization"); + LogDTO logDTO = new LogDTO(orgId, operatorId, operatorId, + LogType.SYNC, LogModule.SYSTEM_ORGANIZATION, detail); + logDTO.setDetail(detail); + logService.add(logDTO); + } + + /** + * 发送同步成功通知 + */ + private void sendSyncSuccessNotice(String operatorId, String orgId, String type) { + try { + // 设置通知参数 + Map paramMap = new HashMap<>(); + paramMap.put("name", Translator.get("message.sync_organization_structure")); + paramMap.put("syncType", getSyncTypeName(type)); + paramMap.put("organizationId", orgId); + + // 发送通知给操作人 + commonNoticeSendService.sendNotice( + NotificationConstants.Module.SYSTEM, // 模块类型 + NotificationConstants.Event.SYNC_ORGANIZATION_STRUCTURE, // 事件类型 + paramMap, + operatorId, + orgId, + List.of(operatorId), // 只发送给操作人 + false // 不排除自己 + ); + log.info("组织架构同步成功通知已发送,操作人:{},同步类型:{}", operatorId, type); + } catch (Exception e) { + log.error("发送组织架构同步成功通知失败", e); + } + } + + /** + * 获取同步类型名称 + */ + private String getSyncTypeName(String type) { + return switch (parseDepartmentType(type)) { + case WECOM -> "企业微信"; + case DINGTALK -> "钉钉"; + case LARK -> "飞书"; + default -> type; + }; + } + + /** + * 获取同步状态Redis Key + */ + private String getSyncStatusKey(String orgId) { + return SYNC_STATUS_PREFIX + orgId; + } + + /** + * 设置同步状态 + */ + private void setSyncStatus(String key, String operatorId) { + try { + Map statusInfo = new HashMap<>(); + statusInfo.put("startTime", System.currentTimeMillis()); + statusInfo.put("status", "SYNCING"); + + String statusJson = JSON.toJSONString(statusInfo); + stringRedisTemplate.opsForValue().set( + key, + statusJson, + SYNC_STATUS_EXPIRE_SECONDS, + java.util.concurrent.TimeUnit.SECONDS + ); + log.info("已存入同步状态到Redis, key: {}, status: {}, operatorId: {}", key, statusJson, operatorId); + } catch (Exception e) { + log.error("存入同步状态到Redis失败", e); + } + } + + /** + * 删除同步状态 + */ + private void deleteSyncStatus(String key) { + try { + stringRedisTemplate.delete(key); + log.info("已从 Redis 删除同步状态, key: {}", key); + } catch (Exception e) { + log.error("从 Redis 删除同步状态失败", e); + } + } + + /** + * 清理相关缓存 + */ + private void clearCaches(String orgId) { + // 清理部门树缓存 + stringRedisTemplate.delete(DEPT_TREE_CACHE_KEY + orgId); + // 清理权限缓存 + deleteKeysByPrefixSafely(orgId); + } + + /** + * 清理权限缓存 + * + * @param orgId 组织ID + */ + public void deleteKeysByPrefixSafely(String orgId) { + String pattern = String.format(PERMISSION_CACHE_PATTERN, orgId); + ScanOptions options = ScanOptions.scanOptions().match(pattern).count(SCAN_COUNT).build(); + + try (Cursor cursor = stringRedisTemplate.scan(options)) { + while (cursor.hasNext()) { + stringRedisTemplate.delete(cursor.next()); + } + } + } + +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/tender/constant/TenderApiPaths.java b/backend/crm/src/main/java/cn/cordys/crm/integration/tender/constant/TenderApiPaths.java new file mode 100644 index 0000000..1948bf9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/tender/constant/TenderApiPaths.java @@ -0,0 +1,6 @@ +package cn.cordys.crm.integration.tender.constant; + +public interface TenderApiPaths { + + String TENDER_API = "https://dadan.vip/#/login?source=feizhiyun"; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/tender/controller/TenderController.java b/backend/crm/src/main/java/cn/cordys/crm/integration/tender/controller/TenderController.java new file mode 100644 index 0000000..bd62539 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/tender/controller/TenderController.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.integration.tender.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.constants.ThirdConfigTypeConstants; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.integration.common.dto.ThirdConfigBaseDTO; +import cn.cordys.crm.system.service.IntegrationConfigService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "标讯") +@RestController +@RequestMapping("/tender") +public class TenderController { + + + @Resource + private IntegrationConfigService integrationConfigService; + + @GetMapping("/application/config") + @Operation(summary = "获取标讯设置") + @RequiresPermissions(PermissionConstants.TENDER_READ) + public ThirdConfigBaseDTO getApplicationConfig() { + return integrationConfigService.getApplicationConfig(OrganizationContext.getOrganizationId(), SessionUtils.getUserId(), ThirdConfigTypeConstants.TENDER.name()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/tender/dto/TenderDetailDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/tender/dto/TenderDetailDTO.java new file mode 100644 index 0000000..ebaaf30 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/tender/dto/TenderDetailDTO.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.integration.tender.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class TenderDetailDTO { + + @Schema(description = "地址", requiredMode = Schema.RequiredMode.REQUIRED) + private String tenderAddress; + + @Schema(description = "是否验证通过") + private Boolean verify; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/constant/WeComApiPaths.java b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/constant/WeComApiPaths.java new file mode 100644 index 0000000..22e0567 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/constant/WeComApiPaths.java @@ -0,0 +1,119 @@ +package cn.cordys.crm.integration.wecom.constant; + +public interface WeComApiPaths { + + /** + * 接口基础路径 + */ + String QY_API_BASE_PATH = "https://qyapi.weixin.qq.com/cgi-bin"; + + + /** + * 获取Access token + * 接口文档: https://developer.work.weixin.qq.com/document/path/91039 + *
+     * https请求方式: GET https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=ID&corpsecret=SECRET
+     * corpid	是	企业ID,获取方式参考:术语说明-corpid
+     * corp secret	是	应用的凭证密钥,注意应用需要是启用状态,获取方式参考:术语说明-secret
+     * 
+ */ + String GET_TOKEN = QY_API_BASE_PATH + "/gettoken?corpid={0}&corpsecret={1}"; + + /** + * 获取指定的应用详情 + * 接口文档: https://developer.work.weixin.qq.com/document/path/91039 + *
+     * https请求方式: GET https://qyapi.weixin.qq.com/cgi-bin/agent/get?access_token=ACCESS_TOKEN&agentid=AGENTID
+     * access_token		是	调用接口凭证,GET_TOKEN
+     * agent	是	应用id
+     * 
+ */ + String GET_AGENT = QY_API_BASE_PATH + "/agent/get?access_token={0}&agentid={1}"; + + /** + *

+ * 获取部门列表
+ * 【重要】从2022年8月15日10点开始,“企业管理后台 - 管理工具 - 通讯录同步”的新增IP将不能再调用此接口,企业可通过「获取部门ID列表」接口获取部门ID列表。查看调整详情。
+ * 权限说明:
+ * 只能拉取token对应的应用的权限范围内的部门列表
+ *

+ *

+ * 接口文档: https://developer.work.weixin.qq.com/document/path/90208
+ * http请求方式: GET https://qyapi.weixin.qq.com/cgi-bin/department/list?access_token=ACCESS_TOKEN&id=ID + *

+ */ + String DEPARTMENT_LIST = QY_API_BASE_PATH + "/department/list?access_token={0}&id={1}"; + + + /** + *

+ * 获取部门成员详情
+ * 应用只能获取可见范围内的成员信息,且每种应用获取的字段有所不同,在返回结果说明中会逐个说明。企业通讯录安全特别重要,企业微信持续升级加固通讯录接口的安全机制,以下是关键的变更点:
+ * - 从2022年6月20号20点开始,除通讯录同步以外的基础应用(如客户联系、微信客服、会话存档、日程等),以及新创建的自建应用与代开发应用,调用该接口时,不再返回以下字段:头像、性别、手机、邮箱、企业邮箱、员工个人二维码、地址,应用需要通过oauth2手工授权的方式获取管理员与员工本人授权的字段。
+ * - 【重要】从2022年8月15日10点开始,“企业管理后台 - 管理工具 - 通讯录同步”的新增IP将不能再调用此接口,企业可通过「获取成员ID列表」和「获取部门ID列表」接口获取userid和部门ID列表。查看调整详情。
+ *

+ *

+ * 接口文档: https://developer.work.weixin.qq.com/document/path/90201
+ * http请求方式: GET https://qyapi.weixin.qq.com/cgi-bin/user/list?access_token=ACCESS_TOKEN&department_id=DEPARTMENT_ID + *

+ */ + String USER_LIST = QY_API_BASE_PATH + "/user/list?access_token={0}&department_id={1}"; + + /** + *

+ * 获取访问用户身份
+ * 该接口用于根据code获取成员信息,适用于自建应用与代开发应用
+ * 权限说明:
+ * 跳转的域名须完全匹配access_token对应应用的可信域名,否则会返回50001错误。 + *

+ *

+ * 接口文档: https://developer.work.weixin.qq.com/document/path/91023 + * http请求地址: GET https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token=ACCESS_TOKEN&code=CODE + *

+ */ + String USER_ID_TICKET = QY_API_BASE_PATH + "/auth/getuserinfo?access_token={0}&code={1}"; + + /** + *

+ * 读取成员
+ * 自建应用与代开发应用可通过该接口获取成员授权的敏感字段 + *

+ *

+ * 接口文档: ... + * 请求方式:POST(HTTPS) + * http请求地址: POST https://qyapi.weixin.qq.com/cgi-bin/auth/getuserdetail?access_token=ACCESS_TOKEN + * 请求包体: + *

+ * { + * "user_ticket": "USER_TICKET" + * } + *

+ */ + String USER_DETAIL = QY_API_BASE_PATH + "/auth/getuserdetail?access_token={0}"; + + /** + *

+ * 读取成员
+ * 应用只能获取可见范围内的成员信息,且每种应用获取的字段有所不同,在返回结果说明中会逐个说明。企业通讯录安全特别重要,企业微信将持续升级加固通讯录接口的安全机制,以下是关键的变更点: + *

+ * 从2022年6月20号20点开始,除通讯录同步以外的基础应用(如客户联系、微信客服、会话存档、日程等),以及新创建的自建应用与代开发应用,调用该接口时,不再返回以下字段:头像、性别、手机、邮箱、企业邮箱、员工个人二维码、地址,应用需要通过oauth2手工授权的方式获取管理员与员工本人授权的字段。 + *

+ *

+ * 接口文档: ... + * 请求方式:POST(HTTPS) + * http请求地址: GET https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token=ACCESS_TOKEN&userid=USERID + *

+ */ + String USER_INFO = QY_API_BASE_PATH + "/user/get?access_token={0}&userid={1}"; + + /** + *

+ * 发送应用消息
+ * 应用支持推送文本、图片、视频、文件、图文等类型。
+ *

+ * 接口文档: https://developer.work.weixin.qq.com/document/path/91039 + * 请求方式: POST(HTTPS) + * http请求地址: POST ... + */ + String SEND_INFO = QY_API_BASE_PATH + "/message/send?access_token={0}"; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/Text.java b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/Text.java new file mode 100644 index 0000000..4034764 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/Text.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.integration.wecom.dto; + +import lombok.Data; + +@Data +public class Text { + + /** + * 消息内容,最长不超过2048个字节,超过将截断(支持id转译) + */ + private String content; + + public Text(String content) { + this.content = content; + } + + public Text() { + // 默认构造函数 + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComDepartment.java b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComDepartment.java new file mode 100644 index 0000000..31d14bd --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComDepartment.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.integration.wecom.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class WeComDepartment { + /** + * 创建的部门id + */ + private Long id; + + /** + * 部门名称 + */ + private String name; + + /** + * 父部门id。根部门为1 + */ + @JsonProperty("parentid") + private Long parentId; + + /** + * 在父部门中的次序值。order值大的排序靠前。值范围是[0, 2^32) + */ + private Long order; + +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComDetail.java b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComDetail.java new file mode 100644 index 0000000..26f852e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComDetail.java @@ -0,0 +1,28 @@ +package cn.cordys.crm.integration.wecom.dto; + +import cn.cordys.crm.integration.wecom.response.WeComResponseEntity; +import lombok.Data; + +@Data +public class WeComDetail extends WeComResponseEntity { + /** + * 应用id + */ + private String agentId; + + /** + * 应用名称 + */ + private String name; + + /** + * 企业应用方形头像 + */ + private String square_logo_url; + + /** + * 应用描述 + */ + private String description; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComSendDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComSendDTO.java new file mode 100644 index 0000000..b2a798a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComSendDTO.java @@ -0,0 +1,62 @@ +package cn.cordys.crm.integration.wecom.dto; + +import lombok.Data; + +@Data +public class WeComSendDTO { + + /** + * 指定接收消息的成员,多个接收者用'|'分隔,最多支持1000个,特殊情况:指定为"@all",则向该企业应用的全部成员发送 + */ + private String touser; + + /** + * 指定接收消息的部门,部门ID列表,多个接收者用‘|’分隔,最多支持100个。 + * 当touser为"@all"时忽略本参数 + */ + private String toparty; + + /** + * 指定接收消息的标签,标签ID列表,多个接收者用‘|’分隔,最多支持100个。 + * 当touser为"@all"时忽略本参数 + */ + private String totag; + + /** + * 消息类型 + */ + private String msgtype; + + /** + * 应用id + */ + private Integer agentid; + + /** + * 接收者的UserID列表 + */ + private Text text; + + /** + * 表示是否是保密消息,0表示可对外分享,1表示不能分享且内容显示水印,默认为0 + */ + private boolean safe; + + + /** + * 表示是否开启id转译,0表示不转译,1表示转译,默认为0 + */ + private boolean enable_id_trans; + + /** + * 表示是否开启重复消息检查,0表示不检查,1表示检查,默认为0 + */ + private boolean enable_duplicate_check; + + /** + * 表示是否重复消息检查的时间间隔,默认1800s,最大不超过4小时 + */ + private Integer duplicate_check_interval; + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComToken.java b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComToken.java new file mode 100644 index 0000000..3a3499e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComToken.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.integration.wecom.dto; + +import cn.cordys.crm.integration.wecom.response.WeComResponseEntity; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class WeComToken extends WeComResponseEntity { + + + /** + * 获取到的凭证,最长为512字节 + */ + @JsonProperty("access_token") + private String accessToken; + + /** + * 凭证的有效时间(秒) + */ + @JsonProperty("expires_in") + private String expiresIn; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComUser.java b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComUser.java new file mode 100644 index 0000000..5f936c4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComUser.java @@ -0,0 +1,131 @@ +package cn.cordys.crm.integration.wecom.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +public class WeComUser { + /** + * 成员UserID。对应管理端的帐号,企业内必须唯一。不区分大小写,长度为1~64个字节;第三方应用返回的值为open_userid + */ + @JsonProperty("userid") + private String userId; + + /** + * 成员名称;第三方不可获取,调用时返回userid以代替name;代开发自建应用需要管理员授权才返回;对于非第三方创建的成员,第三方通讯录应用也不可获取;未返回name的情况需要通过通讯录展示组件来展示名字 + */ + private String name; + + /** + * 手机号码,代开发自建应用需要管理员授权且成员oauth2授权获取;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + private String mobile; + + /** + * 成员所属部门id列表,仅返回该应用有查看权限的部门id;成员授权模式下,固定返回根部门id,即固定为1。对授权了“组织架构信息”权限的第三方应用,返回成员所属的全部部门id + */ + private List department; + + /** + * 部门内的排序值,默认为0。数量必须和department一致,数值越大排序越前面。值范围是[0, 2^32)。成员授权模式下不返回该字段 + */ + private List order; + + /** + * 职务信息;代开发自建应用需要管理员授权才返回;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + private String position; + + /** + * 性别。0表示未定义,1表示男性,2表示女性。代开发自建应用需要管理员授权且成员oauth2授权获取;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段。注:不可获取指返回值0 + */ + private Integer gender; + + /** + * 邮箱,代开发自建应用需要管理员授权且成员oauth2授权获取;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + private String email; + + /** + * 企业邮箱,代开发自建应用需要管理员授权且成员oauth2授权获取;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + @JsonProperty("biz_mail") + private String bizMail; + + /** + * 表示在所在的部门内是否为部门负责人,数量与department一致;第三方通讯录应用或者授权了“组织架构信息-应用可获取企业的部门组织架构信息-部门负责人”权限的第三方应用可获取;对于非第三方创建的成员,第三方通讯录应用不可获取;上游企业不可获取下游企业成员该字段 + */ + @JsonProperty("is_leader_in_dept") + private List isLeaderInDept; + + /** + * 直属上级UserID,返回在应用可见范围内的直属上级列表,最多有五个直属上级;第三方通讯录应用或者授权了“组织架构信息-应用可获取可见范围内成员组织架构信息-直属上级”权限的第三方应用可获取;对于非第三方创建的成员,第三方通讯录应用不可获取;上游企业不可获取下游企业成员该字段;代开发自建应用不可获取该字段 + */ + @JsonProperty("direct_leader") + private List directLeader; + + /** + * 头像url。 代开发自建应用需要管理员授权且成员oauth2授权获取;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + private String avatar; + + /** + * 头像缩略图url。第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + @JsonProperty("thumb_avatar") + private String thumbAvatar; + + /** + * 座机。代开发自建应用需要管理员授权才返回;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + private String telephone; + + /** + * 别名;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + private String alias; + + /** + * 地址。代开发自建应用需要管理员授权且成员oauth2授权获取;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + private String address; + + /** + * 全局唯一。对于同一个服务商,不同应用获取到企业内同一个成员的open_userid是相同的,最多64个字节。仅第三方应用可获取 + */ + @JsonProperty("open_userid") + private String openUserId; + + /** + * 主部门,仅当应用对主部门有查看权限时返回。 + */ + @JsonProperty("main_department") + private Long mainDepartment; + + /** + * 激活状态: 1=已激活,2=已禁用,4=未激活,5=退出企业。 + * 已激活代表已激活企业微信或已关注微信插件(原企业号)。未激活代表既未激活企业微信又未关注微信插件(原企业号)。 + */ + private Integer status; + + /** + * 员工个人二维码,扫描可添加为外部联系人(注意返回的是一个url,可在浏览器上打开该url以展示二维码);代开发自建应用需要管理员授权且成员oauth2授权获取;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + @JsonProperty("qr_code") + private String qrCode; + + /** + * 对外职务,如果设置了该值,则以此作为对外展示的职务,否则以position来展示。代开发自建应用需要管理员授权才返回;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + @JsonProperty("external_position") + private String externalPosition; + + /** + * 成员票据,最大为512字节,有效期为1800s。scope为snsapi_privateinfo,且用户在应用可见范围之内时返回此参数。后续利用该参数可以获取用户信息或敏感信息 + */ + @JsonProperty("user_ticket") + private String userTicket; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComUserTicketDTO.java b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComUserTicketDTO.java new file mode 100644 index 0000000..58e154e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/dto/WeComUserTicketDTO.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.integration.wecom.dto; + +import lombok.Data; + +@Data +public class WeComUserTicketDTO { + + /** + * 成员票据,最大为512字节,有效期为1800s。scope为snsapi_privateinfo,且用户在应用可见范围之内时返回此参数。后续利用该参数可以获取用户信息或敏感信息 + */ + private String user_ticket; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/response/WeComCommonUserResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/response/WeComCommonUserResponse.java new file mode 100644 index 0000000..8446771 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/response/WeComCommonUserResponse.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.integration.wecom.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class WeComCommonUserResponse extends WeComResponseEntity { + + @JsonProperty("userid") + private String userId; + + /** + * 成员票据,最大为512字节,有效期为1800s。scope为snsapi_privateinfo,且用户在应用可见范围之内时返回此参数。后续利用该参数可以获取用户信息或敏感信息 + */ + @JsonProperty("user_ticket") + private String userTicket; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/response/WeComDepartmentListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/response/WeComDepartmentListResponse.java new file mode 100644 index 0000000..3157c0f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/response/WeComDepartmentListResponse.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.integration.wecom.response; + +import cn.cordys.crm.integration.wecom.dto.WeComDepartment; +import lombok.Data; + +import java.util.List; + +/** + * 企业微信部门 响应对象 + */ + +@Data +public class WeComDepartmentListResponse extends WeComResponseEntity { + /** + * 部门列表数据。 + */ + private List department; + +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/response/WeComResponseEntity.java b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/response/WeComResponseEntity.java new file mode 100644 index 0000000..d8a68d6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/response/WeComResponseEntity.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.integration.wecom.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class WeComResponseEntity { + + /** + * 错误码 + */ + @JsonProperty("errcode") + private Integer errCode; + + /** + * 错误消息 + */ + @JsonProperty("errmsg") + private String errMsg; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/response/WeComUserListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/response/WeComUserListResponse.java new file mode 100644 index 0000000..fd9ea41 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/response/WeComUserListResponse.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.integration.wecom.response; + +import cn.cordys.crm.integration.wecom.dto.WeComUser; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +public class WeComUserListResponse extends WeComResponseEntity { + /** + * 成员列表 + */ + @JsonProperty("userlist") + private List userList; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/response/WeComUserResponse.java b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/response/WeComUserResponse.java new file mode 100644 index 0000000..b30aa35 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/response/WeComUserResponse.java @@ -0,0 +1,58 @@ +package cn.cordys.crm.integration.wecom.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + + +@Data +public class WeComUserResponse extends WeComResponseEntity { + + /** + * 成员UserID。对应管理端的帐号,企业内必须唯一。不区分大小写,长度为1~64个字节;第三方应用返回的值为open_userid + */ + @JsonProperty("userid") + private String userId; + + /** + * 成员名称;第三方不可获取,调用时返回userid以代替name;代开发自建应用需要管理员授权才返回;对于非第三方创建的成员,第三方通讯录应用也不可获取;未返回name的情况需要通过通讯录展示组件来展示名字 + */ + private String name; + + /** + * 性别。0表示未定义,1表示男性,2表示女性。代开发自建应用需要管理员授权且成员oauth2授权获取;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段。注:不可获取指返回值0 + */ + private Integer gender; + + /** + * 头像url。 代开发自建应用需要管理员授权且成员oauth2授权获取;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + private String avatar; + + /** + * 员工个人二维码,扫描可添加为外部联系人(注意返回的是一个url,可在浏览器上打开该url以展示二维码);代开发自建应用需要管理员授权且成员oauth2授权获取;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + @JsonProperty("qr_code") + private String qrCode; + + /** + * 手机号码,代开发自建应用需要管理员授权且成员oauth2授权获取;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + private String mobile; + + /** + * 邮箱,代开发自建应用需要管理员授权且成员oauth2授权获取;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + private String email; + + /** + * 企业邮箱,代开发自建应用需要管理员授权且成员oauth2授权获取;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + @JsonProperty("biz_mail") + private String bizMail; + + /** + * 地址。代开发自建应用需要管理员授权且成员oauth2授权获取;第三方仅通讯录应用可获取;对于非第三方创建的成员,第三方通讯录应用也不可获取;上游企业不可获取下游企业成员该字段 + */ + private String address; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/service/WeComDepartmentService.java b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/service/WeComDepartmentService.java new file mode 100644 index 0000000..5f33ed1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/service/WeComDepartmentService.java @@ -0,0 +1,113 @@ +package cn.cordys.crm.integration.wecom.service; + +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.integration.common.utils.HttpClientUtils; +import cn.cordys.crm.integration.sync.dto.ThirdDepartment; +import cn.cordys.crm.integration.sync.dto.ThirdUser; +import cn.cordys.crm.integration.wecom.constant.WeComApiPaths; +import cn.cordys.crm.integration.wecom.response.WeComDepartmentListResponse; +import cn.cordys.crm.integration.wecom.response.WeComUserListResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static cn.cordys.crm.integration.common.utils.HttpClientUtils.urlTransfer; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class WeComDepartmentService { + + /** + * 获取部门列表 + * + * @param accessToken 访问令牌 + * @return 部门列表 + */ + public List getDepartmentList(String accessToken) { + String url = urlTransfer(WeComApiPaths.DEPARTMENT_LIST, accessToken, null); + WeComDepartmentListResponse response = fetchDepartmentList(url); + + if (response.getErrCode() != 0) { + throw new GenericException("获取部门接口返回结果失败: " + response.getErrMsg()); + } + + return response.getDepartment().stream().map(dept -> { + ThirdDepartment thirdDept = new ThirdDepartment(); + thirdDept.setId(dept.getId().toString()); + thirdDept.setName(dept.getName()); + thirdDept.setParentId(dept.getParentId().toString()); + thirdDept.setOrder(dept.getOrder()); + thirdDept.setIsRoot(dept.getId() == 1); + return thirdDept; + }).toList(); + } + + /** + * 获取部门列表 + */ + private WeComDepartmentListResponse fetchDepartmentList(String url) { + try { + String responseStr = HttpClientUtils.sendGetRequest(url, null); + return JSON.parseObject(responseStr, WeComDepartmentListResponse.class); + } catch (Exception e) { + log.error(e.getMessage(), e); + throw new GenericException("调用接口获取部门列表失败:" + e.getMessage()); + } + } + + /** + * 获取部门用户详情 + * + * @param accessToken 访问令牌 + * @param departmentIds 部门ID列表 + * @return 部门ID与用户列表的映射 + */ + public Map> getDepartmentUser(String accessToken, List departmentIds) { + Map> thirdUserMap = new HashMap<>(); + + for (Long departmentId : departmentIds) { + String url = urlTransfer(WeComApiPaths.USER_LIST, accessToken, departmentId); + WeComUserListResponse response = fetchUserList(url); + + if (response.getErrCode() != 0) { + throw new GenericException("获取用户接口返回结果失败: " + response.getErrMsg()); + } + List thirdUsers = response.getUserList().stream()//过滤如果weComUser有mainDepartment属性,过滤department中含有mainDepartment的用户,没有就不过滤 + .filter(weComUser -> weComUser.getMainDepartment() == null || weComUser.getMainDepartment().equals(departmentId)) + .map(weComUser -> ThirdUser.builder() + .userId(weComUser.getUserId()) + .name(weComUser.getName()) + .email(weComUser.getEmail()) + .mobile(weComUser.getMobile()) + .position(weComUser.getPosition()) + .isLeaderInDept(weComUser.getIsLeaderInDept().indexOf(Integer.parseInt(departmentId.toString())) > 0) + .build()) + .collect(Collectors.toList()); + + + thirdUserMap.put(departmentId.toString(), thirdUsers); + } + + return thirdUserMap; + } + + + /** + * 获取用户列表 + */ + private WeComUserListResponse fetchUserList(String url) { + try { + String responseStr = HttpClientUtils.sendGetRequest(url, null); + return JSON.parseObject(responseStr, WeComUserListResponse.class); + } catch (Exception e) { + throw new GenericException("调用接口获取用户列表失败", e); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/service/WeComNoticeSender.java b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/service/WeComNoticeSender.java new file mode 100644 index 0000000..475dc4f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/integration/wecom/service/WeComNoticeSender.java @@ -0,0 +1,100 @@ +package cn.cordys.crm.integration.wecom.service; + +import cn.cordys.common.constants.ThirdDetailType; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.integration.common.dto.ThirdConfigBaseDTO; +import cn.cordys.crm.integration.common.request.WecomThirdConfigRequest; +import cn.cordys.crm.integration.sso.service.TokenService; +import cn.cordys.crm.integration.wecom.dto.Text; +import cn.cordys.crm.integration.wecom.dto.WeComSendDTO; +import cn.cordys.crm.system.constants.OrganizationConfigConstants; +import cn.cordys.crm.system.domain.OrganizationConfig; +import cn.cordys.crm.system.domain.OrganizationConfigDetail; +import cn.cordys.crm.system.dto.MessageDetailDTO; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigDetailMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigMapper; +import cn.cordys.crm.system.notice.common.NoticeModel; +import cn.cordys.crm.system.notice.common.Receiver; +import cn.cordys.crm.system.notice.sender.AbstractNoticeSender; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Slf4j +public class WeComNoticeSender extends AbstractNoticeSender { + + @Resource + private TokenService tokenService; + @Resource + private ExtOrganizationConfigMapper extOrganizationConfigMapper; + @Resource + private ExtOrganizationConfigDetailMapper extOrganizationConfigDetailMapper; + + @Override + public void send(MessageDetailDTO messageDetailDTO, NoticeModel noticeModel) { + try { + String context = super.getContext(messageDetailDTO, noticeModel); + sendWeCom(context, noticeModel, messageDetailDTO.getOrganizationId()); + log.debug("发送企业微信结束"); + } catch (Exception e) { + log.error("企业微信消息通知失败:{}", String.valueOf(e)); + } + } + + public void sendWeCom(MessageDetailDTO clonedMessageDetail, NoticeModel clonedNoticeModel) { + this.send(clonedMessageDetail, clonedNoticeModel); + } + + private void sendWeCom(String context, NoticeModel noticeModel, String organizationId) { + List receivers = super.getReceivers(noticeModel.getReceivers(), noticeModel.isExcludeSelf(), noticeModel.getOperator()); + if (CollectionUtils.isEmpty(receivers)) { + return; + } + List userIds = receivers.stream() + .map(Receiver::getUserId) + .distinct() + .toList(); + //查询同步过的resourceId,这里已经确定是否同步了企业微信,没同步,消息无法发送 + List resourceUserIds = super.getResourceUserIds(userIds, organizationId); + if (CollectionUtils.isEmpty(resourceUserIds)) { + log.warn("没有同步企业微信用户,无法发送消息"); + return; + } + //查询三方配置 + OrganizationConfig organizationConfig = extOrganizationConfigMapper + .getOrganizationConfig(organizationId, OrganizationConfigConstants.ConfigType.THIRD.name()); + if (organizationConfig == null) { + log.warn("没有配置企业微信信息,无法发送消息"); + return; + } + //获取企业微信通知的配置数据 + OrganizationConfigDetail orgConfigDetailByIdAndType = extOrganizationConfigDetailMapper.getOrgConfigDetailByIdAndType(organizationConfig.getId(), ThirdDetailType.WECOM_SYNC.toString()); + if (orgConfigDetailByIdAndType == null || orgConfigDetailByIdAndType.getContent() == null) { + log.warn("没有配置企业微信通知信息,无法发送消息"); + return; + } + var thirdConfigurationDTO = JSON.parseObject( + new String(orgConfigDetailByIdAndType.getContent()), ThirdConfigBaseDTO.class + ); + WecomThirdConfigRequest wecomThirdConfigRequest = new WecomThirdConfigRequest(); + if (thirdConfigurationDTO.getConfig() == null) { + wecomThirdConfigRequest = JSON.parseObject(new String(orgConfigDetailByIdAndType.getContent()), WecomThirdConfigRequest.class); + } else { + wecomThirdConfigRequest = JSON.MAPPER.convertValue(thirdConfigurationDTO.getConfig(), WecomThirdConfigRequest.class); + } + + //构建企业微信消息 + WeComSendDTO weComSendDTO = new WeComSendDTO(); + String result = String.join("|", resourceUserIds); + weComSendDTO.setTouser(result); + weComSendDTO.setAgentid(Integer.parseInt(wecomThirdConfigRequest.getAgentId())); + weComSendDTO.setText(new Text(context)); + weComSendDTO.setMsgtype("text"); + + tokenService.sendNoticeByToken(weComSendDTO, wecomThirdConfigRequest.getCorpId(), wecomThirdConfigRequest.getAppSecret()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/constants/OpportunityFailureReason.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/constants/OpportunityFailureReason.java new file mode 100644 index 0000000..8096888 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/constants/OpportunityFailureReason.java @@ -0,0 +1,28 @@ +package cn.cordys.crm.opportunity.constants; + +/** + * 商机失败原因枚举 + */ +public enum OpportunityFailureReason { + /** + * 客户选择竞品 + */ + COMPETITOR_CHOSEN, + /** + * 立项失败 + */ + PROJECT_FAILED, + /** + * 决策链复杂 + */ + COMPLEX_DECISION_CHAIN, + /** + * 预算限制 + */ + BUDGET_LIMITATION, + /** + * 需求变化 + */ + REQUIREMENT_CHANGE + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/constants/OpportunityStageScenario.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/constants/OpportunityStageScenario.java new file mode 100644 index 0000000..3dfb644 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/constants/OpportunityStageScenario.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.opportunity.constants; + +/** + * 商机阶段场景 + */ +public enum OpportunityStageScenario { + /** + * 进行中 + */ + UNDERWAY, + + /** + * 成功 + */ + SUCCESS, + /** + * 失败 + */ + FAIL, +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/constants/OpportunityStageType.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/constants/OpportunityStageType.java new file mode 100644 index 0000000..60c4f67 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/constants/OpportunityStageType.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.opportunity.constants; + +/** + * 商机阶段类型 + */ +public enum OpportunityStageType { + + + /** + * 进行中 + */ + AFOOT, + + /** + * 完结 + */ + END, +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/constants/StageType.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/constants/StageType.java new file mode 100644 index 0000000..dc5d222 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/constants/StageType.java @@ -0,0 +1,42 @@ +package cn.cordys.crm.opportunity.constants; + +/** + * 商机阶段 + */ +public enum StageType { + + /** + * 新建 + */ + CREATE, + + /** + * 需求明确 + */ + CLEAR_REQUIREMENTS, + + /** + * 方案验证 + */ + SCHEME_VALIDATION, + + /** + * 项目汇报 + */ + PROJECT_PROPOSAL_REPORT, + + /** + * 商务采购 + */ + BUSINESS_PROCUREMENT, + + /** + * 结果:成功 + */ + SUCCESS, + + /** + * 结果:失败 + */ + FAIL, +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityController.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityController.java new file mode 100644 index 0000000..1a519b5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityController.java @@ -0,0 +1,218 @@ +package cn.cordys.crm.opportunity.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.permission.CsBatchPermission; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.dto.ChartAnalysisRequest; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.common.dto.stage.StageSortRequest; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.customer.dto.response.CustomerContactListAllResponse; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.opportunity.dto.request.*; +import cn.cordys.crm.opportunity.dto.response.OpportunityDetailResponse; +import cn.cordys.crm.opportunity.dto.response.OpportunityListResponse; +import cn.cordys.crm.opportunity.dto.response.OpportunitySearchStatisticResponse; +import cn.cordys.crm.opportunity.service.OpportunityExportService; +import cn.cordys.crm.opportunity.service.OpportunityService; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotEmpty; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Tag(name = "商机") +@RestController +@RequestMapping("/opportunity") +public class OpportunityController { + + @Resource + private OpportunityService opportunityService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private DataScopeService dataScopeService; + @Resource + private OpportunityExportService opportunityExportService; + + + @GetMapping("/module/form") + @CsPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "获取表单配置") + public ModuleFormConfigDTO getModuleFormConfig() { + return moduleFormCacheService.getBusinessFormConfig(FormKey.OPPORTUNITY.getKey(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/page") + @CsPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "商机列表") + public PagerWithOption> list(@Validated @RequestBody OpportunityPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.OPPORTUNITY.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.OPPORTUNITY_MANAGEMENT_READ); + return opportunityService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission, false); + } + + @PostMapping("/statistic") + @CsPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "商机统计") + public OpportunitySearchStatisticResponse searchStatistic(@Validated @RequestBody OpportunitySearchStatisticRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.OPPORTUNITY.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.OPPORTUNITY_MANAGEMENT_READ); + return opportunityService.searchStatistic(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + + @PostMapping("/add") + @CsPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_ADD) + @Operation(summary = "添加商机") + public Opportunity add(@Validated @RequestBody OpportunityAddRequest request) { + return opportunityService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/update") + @CsPermission(value = PermissionConstants.OPPORTUNITY_MANAGEMENT_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.OPPORTUNITY) + @Operation(summary = "更新商机") + public Opportunity update(@Validated @RequestBody OpportunityUpdateRequest request) { + return opportunityService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @Operation(summary = "删除商机") + @CsPermission(value = PermissionConstants.OPPORTUNITY_MANAGEMENT_DELETE, resourceId = "{#id}", formType = FormKeyConstants.OPPORTUNITY) + public void deleteOpportunity(@PathVariable String id) { + opportunityService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/batch/transfer") + @CsBatchPermission(value = PermissionConstants.OPPORTUNITY_MANAGEMENT_TRANSFER, resourceId = "{#request.ids}", formType = FormKeyConstants.OPPORTUNITY) + @Operation(summary = "批量转移商机") + public void batchTransfer(@RequestBody OpportunityTransferRequest request) { + opportunityService.transfer(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/batch/delete") + @CsBatchPermission(value = PermissionConstants.OPPORTUNITY_MANAGEMENT_DELETE, resourceId = "{#ids}", formType = FormKeyConstants.OPPORTUNITY) + @Operation(summary = "批量删除商机") + public void delete(@RequestBody @NotEmpty List ids) { + opportunityService.batchDelete(ids, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/get/{id}") + @CsPermission(value = PermissionConstants.OPPORTUNITY_MANAGEMENT_READ, resourceId = "{#id}", formType = FormKeyConstants.OPPORTUNITY) + @Operation(summary = "商机详情") + public OpportunityDetailResponse get(@PathVariable String id) { + return opportunityService.getWithDataPermissionCheck(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update/stage") + @RequiresPermissions(value = {PermissionConstants.OPPORTUNITY_MANAGEMENT_UPDATE, PermissionConstants.OPPORTUNITY_MANAGEMENT_RESIGN}, logical = Logical.OR) + @Operation(summary = "更新商机阶段") + public void updateStage(@RequestBody OpportunityStageRequest request) { + opportunityService.updateStage(request, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch/update") + @CsBatchPermission(value = PermissionConstants.OPPORTUNITY_MANAGEMENT_UPDATE, resourceId = "{#request.ids}", formType = FormKeyConstants.OPPORTUNITY) + @Operation(summary = "批量更新商机") + public void batchUpdate(@Validated @RequestBody ResourceBatchEditRequest request) { + opportunityService.batchUpdate(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/tab") + @CsPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "所有商机和部门商机tab是否显示") + public ResourceTabEnableDTO getTabEnableConfig() { + return opportunityService.getTabEnableConfig(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/contact/list/{opportunityId}") + @Operation(summary = "商机下的联系人列表") + @CsPermission(value = PermissionConstants.OPPORTUNITY_MANAGEMENT_READ, resourceId = "{#opportunityId}", formType = FormKeyConstants.OPPORTUNITY) + public CustomerContactListAllResponse list(@Validated @PathVariable String opportunityId) { + return opportunityService.getContactList(opportunityId, OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/export-all") + @Operation(summary = "商机导出全部") + @CsPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_EXPORT) + public String opportunityExportAll(@Validated @RequestBody OpportunityExportRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.OPPORTUNITY.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.OPPORTUNITY_MANAGEMENT_READ); + return opportunityExportService.export(SessionUtils.getUserId(), request, OrganizationContext.getOrganizationId(), deptDataPermission, LocaleContextHolder.getLocale()); + } + + + @PostMapping("/export-select") + @Operation(summary = "导出选中商机") + @CsPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_EXPORT) + public String opportunityExportSelect(@Validated @RequestBody ExportSelectRequest request) { + return opportunityExportService.exportSelect(SessionUtils.getUserId(), request, OrganizationContext.getOrganizationId(), LocaleContextHolder.getLocale()); + } + + @GetMapping("/template/download") + @CsPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_IMPORT) + @Operation(summary = "下载导入模板") + public void downloadImportTpl(HttpServletResponse response) { + opportunityService.downloadImportTpl(response, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/import/pre-check") + @Operation(summary = "导入检查") + @CsPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_IMPORT) + public ImportResponse preCheck(@RequestPart(value = "file") MultipartFile file) { + return opportunityService.importPreCheck(file, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/import") + @Operation(summary = "导入") + @CsPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_IMPORT) + public ImportResponse realImport(@RequestPart(value = "file") MultipartFile file) { + return opportunityService.realImport(file, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + + @PostMapping("/sort") + @Operation(summary = "商机阶段看板拖拽排序") + public void sortModule(@Validated @RequestBody StageSortRequest request) { + opportunityService.sort(request, SessionUtils.getUserId()); + } + + @PostMapping("/chart") + @CsPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "客户图表生成") + public List chart(@Validated @RequestBody ChartAnalysisRequest request) { + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.OPPORTUNITY_MANAGEMENT_READ); + return opportunityService.chart(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityFollowPlanController.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityFollowPlanController.java new file mode 100644 index 0000000..5f75a70 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityFollowPlanController.java @@ -0,0 +1,89 @@ +package cn.cordys.crm.opportunity.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.follow.domain.FollowUpPlan; +import cn.cordys.crm.follow.dto.request.FollowUpPlanAddRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanPageRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanStatusRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanUpdateRequest; +import cn.cordys.crm.follow.dto.response.FollowUpPlanDetailResponse; +import cn.cordys.crm.follow.dto.response.FollowUpPlanListResponse; +import cn.cordys.crm.follow.service.FollowUpPlanService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "商机跟进计划") +@RestController +@RequestMapping("/opportunity/follow/plan") +public class OpportunityFollowPlanController { + + @Resource + private FollowUpPlanService followUpPlanService; + + @PostMapping("/add") + @CsPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_UPDATE) + @Operation(summary = "添加商机跟进计划") + public FollowUpPlan add(@Validated @RequestBody FollowUpPlanAddRequest request) { + return followUpPlanService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/update") + @CsPermission(value = PermissionConstants.OPPORTUNITY_MANAGEMENT_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.FOLLOW_PLAN) + @Operation(summary = "更新商机跟进计划") + public FollowUpPlan update(@Validated @RequestBody FollowUpPlanUpdateRequest request) { + return followUpPlanService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/page") + @CsPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "商机跟进计划列表") + public PagerWithOption> list(@Validated @RequestBody FollowUpPlanPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.FOLLOW_PLAN.getKey()); + return followUpPlanService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), "OPPORTUNITY", "CUSTOMER"); + } + + + @GetMapping("/get/{id}") + @Operation(summary = "商机跟进计划详情") + public FollowUpPlanDetailResponse get(@PathVariable String id) { + return followUpPlanService.get(id, OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/cancel/{id}") + @CsPermission(value = PermissionConstants.OPPORTUNITY_MANAGEMENT_UPDATE, resourceId = "{#id}", formType = FormKeyConstants.FOLLOW_PLAN) + @Operation(summary = "取消商机跟进计划") + public void cancelPlan(@PathVariable String id) { + followUpPlanService.cancelPlan(id, SessionUtils.getUserId()); + } + + + @GetMapping("/delete/{id}") + @Operation(summary = "商机删除跟进计划") + @CsPermission(value = PermissionConstants.OPPORTUNITY_MANAGEMENT_UPDATE, resourceId = "{#id}", formType = FormKeyConstants.FOLLOW_PLAN) + public void deletePlan(@PathVariable String id) { + followUpPlanService.delete(id); + } + + @PostMapping("/status/update") + @CsPermission(value = PermissionConstants.OPPORTUNITY_MANAGEMENT_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.FOLLOW_PLAN) + @Operation(summary = "商机更新跟进计划状态") + public void updateStatus(@Validated @RequestBody FollowUpPlanStatusRequest request) { + followUpPlanService.updateStatus(request, SessionUtils.getUserId()); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityFollowRecordController.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityFollowRecordController.java new file mode 100644 index 0000000..65b8786 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityFollowRecordController.java @@ -0,0 +1,69 @@ +package cn.cordys.crm.opportunity.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.follow.domain.FollowUpRecord; +import cn.cordys.crm.follow.dto.CustomerDataDTO; +import cn.cordys.crm.follow.dto.request.FollowUpRecordAddRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordPageRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordUpdateRequest; +import cn.cordys.crm.follow.dto.response.FollowUpRecordDetailResponse; +import cn.cordys.crm.follow.dto.response.FollowUpRecordListResponse; +import cn.cordys.crm.follow.service.FollowUpRecordService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "商机跟进记录") +@RestController +@RequestMapping("/opportunity/follow/record") +public class OpportunityFollowRecordController { + + @Resource + private FollowUpRecordService followUpRecordService; + + @PostMapping("/add") + @CsPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_UPDATE) + @Operation(summary = "添加商机跟进记录") + public FollowUpRecord add(@Validated @RequestBody FollowUpRecordAddRequest request) { + return followUpRecordService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @CsPermission(value = PermissionConstants.OPPORTUNITY_MANAGEMENT_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.FOLLOW_RECORD) + @Operation(summary = "更新商机跟进记录") + public FollowUpRecord update(@Validated @RequestBody FollowUpRecordUpdateRequest request) { + return followUpRecordService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/page") + @CsPermission(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "商机跟进记录列表") + public PagerWithOption> list(@Validated @RequestBody FollowUpRecordPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.FOLLOW_RECORD.getKey()); + return followUpRecordService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), "OPPORTUNITY", "CUSTOMER"); + } + + @GetMapping("/get/{id}") + @Operation(summary = "商机跟进记录详情") + public FollowUpRecordDetailResponse get(@PathVariable String id) { + return followUpRecordService.get(id, OrganizationContext.getOrganizationId()); + } + + @GetMapping("/delete/{id}") + @Operation(summary = "客户删除跟进记录") + @CsPermission(value = PermissionConstants.OPPORTUNITY_MANAGEMENT_UPDATE, resourceId = "{#id}", formType = FormKeyConstants.FOLLOW_RECORD) + public void deleteRecord(@PathVariable String id) { + followUpRecordService.delete(id); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityQuotationController.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityQuotationController.java new file mode 100644 index 0000000..7b5fd07 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityQuotationController.java @@ -0,0 +1,172 @@ +package cn.cordys.crm.opportunity.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.permission.CsBatchPermission; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.opportunity.domain.OpportunityQuotation; +import cn.cordys.crm.opportunity.dto.request.*; +import cn.cordys.crm.opportunity.dto.response.OpportunityQuotationGetResponse; +import cn.cordys.crm.opportunity.dto.response.OpportunityQuotationListResponse; +import cn.cordys.crm.opportunity.service.OpportunityQuotationService; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.BatchAffectReasonResponse; +import cn.cordys.crm.system.dto.response.BatchAffectSkipResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "商机报价单") +@RestController +@RequestMapping("/opportunity/quotation") +public class OpportunityQuotationController { + + @Resource + private OpportunityQuotationService opportunityQuotationService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private DataScopeService dataScopeService; + + + @GetMapping("/module/form") + @CsPermission(PermissionConstants.OPPORTUNITY_QUOTATION_READ) + @Operation(summary = "获取表单配置") + public ModuleFormConfigDTO getModuleFormConfig() { + return moduleFormCacheService.getBusinessFormConfig(FormKey.QUOTATION.getKey(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/page") + @CsPermission(PermissionConstants.OPPORTUNITY_QUOTATION_READ) + @Operation(summary = "报价单列表") + public PagerWithOption> list(@Validated @RequestBody OpportunityQuotationPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.QUOTATION.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.OPPORTUNITY_QUOTATION_READ); + return opportunityQuotationService.list(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId(), deptDataPermission, false); + } + + //新增 + @PostMapping("/add") + @CsPermission(PermissionConstants.OPPORTUNITY_QUOTATION_ADD) + @Operation(summary = "新增报价单") + public OpportunityQuotation add(@Validated @RequestBody OpportunityQuotationAddRequest request) { + return opportunityQuotationService.add(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + //编辑 + @PostMapping("/update") + @CsPermission(value = PermissionConstants.OPPORTUNITY_QUOTATION_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.QUOTATION) + @Operation(summary = "更新报价单") + public OpportunityQuotation update(@Validated @RequestBody OpportunityQuotationEditRequest request) { + return opportunityQuotationService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + //查询详情 + @GetMapping("/get/snapshot/{id}") + @CsPermission(value = PermissionConstants.OPPORTUNITY_QUOTATION_READ, resourceId = "{#id}", formType = FormKeyConstants.QUOTATION) + @Operation(summary = "获取报价单快照详情") + public OpportunityQuotationGetResponse getSnapshot(@PathVariable("id") String id) { + return opportunityQuotationService.getSnapshot(id, OrganizationContext.getOrganizationId()); + } + + //查询详情 + @GetMapping("/get/{id}") + @CsPermission(value = PermissionConstants.OPPORTUNITY_QUOTATION_READ, resourceId = "{#id}", formType = FormKeyConstants.QUOTATION) + @Operation(summary = "获取报价单详情") + public OpportunityQuotationGetResponse get(@PathVariable("id") String id) { + return opportunityQuotationService.get(id, OrganizationContext.getOrganizationId()); + } + + @GetMapping("/module/form/snapshot/{id}") + @CsPermission(value = PermissionConstants.OPPORTUNITY_QUOTATION_READ, resourceId = "{#id}", formType = FormKeyConstants.QUOTATION) + @Operation(summary = "获取表单快照配置") + public ModuleFormConfigDTO getFormSnapshot(@PathVariable("id") String id) { + return opportunityQuotationService.getFormSnapshot(id, OrganizationContext.getOrganizationId()); + } + + //撤销审批 + @GetMapping("/revoke/{id}") + @Operation(summary = "撤销报价单审批") + public String revoke(@PathVariable("id") String id) { + return opportunityQuotationService.revoke(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + //作废 + @GetMapping("/voided/{id}") + @CsPermission(value = PermissionConstants.OPPORTUNITY_QUOTATION_VOIDED, resourceId = "{#id}", formType = FormKeyConstants.QUOTATION) + @Operation(summary = "作废报价单") + public void voidQuotation(@PathVariable("id") String id) { + opportunityQuotationService.voidQuotation(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + //批量作废报价单 + @PostMapping("/batch/voided") + @CsBatchPermission(value = PermissionConstants.OPPORTUNITY_QUOTATION_VOIDED, resourceId = "{#request.ids}", formType = FormKeyConstants.QUOTATION) + @Operation(summary = "批量作废报价单") + public BatchAffectReasonResponse batchVoidQuotation(@Validated @RequestBody OpportunityQuotationBatchVoidedRequest request) { + return opportunityQuotationService.batchVoidQuotation(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + //审批 + @PostMapping("/approve") + @CsPermission(value = PermissionConstants.OPPORTUNITY_QUOTATION_APPROVAL, resourceId = "{#request.id}", formType = FormKeyConstants.QUOTATION) + @Operation(summary = "审批报价单") + public String approve(@RequestBody OpportunityQuotationEditRequest request) { + return opportunityQuotationService.approve(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + //批量审批 + @PostMapping("/batch/approve") + @CsBatchPermission(value = PermissionConstants.OPPORTUNITY_QUOTATION_APPROVAL, resourceId = "{#request.ids}", formType = FormKeyConstants.QUOTATION) + @Operation(summary = "批量审批报价单") + public BatchAffectSkipResponse batchApprove(@Validated @RequestBody OpportunityQuotationBatchRequest request) { + return opportunityQuotationService.batchApprove(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch/update") + @CsBatchPermission(value = PermissionConstants.OPPORTUNITY_QUOTATION_UPDATE, resourceId = "{#request.ids}", formType = FormKeyConstants.QUOTATION) + @Operation(summary = "批量更新报价单") + public BatchAffectReasonResponse batchUpdate(@Validated @RequestBody ResourceBatchEditRequest request) { + return opportunityQuotationService.batchUpdate(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + //删除报价单 + @GetMapping("/delete/{id}") + @CsPermission(value = PermissionConstants.OPPORTUNITY_QUOTATION_DELETE, resourceId = "{#id}", formType = FormKeyConstants.QUOTATION) + @Operation(summary = "删除报价单") + public void delete(@PathVariable("id") String id) { + opportunityQuotationService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/tab") + @CsPermission(PermissionConstants.OPPORTUNITY_QUOTATION_READ) + @Operation(summary = "所有商机报价单和部门商机报价单tab是否显示") + public ResourceTabEnableDTO getTabEnableConfig() { + return opportunityQuotationService.getTabEnableConfig(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/download/{id}") + @CsPermission(value = PermissionConstants.OPPORTUNITY_QUOTATION_DOWNLOAD, resourceId = "{#id}", formType = FormKeyConstants.QUOTATION) + @Operation(summary = "下载报价单日志记录") + public void download(@PathVariable("id") String id) { + opportunityQuotationService.download(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityQuotationUserViewController.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityQuotationUserViewController.java new file mode 100644 index 0000000..f685251 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityQuotationUserViewController.java @@ -0,0 +1,95 @@ +package cn.cordys.crm.opportunity.controller; + + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.UserViewResourceType; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import cn.cordys.crm.system.dto.response.UserViewListResponse; +import cn.cordys.crm.system.dto.response.UserViewResponse; +import cn.cordys.crm.system.service.UserViewService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "商机报价单视图") +@RestController +@RequestMapping("/opportunity/quotation/view") +public class OpportunityQuotationUserViewController { + + @Resource + private UserViewService userViewService; + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_QUOTATION_READ) + @Operation(summary = "添加报价单视图") + public UserView add(@Validated @RequestBody UserViewAddRequest request) { + return userViewService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), UserViewResourceType.OPPORTUNITY_QUOTATION.name()); + } + + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_QUOTATION_READ) + @Operation(summary = "编辑报价单视图") + public UserView update(@Validated @RequestBody UserViewUpdateRequest request) { + return userViewService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_QUOTATION_READ) + @Operation(summary = "删除报价单视图") + public void delete(@PathVariable String id) { + userViewService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/detail/{id}") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_QUOTATION_READ) + @Operation(summary = "报价单视图详情") + public UserViewResponse viewDetail(@PathVariable String id) { + return userViewService.getViewDetail(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), FormKey.QUOTATION.getKey()); + } + + + @GetMapping("/list") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_QUOTATION_READ) + @Operation(summary = "报价单视图列表") + public List queryList() { + return userViewService.list(UserViewResourceType.OPPORTUNITY_QUOTATION.name(), SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/fixed/{id}") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_QUOTATION_READ) + @Operation(summary = "报价单视图固定/取消固定") + public void fixed(@PathVariable String id) { + userViewService.fixed(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/edit/pos") + @Operation(summary = "报价单视图-拖拽排序") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_QUOTATION_READ) + public void editPos(@Validated @RequestBody PosRequest request) { + userViewService.editPos(request, SessionUtils.getUserId(), UserViewResourceType.OPPORTUNITY_QUOTATION.name()); + } + + + @GetMapping("/enable/{id}") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_QUOTATION_READ) + @Operation(summary = "报价单视图-启用/禁用") + public void enable(@PathVariable String id) { + userViewService.enable(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityRuleController.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityRuleController.java new file mode 100644 index 0000000..e054aaf --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityRuleController.java @@ -0,0 +1,67 @@ +package cn.cordys.crm.opportunity.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.opportunity.dto.OpportunityRuleDTO; +import cn.cordys.crm.opportunity.dto.request.OpportunityRuleAddRequest; +import cn.cordys.crm.opportunity.dto.request.OpportunityRuleUpdateRequest; +import cn.cordys.crm.opportunity.service.OpportunityRuleService; +import cn.cordys.security.SessionUtils; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/opportunity-rule") +@Tag(name = "商机规则") +public class OpportunityRuleController { + + @Resource + private OpportunityRuleService opportunityRuleService; + + @PostMapping("/page") + @Operation(summary = "分页获取商机规则") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public Pager> page(@Validated @RequestBody BasePageRequest request) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, opportunityRuleService.page(request, OrganizationContext.getOrganizationId())); + } + + @PostMapping("/add") + @Operation(summary = "添加商机规则") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void save(@Validated @RequestBody OpportunityRuleAddRequest request) { + opportunityRuleService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @Operation(summary = "修改商机规则") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void update(@Validated @RequestBody OpportunityRuleUpdateRequest request) { + opportunityRuleService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/delete/{id}") + @Operation(summary = "删除商机规则") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void delete(@PathVariable String id) { + opportunityRuleService.delete(id); + } + + @GetMapping("/switch/{id}") + @Operation(summary = "启用/禁用商机规则") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void switchStatus(@PathVariable String id) { + opportunityRuleService.switchStatus(id, SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityStageController.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityStageController.java new file mode 100644 index 0000000..13fc565 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityStageController.java @@ -0,0 +1,74 @@ +package cn.cordys.crm.opportunity.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.stage.StageRollBackRequest; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.opportunity.dto.request.OpportunityStageAddRequest; +import cn.cordys.crm.opportunity.dto.request.StageUpdateRequest; +import cn.cordys.crm.opportunity.dto.response.StageConfigListResponse; +import cn.cordys.crm.opportunity.service.OpportunityStageService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "商机阶段设置") +@RestController +@RequestMapping("/opportunity/stage") +public class OpportunityStageController { + @Resource + private OpportunityStageService opportunityStageService; + + + @GetMapping("/get") + @Operation(summary = "商机阶段配置列表") + public StageConfigListResponse getStageConfigList() { + return opportunityStageService.getStageConfigList(OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/add") + @Operation(summary = "添加商机阶段") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public String add(@RequestBody OpportunityStageAddRequest request) { + return opportunityStageService.addStageConfig(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @Operation(summary = "删除商机阶段") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void delete(@PathVariable("id") @Validated String id) { + opportunityStageService.delete(id, OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/update-rollback") + @Operation(summary = "商机阶段回退设置") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void update(@Validated @RequestBody StageRollBackRequest request) { + opportunityStageService.updateRollBack(request, OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/update") + @Operation(summary = "更新商机阶段配置") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void update(@Validated @RequestBody StageUpdateRequest request) { + opportunityStageService.update(request, SessionUtils.getUserId()); + } + + + @PostMapping("/sort") + @Operation(summary = "商机阶段排序") + @RequiresPermissions(PermissionConstants.MODULE_SETTING_UPDATE) + public void sort(@RequestBody List ids) { + opportunityStageService.sort(ids, OrganizationContext.getOrganizationId()); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityUserViewController.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityUserViewController.java new file mode 100644 index 0000000..67ffe4b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityUserViewController.java @@ -0,0 +1,95 @@ +package cn.cordys.crm.opportunity.controller; + + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.UserViewResourceType; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import cn.cordys.crm.system.dto.response.UserViewListResponse; +import cn.cordys.crm.system.dto.response.UserViewResponse; +import cn.cordys.crm.system.service.UserViewService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "商机视图") +@RestController +@RequestMapping("/opportunity/view") +public class OpportunityUserViewController { + + @Resource + private UserViewService userViewService; + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "添加商机视图") + public UserView add(@Validated @RequestBody UserViewAddRequest request) { + return userViewService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), UserViewResourceType.OPPORTUNITY.name()); + } + + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "编辑商机视图") + public UserView update(@Validated @RequestBody UserViewUpdateRequest request) { + return userViewService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "删除商机视图") + public void delete(@PathVariable String id) { + userViewService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/detail/{id}") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "商机视图详情") + public UserViewResponse viewDetail(@PathVariable String id) { + return userViewService.getViewDetail(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), FormKey.OPPORTUNITY.getKey()); + } + + + @GetMapping("/list") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "商机视图列表") + public List queryList() { + return userViewService.list(UserViewResourceType.OPPORTUNITY.name(), SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/fixed/{id}") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "商机视图固定/取消固定") + public void fixed(@PathVariable String id) { + userViewService.fixed(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/edit/pos") + @Operation(summary = "商机视图-拖拽排序") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + public void editPos(@Validated @RequestBody PosRequest request) { + userViewService.editPos(request, SessionUtils.getUserId(), UserViewResourceType.OPPORTUNITY.name()); + } + + + @GetMapping("/enable/{id}") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "商机视图-启用/禁用") + public void enable(@PathVariable String id) { + userViewService.enable(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/Opportunity.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/Opportunity.java new file mode 100644 index 0000000..67a182a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/Opportunity.java @@ -0,0 +1,67 @@ +package cn.cordys.crm.opportunity.domain; + +import cn.cordys.common.domain.BaseModel; +import cn.cordys.common.util.BigDecimalNoTrailingZeroSerializer; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + + +@Data +@Table(name = "opportunity") +public class Opportunity extends BaseModel { + + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "商机名称") + private String name; + + @Schema(description = "金额") + @JsonSerialize(using = BigDecimalNoTrailingZeroSerializer.class) + private BigDecimal amount; + + @Schema(description = "可能性") + @JsonSerialize(using = BigDecimalNoTrailingZeroSerializer.class) + private BigDecimal possible; + + @Schema(description = "意向产品id") + private List products; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "上次修改前的商机阶段") + private String lastStage; + + @Schema(description = "商机阶段") + private String stage; + + @Schema(description = "联系人") + private String contactId; + + @Schema(description = "责任人") + private String owner; + + @Schema(description = "最新跟进人") + private String follower; + + @Schema(description = "最新跟进时间") + private Long followTime; + + @Schema(description = "结束时间") + private Long expectedEndTime; + + @Schema(description = "实际结束时间") + private Long actualEndTime; + + @Schema(description = "失败原因") + private String failureReason; + + @Schema(description = "自定义排序") + private Long pos; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityField.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityField.java new file mode 100644 index 0000000..1f9681e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityField.java @@ -0,0 +1,10 @@ +package cn.cordys.crm.opportunity.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "opportunity_field") +public class OpportunityField extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityFieldBlob.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityFieldBlob.java new file mode 100644 index 0000000..73a400b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityFieldBlob.java @@ -0,0 +1,10 @@ +package cn.cordys.crm.opportunity.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "opportunity_field_blob") +public class OpportunityFieldBlob extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityQuotation.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityQuotation.java new file mode 100644 index 0000000..08aaee8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityQuotation.java @@ -0,0 +1,39 @@ +package cn.cordys.crm.opportunity.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 商机报价单; + */ +@Data +@Table(name = "opportunity_quotation") +public class OpportunityQuotation extends BaseModel { + @Schema(description = "名称") + private String name; + + @Schema(description = "商机id") + private String opportunityId; + + @Schema(description = "有效期至") + private Long untilTime; + + @Schema(description = "累计金额") + private BigDecimal amount; + + @Schema(description = "审核状态") + private String approvalStatus; + + @Schema(description = "是否作废") + private Boolean invalid; + + @Schema(description = "组织ID") + private String organizationId; + + @Schema(description = "是否审批通过过") + private Boolean approved; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityQuotationApproval.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityQuotationApproval.java new file mode 100644 index 0000000..d62b3a3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityQuotationApproval.java @@ -0,0 +1,39 @@ +package cn.cordys.crm.opportunity.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 商机报价单审批; + */ +@Data +@Table(name = "opportunity_quotation_approval") +public class OpportunityQuotationApproval implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "商机报价单id") + private String quotationId; + + @Schema(description = "审核状态") + private String approvalStatus; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityQuotationField.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityQuotationField.java new file mode 100644 index 0000000..93f6a23 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityQuotationField.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.opportunity.domain; + +import cn.cordys.common.domain.BaseResourceSubField; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * 商机报价单自定义属性; + */ +@Data +@Table(name = "opportunity_quotation_field") +public class OpportunityQuotationField extends BaseResourceSubField { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityQuotationFieldBlob.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityQuotationFieldBlob.java new file mode 100644 index 0000000..ed0a633 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityQuotationFieldBlob.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.opportunity.domain; + +import cn.cordys.common.domain.BaseResourceSubField; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * 商机报价单自定义属性大文本; + */ +@Data +@Table(name = "opportunity_quotation_field_blob") +public class OpportunityQuotationFieldBlob extends BaseResourceSubField { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityQuotationSnapshot.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityQuotationSnapshot.java new file mode 100644 index 0000000..3ff74d0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityQuotationSnapshot.java @@ -0,0 +1,30 @@ +package cn.cordys.crm.opportunity.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 商机报价单快照; + */ +@Data +@Table(name = "opportunity_quotation_snapshot") +public class OpportunityQuotationSnapshot implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "报价单id") + private String quotationId; + + @Schema(description = "表单属性快照") + private String quotationProp; + + @Schema(description = "表单值快照") + private String quotationValue; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityRule.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityRule.java new file mode 100644 index 0000000..38819bc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityRule.java @@ -0,0 +1,35 @@ +package cn.cordys.crm.opportunity.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "opportunity_rule") +public class OpportunityRule extends BaseModel { + + @Schema(description = "规则名称") + private String name; + + @Schema(description = "组织ID") + private String organizationId; + + @Schema(description = "管理员ID") + private String ownerId; + + @Schema(description = "范围ID") + private String scopeId; + + @Schema(description = "启用/禁用") + private Boolean enable; + + @Schema(description = "自动回收") + private Boolean auto; + + @Schema(description = "操作符") + private String operator; + + @Schema(description = "回收条件") + private String condition; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityStageConfig.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityStageConfig.java new file mode 100644 index 0000000..f15b26e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityStageConfig.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.opportunity.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "opportunity_stage_config") +public class OpportunityStageConfig extends BaseModel { + + @Schema(description = "值") + private String name; + + @Schema(description = "类型") + private String type; + + @Schema(description = "赢率") + private String rate; + + @Schema(description = "进行中回退设置") + private Boolean afootRollBack; + + @Schema(description = "完结回退设置") + private Boolean endRollBack; + + @Schema(description = "顺序") + private Long pos; + + @Schema(description = "组织id") + private String organizationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/OpportunityRuleDTO.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/OpportunityRuleDTO.java new file mode 100644 index 0000000..7eacc8c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/OpportunityRuleDTO.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.opportunity.dto; + +import cn.cordys.crm.opportunity.domain.OpportunityRule; +import cn.cordys.crm.system.dto.ScopeNameDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class OpportunityRuleDTO extends OpportunityRule { + + @Schema(description = "成员集合") + private List members; + @Schema(description = "管理员集合") + private List owners; + @Schema(description = "创建人名称") + private String createUserName; + @Schema(description = "更新人名称") + private String updateUserName; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityAddRequest.java new file mode 100644 index 0000000..5581094 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityAddRequest.java @@ -0,0 +1,55 @@ +package cn.cordys.crm.opportunity.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +@Data +public class OpportunityAddRequest { + + @NotBlank + @Size(max = 255) + @Schema(description = "商机名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Size(max = 32) + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "金额") + private BigDecimal amount; + + @Schema(description = "意向产品", requiredMode = Schema.RequiredMode.REQUIRED) + private List products; + + @Schema(description = "可能性") + private BigDecimal possible; + + @Size(max = 32) + @Schema(description = "联系人", requiredMode = Schema.RequiredMode.REQUIRED) + private String contactId; + + @Size(max = 32) + @Schema(description = "负责人", requiredMode = Schema.RequiredMode.REQUIRED) + private String owner; + + + @Schema(description = "结束时间") + private Long expectedEndTime; + + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "最新跟进人(转商机时需录入)") + private String follower; + + @Schema(description = "最新跟进时间(转商机时需录入)") + private Long followTime; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityExportRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityExportRequest.java new file mode 100644 index 0000000..7621432 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityExportRequest.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.opportunity.dto.request; + +import cn.cordys.common.dto.ExportHeadDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class OpportunityExportRequest extends OpportunityPageRequest { + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "表头信息") + @NotEmpty(message = "{export_head_list_is_empty}") + private List headList; + + @Schema(description = "客户ID") + private String customerId; + + @Override + public String getCustomerId() { + return customerId; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityPageRequest.java new file mode 100644 index 0000000..e0da61a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityPageRequest.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.opportunity.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class OpportunityPageRequest extends BasePageRequest { + + @Schema(description = "看板模式") + private Boolean board = false; + + public String getCustomerId() { + return null; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityQuotationAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityQuotationAddRequest.java new file mode 100644 index 0000000..252dd26 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityQuotationAddRequest.java @@ -0,0 +1,41 @@ +package cn.cordys.crm.opportunity.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class OpportunityQuotationAddRequest { + + @NotBlank(message = "{opportunity.quotation.name.required}") + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @NotBlank(message = "{opportunity.required}") + @Schema(description = "商机id", requiredMode = Schema.RequiredMode.REQUIRED) + private String opportunityId; + + @Schema(description = "有效期至", requiredMode = Schema.RequiredMode.REQUIRED) + private Long untilTime; + + @Schema(description = "累计金额") + private String amount; + + @NotEmpty(message = "{opportunity.quotation.field.required}") + @Schema(description = "自定义字段值", requiredMode = Schema.RequiredMode.REQUIRED) + private List moduleFields; + + @NotNull(message = "{opportunity.quotation.form.config.required}") + @Schema(description = "表单配置", requiredMode = Schema.RequiredMode.REQUIRED) + private ModuleFormConfigDTO moduleFormConfigDTO; + + @Schema(description = "子产品信息", requiredMode = Schema.RequiredMode.REQUIRED) + private List> products; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityQuotationBatchRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityQuotationBatchRequest.java new file mode 100644 index 0000000..2857c21 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityQuotationBatchRequest.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.opportunity.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class OpportunityQuotationBatchRequest { + + @NotEmpty + @Schema(description = "ID集合") + private List ids; + + @NotBlank + @Schema(description = "审批状态") + private String approvalStatus; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityQuotationBatchVoidedRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityQuotationBatchVoidedRequest.java new file mode 100644 index 0000000..547d0aa --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityQuotationBatchVoidedRequest.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.opportunity.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class OpportunityQuotationBatchVoidedRequest { + + @NotEmpty + @Schema(description = "ID集合") + private List ids; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityQuotationEditRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityQuotationEditRequest.java new file mode 100644 index 0000000..0eae659 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityQuotationEditRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.opportunity.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class OpportunityQuotationEditRequest extends OpportunityQuotationAddRequest { + + @NotBlank(message = "{opportunity.quotation.id.required}") + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "审批状态", requiredMode = Schema.RequiredMode.REQUIRED) + private String approvalStatus; + + @Schema(description = "是否提审更新 normal-正常更新 approval-评审更新") + private String updateType; + + @Schema(description = "变更说明") + private String comment; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityQuotationPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityQuotationPageRequest.java new file mode 100644 index 0000000..b0b65d7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityQuotationPageRequest.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.opportunity.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * @author guoyuqi + */ +@Data +public class OpportunityQuotationPageRequest extends BasePageRequest { + + /** + * 商机id + */ + @Schema(description = "商机id") + private String opportunityId; + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityRuleAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityRuleAddRequest.java new file mode 100644 index 0000000..3c36a4f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityRuleAddRequest.java @@ -0,0 +1,44 @@ +package cn.cordys.crm.opportunity.dto.request; + +import cn.cordys.crm.system.dto.RuleConditionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class OpportunityRuleAddRequest { + + @NotBlank + @Size(max = 255) + @Schema(description = "规则名称") + private String name; + + @NotNull + @Schema(description = "范围ID集合") + private List scopeIds; + + @NotNull + @Schema(description = "管理员ID集合") + private List ownerIds; + + @NotNull + @Schema(description = "是否开启") + private Boolean enable; + + @NotNull + @Schema(description = "自动回收") + private Boolean auto; + + @Size(max = 10) + @Schema(description = "操作符") + private String operator; + + @Schema(description = "规则条件集合") + private List conditions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityRuleUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityRuleUpdateRequest.java new file mode 100644 index 0000000..a3ab852 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityRuleUpdateRequest.java @@ -0,0 +1,47 @@ +package cn.cordys.crm.opportunity.dto.request; + +import cn.cordys.crm.system.dto.RuleConditionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class OpportunityRuleUpdateRequest { + + @NotBlank + @Size(max = 32) + @Schema(description = "ID") + private String id; + + @NotBlank + @Size(max = 255) + @Schema(description = "规则名称") + private String name; + + @NotNull + @Schema(description = "范围ID集合") + private List scopeIds; + + @NotNull + @Schema(description = "管理员ID集合") + private List ownerIds; + + @NotNull + @Schema(description = "是否开启") + private Boolean enable; + + @NotNull + @Schema(description = "自动回收") + private Boolean auto; + + @Size(max = 10) + @Schema(description = "操作符") + private String operator; + + @Schema(description = "规则条件集合") + private List conditions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunitySearchStatisticRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunitySearchStatisticRequest.java new file mode 100644 index 0000000..842f18f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunitySearchStatisticRequest.java @@ -0,0 +1,11 @@ +package cn.cordys.crm.opportunity.dto.request; + +import cn.cordys.common.dto.condition.BaseCondition; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class OpportunitySearchStatisticRequest extends BaseCondition { + @Schema(description = "客户ID") + private String customerId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityStageAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityStageAddRequest.java new file mode 100644 index 0000000..6adeb03 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityStageAddRequest.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.opportunity.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class OpportunityStageAddRequest { + + @Schema(description = "值") + private String name; + + @Schema(description = "类型") + private String type; + + @Schema(description = "赢率") + private String rate; + + @Schema(description = "添加的位置(取值:-1,,1。 -1:源节点之前,1:源节点之后)", requiredMode = Schema.RequiredMode.REQUIRED) + private int dropPosition; + + @Schema(description = "源节点") + private String targetId; + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityStageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityStageRequest.java new file mode 100644 index 0000000..8a81e9a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityStageRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.opportunity.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + + +@Data +public class OpportunityStageRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @NotBlank + @Schema(description = "商机阶段", requiredMode = Schema.RequiredMode.REQUIRED) + private String stage; + + @Schema(description = "失败原因") + private String failureReason; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityTransferRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityTransferRequest.java new file mode 100644 index 0000000..74be403 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityTransferRequest.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.opportunity.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class OpportunityTransferRequest { + + @NotBlank + @Schema(description = "ids", requiredMode = Schema.RequiredMode.REQUIRED) + private List ids; + + @NotBlank + @Size(max = 32) + @Schema(description = "修改负责人", requiredMode = Schema.RequiredMode.REQUIRED) + private String owner; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityUpdateRequest.java new file mode 100644 index 0000000..501890c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/OpportunityUpdateRequest.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.opportunity.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class OpportunityUpdateRequest extends OpportunityAddRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 32) + private String id; + + + private Boolean agentInvoke = false; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/StageMoveRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/StageMoveRequest.java new file mode 100644 index 0000000..7f98946 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/StageMoveRequest.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.opportunity.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class StageMoveRequest { + + @Schema(description = "ids") + private List ids; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/StageUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/StageUpdateRequest.java new file mode 100644 index 0000000..a72b175 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/request/StageUpdateRequest.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.opportunity.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class StageUpdateRequest { + + @Schema(description = "id") + private String id; + + @Schema(description = "值") + private String name; + + @Schema(description = "赢率") + private String rate; +} + diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunityDetailResponse.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunityDetailResponse.java new file mode 100644 index 0000000..d45c6e6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunityDetailResponse.java @@ -0,0 +1,123 @@ +package cn.cordys.crm.opportunity.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +@Data +public class OpportunityDetailResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "商机名称") + private String name; + + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "客户名称") + private String customerName; + + @Schema(description = "金额") + private BigDecimal amount; + + @Schema(description = "可能性") + private BigDecimal possible; + + @Schema(description = "意向产品") + private List products; + + @Schema(description = "联系人") + private String contactId; + + @Schema(description = "联系人名称") + private String contactName; + + @Schema(description = "上次修改前的商机阶段") + private String lastStage; + + @Schema(description = "商机阶段") + private String stage; + + @Schema(description = "商机阶段名称") + private String stageName; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "结束时间") + private Long expectedEndTime; + + @Schema(description = "结束时间") + private Long actualEndTime; + + @Schema(description = "失败原因") + private String failureReason; + + @Schema(description = "关联的客户是否在公海") + private Boolean inCustomerPool; + + @Schema(description = "客户公海id") + private String poolId; + + @Schema(description = "剩余归属天数") + private Integer reservedDays; + + @Schema(description = "最新跟进人") + private String follower; + + @Schema(description = "最新跟进人名称") + private String followerName; + + @Schema(description = "最新跟进日期") + private Long followTime; + + @Schema(description = "部门id") + private String departmentId; + + @Schema(description = "部门名称") + private String departmentName; + + @Schema(description = "组织ID") + private String organizationId; + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "选项集合") + private Map> optionMap; + + /** + * 附件集合 + */ + @Schema(description = "附件集合") + private Map> attachmentMap; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunityListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunityListResponse.java new file mode 100644 index 0000000..09c5bd0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunityListResponse.java @@ -0,0 +1,108 @@ +package cn.cordys.crm.opportunity.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +@Data +public class OpportunityListResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "商机名称") + private String name; + + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "客户名称") + private String customerName; + + @Schema(description = "关联的客户是否在公海") + private Boolean inCustomerPool; + + @Schema(description = "客户公海id") + private String poolId; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "金额") + private BigDecimal amount; + + @Schema(description = "可能性") + private BigDecimal possible; + + @Schema(description = "意向产品") + private List products; + + @Schema(description = "联系人") + private String contactId; + + @Schema(description = "联系人名称") + private String contactName; + + @Schema(description = "上次修改前的商机阶段") + private String lastStage; + + @Schema(description = "商机阶段") + private String stage; + + @Schema(description = "商机阶段") + private String stageName; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "剩余归属天数") + private Integer reservedDays; + + @Schema(description = "最新跟进人") + private String follower; + + @Schema(description = "最新跟进人名称") + private String followerName; + + @Schema(description = "最新跟进日期") + private Long followTime; + + @Schema(description = "部门id") + private String departmentId; + + @Schema(description = "部门名称") + private String departmentName; + + @Schema(description = "结束时间") + private Long expectedEndTime; + + @Schema(description = "结束时间") + private Long actualEndTime; + + @Schema(description = "失败原因") + private String failureReason; + + @Schema(description = "自定义字段集合") + private List moduleFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunityQuotationGetResponse.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunityQuotationGetResponse.java new file mode 100644 index 0000000..f43b372 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunityQuotationGetResponse.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.opportunity.dto.response; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * 商机报价单响应类; + * + * @author guoyuqi + */ +@Data +public class OpportunityQuotationGetResponse extends OpportunityQuotationListResponse { + + @Schema(description = "选项集合") + private Map> optionMap; + /** + * 附件集合 + */ + @Schema(description = "附件集合") + private Map> attachmentMap; + + @Schema(description = "产品子列表") + private List> products; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunityQuotationListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunityQuotationListResponse.java new file mode 100644 index 0000000..6c706e9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunityQuotationListResponse.java @@ -0,0 +1,75 @@ +package cn.cordys.crm.opportunity.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 商机报价单列表响应类; + * + * @author guoyuqi + */ +@Data +public class OpportunityQuotationListResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "名称") + private String name; + + @Schema(description = "商机id") + private String opportunityId; + + @Schema(description = "商机名称") + private String opportunityName; + + @Schema(description = "部门id") + private String departmentId; + + @Schema(description = "部门名称") + private String departmentName; + + @Schema(description = "累计金额") + private BigDecimal amount; + + @Schema(description = "审核状态") + private String approvalStatus; + + @Schema(description = "是否作废") + private Boolean invalid; + + @Schema(description = "有效期至") + private Long untilTime; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "审批第一个节点是否通过") + private Boolean firstApproved; + + @Schema(description = "是否审批通过过") + private Boolean approved; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunitySearchStatisticResponse.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunitySearchStatisticResponse.java new file mode 100644 index 0000000..2e0c01a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunitySearchStatisticResponse.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.opportunity.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class OpportunitySearchStatisticResponse { + + @Schema(description = "总金额") + private Double amount = 0.0; + + @Schema(description = "平均金额") + private Double averageAmount = 0.0; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/StageConfigListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/StageConfigListResponse.java new file mode 100644 index 0000000..6500db6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/StageConfigListResponse.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.opportunity.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class StageConfigListResponse { + + @Schema(description = "商机阶段配置列表") + List stageConfigList; + + @Schema(description = "进行中回退设置") + private Boolean afootRollBack = true; + + @Schema(description = "完结回退设置") + private Boolean endRollBack = false; + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/StageConfigResponse.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/StageConfigResponse.java new file mode 100644 index 0000000..803652d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/StageConfigResponse.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.opportunity.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class StageConfigResponse { + + @Schema(description = "ID") + private String id; + @Schema(description = "值") + private String name; + + @Schema(description = "类型") + private String type; + + @Schema(description = "赢率") + private String rate; + + @Schema(description = "进行中回退设置") + private Boolean afootRollBack; + + @Schema(description = "完结回退设置") + private Boolean endRollBack; + + @Schema(description = "顺序") + private Long pos; + + @Schema(description = "当前阶段是否存在数据") + private Boolean stageHasData = false; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityMapper.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityMapper.java new file mode 100644 index 0000000..3232032 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityMapper.java @@ -0,0 +1,106 @@ +package cn.cordys.crm.opportunity.mapper; + + +import cn.cordys.common.dto.*; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.crm.customer.dto.request.CustomerMergeRequest; +import cn.cordys.crm.home.dto.request.HomeStatisticSearchWrapperRequest; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.opportunity.dto.request.OpportunityAddRequest; +import cn.cordys.crm.opportunity.dto.request.OpportunityPageRequest; +import cn.cordys.crm.opportunity.dto.request.OpportunitySearchStatisticRequest; +import cn.cordys.crm.opportunity.dto.request.OpportunityTransferRequest; +import cn.cordys.crm.opportunity.dto.response.OpportunityDetailResponse; +import cn.cordys.crm.opportunity.dto.response.OpportunityListResponse; +import cn.cordys.crm.opportunity.dto.response.OpportunitySearchStatisticResponse; +import cn.cordys.crm.search.response.advanced.AdvancedOpportunityResponse; +import cn.cordys.crm.search.response.advanced.OpportunityRepeatResponse; +import cn.cordys.crm.search.response.global.GlobalOpportunityResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + + +public interface ExtOpportunityMapper { + + + List list(@Param("request") OpportunityPageRequest request, @Param("orgId") String orgId, + @Param("userId") String userId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission, @Param("source") boolean source); + + OpportunitySearchStatisticResponse searchStatistic(@Param("request") OpportunitySearchStatisticRequest request, @Param("orgId") String orgId, + @Param("userId") String userId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission); + + List selectByProducts(@Param("request") OpportunityAddRequest request, @Param("orgId") String orgId, @Param("id") String id); + + void batchTransfer(@Param("request") OpportunityTransferRequest request, @Param("userId") String userId, @Param("updateTime") long updateTime, @Param("stage") String stage); + + OpportunityDetailResponse getDetail(@Param("id") String id); + + List getRepeatList(@Param("customerId") String customerId); + + List getRepeatCountMap(@Param("customerIds") List customerIds); + + int countByOwner(@Param("owner") String owner); + + List getOpportunityOptionsByIds(@Param("ids") List ids); + + List getOpportunityOptions(@Param("keyword") String keyword, @Param("orgId") String orgId); + + + List getListByIds(@Param("ids") List ids); + + Long selectOpportunityCount(@Param("request") HomeStatisticSearchWrapperRequest request, @Param("amount") boolean amount); + + List advancedSearchList(@Param("request") OpportunityPageRequest request, @Param("orgId") String orgId); + + List globalSearchList(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + long globalSearchListCount(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + List searchColumnsByIds(@Param("columns") List columns, @Param("ids") List opportunityIds); + + /** + * 全量更新商机 + * + * @param opportunity 商机 + */ + void updateIncludeNullById(@Param("opportunity") Opportunity opportunity); + + void batchUpdate(@Param("request") BatchUpdateDbParam request); + + void moveUpOpportunity(@Param("start") Long start, @Param("end") Long end, @Param("stage") String stage); + + void moveDownOpportunity(@Param("start") Long start, @Param("end") Long end, @Param("stage") String stage); + + Long selectNextPos(@Param("orgId") String orgId, @Param("stage") String stage); + + void transfer(@Param("owner") String owner, @Param("userId") String userId, @Param("id") String id, @Param("updateTime") long updateTime); + + void moveDownStageOpportunity(@Param("end") Long end, @Param("stage") String stage, @Param("pos") Long pos); + + void moveUpStageOpportunity(@Param("end") Long end, @Param("stage") String stage, @Param("pos") Long pos); + + int countByStage(@Param("stageId") String stageId); + + /** + * 批量合并客户商机 + * + * @param request 请求参数 + * @param userId 用户ID + * @param orgId 组织ID + */ + void batchMerge(@Param("request") CustomerMergeRequest request, @Param("userId") String userId, @Param("orgId") String orgId); + + /** + * 获取待合并的客户商机列表 + * + * @param request 请求参数 + * @param orgId 组织ID + * + * @return 客户商机列表 + */ + List getMergeOpportunityList(@Param("request") CustomerMergeRequest request, @Param("orgId") String orgId); + + List chart(@Param("request") ChartAnalysisDbRequest request, @Param("userId") String userId, @Param("orgId") String orgId, + @Param("dataPermission") DeptDataPermissionDTO dataPermission); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityMapper.xml new file mode 100644 index 0000000..3d91943 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityMapper.xml @@ -0,0 +1,1010 @@ + + + + + + + + + + + + + + + + + + order by + + + o.`name` ${sortType}, o.id ASC + + + c.`name` ${sortType}, o.id ASC + + + o.`owner` ${sortType}, o.id ASC + + + o.`amount` ${sortType}, o.id ASC + + + o.`possible` ${sortType}, o.id ASC + + + o.`stage` ${sortType}, o.id ASC + + + o.`contact_id` ${sortType}, o.id ASC + + + o.`create_time` ${sortType}, o.id ASC + + + o.`update_time` ${sortType}, o.id ASC + + + o.`create_user` ${sortType}, o.id ASC + + + o.`update_user` ${sortType}, o.id ASC + + + o.`follow_time` ${sortType}, o.id ASC + + + o.`follower` ${sortType}, o.id ASC + + + o.`expected_end_time` ${sortType}, o.id ASC + + + o.`actual_end_time` ${sortType}, o.id ASC + + + o.`failure_reason` ${sortType}, o.id ASC + + + souc_sort.`id` ${sortType}, o.id ASC + + + + CASE + WHEN module_table.type = 'INPUT_NUMBER' OR module_table.type = 'FORMULA' + THEN sort_table.field_value+0 + END ${sortType}, + sort_table.field_value ${sortType}, + o.id ${sortType} + + + + + order by o.create_time desc, o.id asc + + + + + + + + + + + + + + + + + + + + left join opportunity_field_blob ${tablePrefix}_${i} + on o.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + left join opportunity_field ${tablePrefix}_${i} + on o.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + + left join sys_organization_user ${tablePrefix}_${i} + on ${tablePrefix}_${i}.user_id = o.owner + + + + + + + + + + + left join opportunity_field sort_table + on o.id = sort_table.resource_id and sort_table.field_id = #{sortName} + left join sys_module_field module_table on sort_table.field_id = module_table.id + + + left join sys_organization_user souc_sort on souc_sort.user_id = o.owner + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + update opportunity + set owner = #{request.owner}, stage = #{stage}, last_stage = Null, update_user = #{userId}, update_time = #{updateTime} + where id in + + #{id} + + + + + update opportunity set owner = #{owner}, update_user = #{userId}, update_time = #{updateTime} where id = #{id} + + + + + + + + + + + + + + + + + + + + + + + + + + + update opportunity set customer_id = #{opportunity.customerId}, name = #{opportunity.name}, amount = #{opportunity.amount}, + possible = #{opportunity.possible}, products = #{opportunity.products,typeHandler=cn.cordys.common.handler.ListTypeHandler}, contact_id = #{opportunity.contactId}, + owner = #{opportunity.owner}, expected_end_time = #{opportunity.expectedEndTime}, failure_reason = #{opportunity.failureReason} + where id = #{opportunity.id} + + + UPDATE opportunity + + + owner = #{request.fieldValue}, + + + name = #{request.fieldValue}, + + + customer_id = #{request.fieldValue}, + + + amount = #{request.fieldValue}, + + + possible = #{request.fieldValue}, + + + products = #{request.fieldValue}, + + + contact_id = #{request.fieldValue}, + + + expected_end_time = #{request.fieldValue}, + + + actual_end_time = #{request.fieldValue}, + + update_time = #{request.updateTime}, + update_user = #{request.updateUser} + + WHERE id IN + + #{id} + + + + + + update opportunity set pos = pos - 1 where pos > #{start} and pos <= #{end} and stage = #{stage} + + + + update opportunity set pos = pos + 1 where pos >= #{start} and pos < #{end} and stage = #{stage} + + + + + + update opportunity set pos = pos + #{pos} where pos >= #{end} and stage = #{stage} + + + + update opportunity set pos = pos + #{pos} where pos > #{end} and stage = #{stage} + + + + + + update opportunity set customer_id = #{request.toMergeId}, update_user = #{userId}, update_time = unix_timestamp() * 1000 + where customer_id in + + #{id} + + and organization_id = #{orgId} + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityQuotationMapper.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityQuotationMapper.java new file mode 100644 index 0000000..6e3fc2e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityQuotationMapper.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.opportunity.mapper; + + +import cn.cordys.common.dto.BatchUpdateDbParam; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.crm.opportunity.domain.OpportunityQuotation; +import cn.cordys.crm.opportunity.dto.request.OpportunityQuotationPageRequest; +import cn.cordys.crm.opportunity.dto.response.OpportunityQuotationListResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + + +public interface ExtOpportunityQuotationMapper { + + List list(@Param("request") OpportunityQuotationPageRequest request, @Param("orgId") String orgId, + @Param("userId") String userId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission, @Param("source") boolean source); + + void updateApprovalStatus(@Param("approvingId") String approvingId, @Param("approvalStatus") String approvalStatus, @Param("userId") String userId, @Param("updateTime") long updateTime); + + void voided(@Param("approvingId") String approvingId, @Param("userId") String userId, @Param("updateTime") long updateTime); + + void batchUpdate(@Param("request") BatchUpdateDbParam request); + + //根据时间戳获取报价单列表 + List getQuotationByTimestamp(@Param("timestamp") long timestamp, @Param("timestampOld") long timestampOld, @Param("orgId") String orgId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityQuotationMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityQuotationMapper.xml new file mode 100644 index 0000000..8599947 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityQuotationMapper.xml @@ -0,0 +1,414 @@ + + + + + + + update opportunity_quotation + set approval_status = #{approvalStatus}, + update_user = #{userId}, + update_time = #{updateTime} + where id = #{approvingId} + + + + + update opportunity_quotation + set invalid = true, + update_user = #{userId}, + update_time = #{updateTime} + where id = #{approvingId} + + + + update opportunity_quotation + + + name = #{request.fieldValue}, + + + opportunity_id = #{request.fieldValue}, + + + until_time = #{request.fieldValue}, + + + amount = #{request.fieldValue}, + + update_time = #{request.updateTime}, + update_user = #{request.updateUser} + + where id in + + #{id} + + + + + + + + + + + + + + + + + + + + + + + + + left join opportunity_quotation_field_blob ${tablePrefix}_${i} + on oq.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + left join opportunity_quotation_field ${tablePrefix}_${i} + on oq.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + + left join sys_organization_user ${tablePrefix}_${i} + on ${tablePrefix}_${i}.user_id = oq.create_user + + + + + + + + + + + left join opportunity_quotation_field sort_table + on oq.id = sort_table.resource_id and sort_table.field_id = #{sortName} + left join sys_module_field module_table on sort_table.field_id = module_table.id + + + left join sys_organization_user souc_sort on souc_sort.user_id = oq.create_user + + + + + + + + order by + + + oq.`name` ${sortType}, oq.id ASC + + + FIELD(oq.approval_status, 'NONE', 'PENDING', 'APPROVING', 'APPROVED', 'REVOKED', 'UNAPPROVED') ${sortType}, oq.id ASC + + + oq.`invalid` ${sortType}, oq.id ASC + + + oq.`amount` ${sortType}, oq.id ASC + + + o.`id` ${sortType}, oq.id ASC + + + oq.`until_time` ${sortType}, oq.id ASC + + + souc_sort.`id` ${sortType}, oq.id ASC + + + oq.`create_time` ${sortType}, oq.id ASC + + + oq.`update_time` ${sortType}, oq.id ASC + + + oq.`create_user` ${sortType}, oq.id ASC + + + oq.`update_user` ${sortType}, oq.id ASC + + + CASE + WHEN module_table.type = 'INPUT_NUMBER' OR module_table.type = 'FORMULA' + THEN sort_table.field_value+0 + END ${sortType}, + sort_table.field_value ${sortType}, + oq.id ${sortType} + + + + + order by oq.create_time desc + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + oq.invalid in + + #{v} + + + + !( oq.invalid + in + + #{v} + + ) + + + oq.invalid is null + + + oq.invalid is not null + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityQuotationSnapshotMapper.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityQuotationSnapshotMapper.java new file mode 100644 index 0000000..251c8db --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityQuotationSnapshotMapper.java @@ -0,0 +1,9 @@ +package cn.cordys.crm.opportunity.mapper; + +import cn.cordys.crm.opportunity.domain.OpportunityQuotationSnapshot; +import org.apache.ibatis.annotations.Param; + +public interface ExtOpportunityQuotationSnapshotMapper { + + void update(@Param("snapshot") OpportunityQuotationSnapshot snapshot); +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityQuotationSnapshotMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityQuotationSnapshotMapper.xml new file mode 100644 index 0000000..3a20fb6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityQuotationSnapshotMapper.xml @@ -0,0 +1,15 @@ + + + + + + + update opportunity_quotation_snapshot + set quotation_id = #{snapshot.quotationId}, + quotation_prop = #{snapshot.quotationProp}, + quotation_value = #{snapshot.quotationValue} + where id = #{snapshot.id} + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityRuleMapper.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityRuleMapper.java new file mode 100644 index 0000000..16cb1e2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityRuleMapper.java @@ -0,0 +1,30 @@ +package cn.cordys.crm.opportunity.mapper; + +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.crm.opportunity.domain.OpportunityRule; +import cn.cordys.crm.opportunity.dto.OpportunityRuleDTO; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtOpportunityRuleMapper { + + /** + * 分页获取商机规则 + * + * @param request 请求参数 + * @param orgId 组织ID + * + * @return 商机规则集合 + */ + List list(@Param("request") BasePageRequest request, @Param("orgId") String orgId); + + /** + * 获取范围下商机关闭规则 + * + * @param orgId 组织ID + * + * @return 商机关闭规则 + */ + List getAllRule(@Param("orgId") String orgId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityRuleMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityRuleMapper.xml new file mode 100644 index 0000000..6bc0a64 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityRuleMapper.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + order by + + + `name` ${sortType} + + + `enable` ${sortType} + + + `scope_id` ${sortType} + + + + + order by create_time desc + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityStageConfigMapper.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityStageConfigMapper.java new file mode 100644 index 0000000..0b3a7d2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityStageConfigMapper.java @@ -0,0 +1,36 @@ +package cn.cordys.crm.opportunity.mapper; + +import cn.cordys.common.dto.stage.StageRollBackRequest; +import cn.cordys.crm.opportunity.domain.OpportunityStageConfig; +import cn.cordys.crm.opportunity.dto.request.StageUpdateRequest; +import cn.cordys.crm.opportunity.dto.response.StageConfigResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtOpportunityStageConfigMapper { + + + int countStageConfig(@Param("orgId") String orgId); + + + void moveUpStageConfig(@Param("start") Long start, @Param("orgId") String orgId, @Param("pos") Long pos); + + void moveDownStageConfig(@Param("start") Long start, @Param("orgId") String orgId, @Param("pos") Long pos); + + List getStageConfigList(@Param("orgId") String orgId); + + void updateRollBack(@Param("request") StageRollBackRequest request, @Param("orgId") String orgId); + + void updateStageConfig(@Param("request") StageUpdateRequest request, @Param("userId") String userId); + + List getAllStageConfigList(); + + void moveUp(@Param("start") Long start, @Param("end") Long end, @Param("orgId") String orgId, @Param("defaultPos") Long defaultPos); + + void moveDown(@Param("start") Long start, @Param("end") Long end, @Param("orgId") String orgId, @Param("defaultPos") Long defaultPos); + + void updatePos(@Param("id") String id, @Param("pos") Long pos); + + int countByType(@Param("type") String type,@Param("orgId") String orgId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityStageConfigMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityStageConfigMapper.xml new file mode 100644 index 0000000..a2ab3ce --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/mapper/ExtOpportunityStageConfigMapper.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + update opportunity_stage_config + set pos = pos + #{pos} + where pos >= #{start} + and organization_id = #{orgId} + + + + update opportunity_stage_config + set pos = pos + #{pos} + where pos > #{start} + and organization_id = #{orgId} + + + + UPDATE opportunity_stage_config + + + afoot_roll_back =#{request.afootRollBack}, + + + end_roll_back = #{request.endRollBack}, + + + WHERE organization_id = #{orgId} + + + + + update opportunity_stage_config + set name = #{request.name}, + rate = #{request.rate}, + update_user = #{userId}, + update_time = unix_timestamp() * 1000 + where id = #{request.id} + + + + + + + update opportunity_stage_config set pos = pos - 1 where pos > #{start} and pos <= #{end} + + + + update opportunity_stage_config set pos = pos + 1 where pos >= #{start} and pos < #{end} + + + + + update opportunity_stage_config set pos = #{pos} where id = #{id} + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityExportService.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityExportService.java new file mode 100644 index 0000000..2655d39 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityExportService.java @@ -0,0 +1,316 @@ +package cn.cordys.crm.opportunity.service; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ExportHeadDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.service.BaseExportService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.SubListUtils; +import cn.cordys.common.utils.OpportunityFieldUtils; +import cn.cordys.crm.opportunity.dto.request.OpportunityExportRequest; +import cn.cordys.crm.opportunity.dto.response.OpportunityListResponse; +import cn.cordys.crm.opportunity.dto.response.StageConfigResponse; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityMapper; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityStageConfigMapper; +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.domain.ExportTask; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.service.ExportTaskService; +import cn.cordys.registry.ExportThreadRegistry; +import cn.idev.excel.EasyExcel; +import cn.idev.excel.ExcelWriter; +import cn.idev.excel.support.ExcelTypeEnum; +import cn.idev.excel.write.metadata.WriteSheet; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.File; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class OpportunityExportService extends BaseExportService { + + @Resource + private OpportunityService opportunityService; + @Resource + private ExportTaskService exportTaskService; + @Resource + private ExtOpportunityMapper extOpportunityMapper; + @Resource + private ExtOpportunityStageConfigMapper extOpportunityStageConfigMapper; + + + /** + * 商机导出(条件导出) + * + * @param userId 用户ID + * @param request 导出请求参数 + * @param orgId 组织ID + * @param deptDataPermission 部门数据权限 + * @param locale 语言环境 + * + * @return 导出任务ID + */ + public String export( + String userId, OpportunityExportRequest request, + String orgId, DeptDataPermissionDTO deptDataPermission, Locale locale) { + + checkFileName(request.getFileName()); + Objects.requireNonNull(userId, "userId 不能为空"); + exportTaskService.checkUserTaskLimit(userId, ExportConstants.ExportType.OPPORTUNITY.name()); + + String fileId = IDGenerator.nextStr(); + ExportTask exportTask = exportTaskService.saveTask( + orgId, fileId, userId, + ExportConstants.ExportType.OPPORTUNITY.toString(), + request.getFileName() + ); + + runExport( + orgId, userId, LogModule.OPPORTUNITY_INDEX, locale, + exportTask, request.getFileName(), + () -> exportData(fileId, exportTask, userId, request, orgId, deptDataPermission) + ); + + return exportTask.getId(); + } + + /** + * 执行商机条件导出数据写入 + * + * @param fileId 文件ID + * @param exportTask 导出任务 + * @param userId 用户ID + * @param request 导出请求 + * @param orgId 组织ID + * @param deptDataPermission 部门数据权限 + * + * @throws InterruptedException 线程中断异常 + */ + private void exportData( + String fileId, ExportTask exportTask, String userId, + OpportunityExportRequest request, String orgId, + DeptDataPermissionDTO deptDataPermission) throws InterruptedException { + + List> headList = request.getHeadList().stream() + .map(h -> Collections.singletonList(h.getTitle())) + .toList(); + + batchHandleData( + fileId, headList, exportTask, request.getFileName(), request, + t -> getExportData( + request.getHeadList(), request, userId, + orgId, deptDataPermission, exportTask.getId() + ) + ); + } + + /** + * 构建商机条件导出数据 + * + * @param headList 表头列表 + * @param request 导出请求 + * @param userId 用户ID + * @param orgId 组织ID + * @param deptDataPermission 部门数据权限 + * @param taskId 任务ID + * + * @return 导出数据列表 + * + * @throws InterruptedException 线程中断异常 + */ + private List> getExportData( + List headList, OpportunityExportRequest request, + String userId, String orgId, + DeptDataPermissionDTO deptDataPermission, String taskId) throws InterruptedException { + + PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List rawList = + extOpportunityMapper.list(request, orgId, userId, deptDataPermission, false); + + return buildOpportunityExportData(headList, rawList, orgId, taskId); + } + + /** + * 商机导出(选中数据) + * + * @param userId 用户ID + * @param request 选中导出请求 + * @param orgId 组织ID + * @param locale 语言环境 + * + * @return 导出任务ID + */ + public String exportSelect(String userId, ExportSelectRequest request, String orgId, Locale locale) { + checkFileName(request.getFileName()); + Objects.requireNonNull(userId, "userId 不能为空"); + exportTaskService.checkUserTaskLimit(userId, ExportConstants.ExportStatus.PREPARED.toString()); + + String fileId = IDGenerator.nextStr(); + ExportTask exportTask = exportTaskService.saveTask( + orgId, fileId, userId, + ExportConstants.ExportType.OPPORTUNITY.toString(), + request.getFileName() + ); + + runExport( + orgId, userId, LogModule.OPPORTUNITY_INDEX, locale, + exportTask, request.getFileName(), + () -> exportData(fileId, exportTask, userId, request, orgId) + ); + + return exportTask.getId(); + } + + /** + * 执行商机选中数据导出 + * + * @param fileId 文件ID + * @param exportTask 导出任务 + * @param userId 用户ID + * @param request 选中导出请求 + * @param orgId 组织ID + */ + private void exportData( + String fileId, ExportTask exportTask, + String userId, ExportSelectRequest request, String orgId) { + + List> headList = request.getHeadList().stream() + .map(h -> Collections.singletonList(h.getTitle())) + .toList(); + + File file = prepareExportFile(fileId, request.getFileName(), exportTask.getOrganizationId()); + try (ExcelWriter writer = EasyExcel.write(file) + .head(headList) + .excelType(ExcelTypeEnum.XLSX) + .build()) { + + WriteSheet sheet = EasyExcel.writerSheet("导出数据").build(); + SubListUtils.dealForSubList( + request.getIds(), SubListUtils.DEFAULT_EXPORT_BATCH_SIZE, + subIds -> { + try { + writer.write( + getExportDataBySelect( + request.getHeadList(), subIds, orgId, exportTask.getId() + ), + sheet + ); + } catch (InterruptedException e) { + log.error("任务停止中断", e); + exportTaskService.update( + exportTask.getId(), + ExportConstants.ExportStatus.STOP.toString(), + userId + ); + } + } + ); + } + } + + /** + * 构建选中商机导出数据 + * + * @param headList 表头列表 + * @param ids 商机ID列表 + * @param orgId 组织ID + * @param taskId 任务ID + * + * @return 导出数据列表 + * + * @throws InterruptedException 线程中断异常 + */ + private List> getExportDataBySelect( + List headList, List ids, + String orgId, String taskId) throws InterruptedException { + + List rawList = extOpportunityMapper.getListByIds(ids); + return buildOpportunityExportData(headList, rawList, orgId, taskId); + } + + /** + * 构建商机导出核心数据 + * + * @param headList 表头列表 + * @param rawList 原始商机数据 + * @param orgId 组织ID + * @param taskId 任务ID + * + * @return 导出数据列表 + * + * @throws InterruptedException 线程中断异常 + */ + private List> buildOpportunityExportData( + List headList, + List rawList, + String orgId, + String taskId) throws InterruptedException { + + List dataList = opportunityService.buildListData(rawList, orgId); + Map> optionMap = + opportunityService.buildOptionMap(orgId, rawList, dataList); + Map fieldConfigMap = + getFieldConfigMap(FormKey.OPPORTUNITY.getKey(), orgId); + Map stageConfigMap = + extOpportunityStageConfigMapper.getStageConfigList(orgId) + .stream() + .collect(Collectors.toMap( + StageConfigResponse::getId, + StageConfigResponse::getName + )); + + List> result = new ArrayList<>(dataList.size()); + for (OpportunityListResponse response : dataList) { + if (ExportThreadRegistry.isInterrupted(taskId)) { + throw new InterruptedException("线程已被中断,主动退出"); + } + result.add(buildData(headList, response, optionMap, fieldConfigMap, stageConfigMap)); + } + return result; + } + + /** + * 构建单行商机导出数据 + * + * @param headList 表头列表 + * @param data 商机数据 + * @param optionMap 选项映射 + * @param fieldConfigMap 字段配置 + * @param stageConfigMap 阶段配置 + * + * @return 单行导出数据 + */ + private List buildData( + List headList, + OpportunityListResponse data, + Map> optionMap, + Map fieldConfigMap, + Map stageConfigMap) { + + List dataList = new ArrayList<>(); + LinkedHashMap systemFieldMap = + OpportunityFieldUtils.getSystemFieldMap(data, optionMap, stageConfigMap,fieldConfigMap); + + Map moduleFieldMap = Optional.ofNullable(data.getModuleFields()) + .map(fields -> fields.stream().collect(Collectors.toMap( + BaseModuleFieldValue::getFieldId, + BaseModuleFieldValue::getFieldValue + ))) + .orElseGet(LinkedHashMap::new); + + transModuleFieldValue(headList, systemFieldMap, moduleFieldMap, dataList, fieldConfigMap); + return dataList; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityFieldService.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityFieldService.java new file mode 100644 index 0000000..fc0798c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityFieldService.java @@ -0,0 +1,35 @@ +package cn.cordys.crm.opportunity.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.crm.opportunity.domain.OpportunityField; +import cn.cordys.crm.opportunity.domain.OpportunityFieldBlob; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(rollbackFor = Exception.class) +public class OpportunityFieldService extends BaseResourceFieldService { + + @Resource + private BaseMapper opportunityFieldMapper; + @Resource + private BaseMapper opportunityFieldBlobMapper; + + @Override + protected String getFormKey() { + return FormKey.OPPORTUNITY.getKey(); + } + + @Override + protected BaseMapper getResourceFieldMapper() { + return opportunityFieldMapper; + } + + @Override + protected BaseMapper getResourceFieldBlobMapper() { + return opportunityFieldBlobMapper; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityLogService.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityLogService.java new file mode 100644 index 0000000..0ee0350 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityLogService.java @@ -0,0 +1,93 @@ +package cn.cordys.crm.opportunity.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.system.service.BaseModuleLogService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.text.SimpleDateFormat; +import java.util.List; + +@Service +@Transactional(rollbackFor = Exception.class) +public class OpportunityLogService extends BaseModuleLogService { + + @Resource + private BaseMapper customerMapper; + + @Override + public List handleLogField(List differences, String orgId) { + differences = super.handleModuleLogField(differences, orgId, FormKey.OPPORTUNITY.getKey()); + + for (JsonDifferenceDTO differ : differences) { + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.OPPORTUNITY_OWNER.getBusinessKey())) { + setUserFieldName(differ); + continue; + } + + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.OPPORTUNITY_PRODUCTS.getBusinessKey())) { + setProductName(differ); + continue; + } + + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.OPPORTUNITY_CONTACT.getBusinessKey())) { + setContactFieldName(differ); + continue; + } + + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.OPPORTUNITY_CUSTOMER_NAME.getBusinessKey())) { + if (differ.getOldValue() != null) { + Customer customer = customerMapper.selectByPrimaryKey(differ.getOldValue().toString()); + if (customer != null) { + differ.setOldValueName(customer.getName()); + } + } + if (differ.getNewValue() != null) { + Customer customer = customerMapper.selectByPrimaryKey(differ.getNewValue().toString()); + if (customer != null) { + differ.setNewValueName(customer.getName()); + } + } + continue; + } + + if (Strings.CS.equals(differ.getColumnName(), Translator.get("log.stage"))) { + if (differ.getOldValue() != null) { + differ.setOldValueName(Translator.get(differ.getOldValueName().toString(), differ.getOldValueName().toString())); + } + if (differ.getNewValue() != null) { + differ.setNewValueName(Translator.get(differ.getNewValueName().toString(), differ.getNewValueName().toString())); + } + } + + if (Strings.CS.equals(differ.getColumn(), "status")) { + differ.setColumnName(Translator.get("log.opportunity." + differ.getColumn())); + if (differ.getOldValue() != null) { + differ.setOldValueName(Boolean.parseBoolean(differ.getOldValueName().toString()) ? Translator.get("log.opportunity.status.true") : Translator.get("log.opportunity.status.false")); + } + if (differ.getNewValue() != null) { + differ.setNewValueName(Boolean.parseBoolean(differ.getNewValueName().toString()) ? Translator.get("log.opportunity.status.true") : Translator.get("log.opportunity.status.false")); + } + } + + if (Strings.CS.equals(differ.getColumn(), "failureReason")) { + if (differ.getOldValue() != null) { + differ.setOldValueName(Translator.get(differ.getOldValue().toString())); + } + if (differ.getNewValue() != null) { + differ.setNewValueName(Translator.get(differ.getNewValue().toString())); + } + } + } + return differences; + } + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityQuotationFieldService.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityQuotationFieldService.java new file mode 100644 index 0000000..b6f6623 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityQuotationFieldService.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.opportunity.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.crm.opportunity.domain.OpportunityQuotationField; +import cn.cordys.crm.opportunity.domain.OpportunityQuotationFieldBlob; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author guoyuqi + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class OpportunityQuotationFieldService extends BaseResourceFieldService { + + @Resource + private BaseMapper opportunityQuotationFieldMapper; + @Resource + private BaseMapper opportunityQuotationFieldBlobMapper; + + @Override + protected String getFormKey() { + return FormKey.QUOTATION.getKey(); + } + + @Override + protected BaseMapper getResourceFieldMapper() { + return opportunityQuotationFieldMapper; + } + + @Override + protected BaseMapper getResourceFieldBlobMapper() { + return opportunityQuotationFieldBlobMapper; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityQuotationResourceAccessContextProvider.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityQuotationResourceAccessContextProvider.java new file mode 100644 index 0000000..e0b95dd --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityQuotationResourceAccessContextProvider.java @@ -0,0 +1,41 @@ +package cn.cordys.crm.opportunity.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.permission.ResourceAccessContext; +import cn.cordys.common.permission.ResourceAccessContextProvider; +import cn.cordys.crm.opportunity.domain.OpportunityQuotation; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +@Component +public class OpportunityQuotationResourceAccessContextProvider implements ResourceAccessContextProvider { + + @Resource + private BaseMapper quotationMapper; + + @Override + public String getFormType() { + return FormKey.OPPORTUNITY.getKey(); + } + + @Override + public ResourceAccessContext getAccessContext(String resourceId, String orgId) { + OpportunityQuotation quotation = quotationMapper.selectByPrimaryKey(resourceId); + if (quotation == null) { + return null; + } + ResourceAccessContext context = new ResourceAccessContext(); + context.setApprovalStatus(quotation.getApprovalStatus()); + return context; + } + + @Override + public Map batchGetOwnerIds(List resourceIds, String orgId) { + // 不校验数据权限 + return Map.of(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityQuotationService.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityQuotationService.java new file mode 100644 index 0000000..20d2de0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityQuotationService.java @@ -0,0 +1,1311 @@ +package cn.cordys.crm.opportunity.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.CommonResultCode; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.*; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.permission.PermissionUtils; +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.common.resolver.field.ModuleFieldResolverFactory; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.approval.annotation.HitApproval; +import cn.cordys.crm.approval.constants.ApprovalFormTypeEnum; +import cn.cordys.crm.approval.constants.ApprovalState; +import cn.cordys.crm.approval.constants.ApprovalStatus; +import cn.cordys.crm.approval.constants.ExecuteTimingEnum; +import cn.cordys.crm.approval.dto.ResourceApprovalFieldUpdateParam; +import cn.cordys.crm.approval.dto.ResourceApprovalPostUpdateParam; +import cn.cordys.crm.approval.dto.ResourceSnapshotApprovalParam; +import cn.cordys.crm.approval.handler.ApprovalResourceHandler; +import cn.cordys.crm.approval.service.ApprovalFlowService; +import cn.cordys.crm.approval.service.ApprovalResourceService; +import cn.cordys.crm.contract.domain.ContractField; +import cn.cordys.crm.contract.domain.ContractFieldBlob; +import cn.cordys.crm.opportunity.domain.*; +import cn.cordys.crm.opportunity.dto.request.*; +import cn.cordys.crm.opportunity.dto.response.OpportunityQuotationGetResponse; +import cn.cordys.crm.opportunity.dto.response.OpportunityQuotationListResponse; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityQuotationMapper; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityQuotationSnapshotMapper; +import cn.cordys.crm.system.constants.DictModule; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.domain.Attachment; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.BatchAffectReasonResponse; +import cn.cordys.crm.system.dto.response.BatchAffectSkipResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.notice.CommonNoticeSendService; +import cn.cordys.crm.system.service.DictService; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.ibatis.session.ExecutorType; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.mybatis.spring.SqlSessionUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class OpportunityQuotationService implements ApprovalResourceHandler { + + @Resource + private OpportunityQuotationFieldService opportunityQuotationFieldService; + @Resource + private BaseService baseService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private CommonNoticeSendService commonNoticeSendService; + @Resource + private LogService logService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private PermissionCache permissionCache; + @Resource + private ExtOpportunityQuotationMapper extOpportunityQuotationMapper; + @Resource + private SqlSessionFactory sqlSessionFactory; + @Resource + private BaseMapper opportunityQuotationMapper; + @Resource + private BaseMapper snapshotBaseMapper; + @Resource + private BaseMapper contractFieldMapper; + @Resource + private BaseMapper contractFieldBlobMapper; + @Resource + private BaseMapper opportunityBaseMapper; + @Resource + private DictService dictService; + @Resource + private ApprovalFlowService approvalFlowService; + + private static final ObjectMapper mapper = new ObjectMapper(); + + private static final BigDecimal MAX_AMOUNT = new BigDecimal("9999999999"); + + + /** + * 新增商机报价单 + * 新增报价单会自动将报价单状态设置为“提审”,此时需要保存报价单值快照,报价单表单设置快照 + * + * @param request 新增请求参数 + * @return 商机报价单实体 + */ + @OperationLog(module = LogModule.OPPORTUNITY_QUOTATION, type = LogType.ADD, resourceName = "{#request.name}", operator = "{#userId}") + @HitApproval(formKey = FormKey.QUOTATION, executeType = ExecuteTimingEnum.CREATE, operatorId = "{#userId}") + public OpportunityQuotation add(OpportunityQuotationAddRequest request, String orgId, String userId) { + List moduleFields = request.getModuleFields(); + ModuleFormConfigDTO moduleFormConfigDTO = request.getModuleFormConfigDTO(); + checkQuotationInfo(moduleFields, moduleFormConfigDTO); + + ModuleFormConfigDTO saveModuleFormConfigDTO = JSON.parseObject(JSON.toJSONString(moduleFormConfigDTO), ModuleFormConfigDTO.class); + OpportunityQuotation opportunityQuotation = new OpportunityQuotation(); + opportunityQuotation.setId(IDGenerator.nextStr()); + opportunityQuotation.setOrganizationId(orgId); + opportunityQuotation.setName(request.getName()); + opportunityQuotation.setInvalid(false); + opportunityQuotation.setApprovalStatus(ApprovalStatus.NONE.name()); + opportunityQuotation.setOpportunityId(request.getOpportunityId()); + opportunityQuotation.setUntilTime(request.getUntilTime()); + opportunityQuotation.setCreateUser(userId); + opportunityQuotation.setUpdateUser(userId); + opportunityQuotation.setCreateTime(System.currentTimeMillis()); + opportunityQuotation.setUpdateTime(System.currentTimeMillis()); + + //判断总金额 + setAmount(request.getAmount(), opportunityQuotation); + // 设置子表格字段值 + moduleFields.add(new BaseModuleFieldValue("products", request.getProducts())); + + opportunityQuotationFieldService.saveModuleField(opportunityQuotation, orgId, userId, moduleFields, false); + opportunityQuotationMapper.insert(opportunityQuotation); + + // 保存表单配置快照 + List resolveFieldValues = moduleFormService.resolveSnapshotFields(moduleFields, moduleFormConfigDTO, opportunityQuotationFieldService, opportunityQuotation.getId()); + OpportunityQuotationGetResponse response = getOpportunityQuotationGetResponse(opportunityQuotation, resolveFieldValues, moduleFormConfigDTO); + + baseService.handleAddLogWithSubTable(opportunityQuotation, moduleFields, Translator.get("products_info"), moduleFormConfigDTO); + + saveSnapshot(opportunityQuotation, saveModuleFormConfigDTO, response); + + return opportunityQuotation; + + } + + + /** + * 保存商机报价单快照 + * + * @param opportunityQuotation 报价单实体 + * @param moduleFormConfigDTO 报价单表单配置 + * @param response 报价单详情响应类 + */ + private void saveSnapshot(OpportunityQuotation opportunityQuotation, ModuleFormConfigDTO moduleFormConfigDTO, OpportunityQuotationGetResponse response) { + OpportunityQuotationSnapshot snapshot = new OpportunityQuotationSnapshot(); + response.setModuleFields(response.getModuleFields().stream() + .filter(field -> (field.getFieldValue() != null && StringUtils.isNotBlank(field.getFieldValue().toString()) && !"[]".equals(field.getFieldValue().toString()))).toList()); + snapshot.setId(IDGenerator.nextStr()); + snapshot.setQuotationId(opportunityQuotation.getId()); + snapshot.setQuotationProp(JSON.toJSONString(moduleFormConfigDTO)); + snapshot.setQuotationValue(JSON.toJSONString(response)); + snapshotBaseMapper.insert(snapshot); + } + + /** + * 新增商机报价单详情 + * + * @param opportunityQuotation 报价单实体 + * @param moduleFields 报价单字段值 + * @param moduleFormConfigDTO 报价单表单配置 + * @return 报价单详情 + */ + private OpportunityQuotationGetResponse getOpportunityQuotationGetResponse(OpportunityQuotation opportunityQuotation, List moduleFields, ModuleFormConfigDTO moduleFormConfigDTO) { + OpportunityQuotationGetResponse response = BeanUtils.copyBean(new OpportunityQuotationGetResponse(), opportunityQuotation); + List fvs = opportunityQuotationFieldService.setBusinessRefFieldValue(List.of(response), moduleFormService.getFlattenFormFields(FormKey.QUOTATION.getKey(), opportunityQuotation.getOrganizationId()), + new HashMap<>(Map.of(response.getId(), moduleFields))).get(response.getId()); + response.setModuleFields(fvs); + Opportunity opportunity = opportunityBaseMapper.selectByPrimaryKey(response.getOpportunityId()); + Map> optionMap = moduleFormService.getOptionMap(moduleFormConfigDTO, fvs); + if (opportunity != null) { + optionMap.put("opportunityId", List.of(new OptionDTO(opportunity.getId(), opportunity.getName()))); + response.setOpportunityName(opportunity.getName()); + } + response.setOptionMap(optionMap); + Map> attachmentMap = moduleFormService.getAttachmentMap(moduleFormConfigDTO, moduleFields); + response.setAttachmentMap(attachmentMap); + moduleFormService.processBusinessFieldValues(response, moduleFields, moduleFormConfigDTO); + return baseService.setCreateAndUpdateUserName(response); + } + + /** + * 查询商机报价单快照详情 + * + * @param id 报价单ID + * @return 报价单详情 + */ + public OpportunityQuotationGetResponse getSnapshot(String id, String orgId) { + OpportunityQuotationGetResponse response = new OpportunityQuotationGetResponse(); + OpportunityQuotation opportunityQuotation = opportunityQuotationMapper.selectByPrimaryKey(id); + if (opportunityQuotation == null) { + throw new GenericException(Translator.get("opportunity.quotation.not.exist")); + } + // 已审核,查询最新快照 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(OpportunityQuotationSnapshot::getQuotationId, id); + OpportunityQuotationSnapshot snapshot = snapshotBaseMapper.selectListByLambda(wrapper).stream().findFirst().orElse(null); + if (snapshot != null) { + response = JSON.parseObject(snapshot.getQuotationValue(), OpportunityQuotationGetResponse.class); + } + if (Strings.CI.equals(response.getApprovalStatus(), ApprovalStatus.APPROVING.name())) { + Map firstNodeApproved = baseService.getApprovingResourceFirstNodeApproved(List.of(response.getId()), orgId); + response.setFirstApproved(firstNodeApproved.get(response.getId())); + } + response.setApproved(opportunityQuotation.getApproved()); + return response; + } + + /** + * @param id 报价单ID + * @return 报价单详情 + */ + public OpportunityQuotationGetResponse get(String id, String orgId) { + OpportunityQuotationGetResponse response = new OpportunityQuotationGetResponse(); + OpportunityQuotation opportunityQuotation = opportunityQuotationMapper.selectByPrimaryKey(id); + if (opportunityQuotation == null) { + throw new GenericException(Translator.get("opportunity.quotation.not.exist")); + } + // 已审核,查询最新快照 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(OpportunityQuotationSnapshot::getQuotationId, id); + OpportunityQuotationSnapshot snapshot = snapshotBaseMapper.selectListByLambda(wrapper).stream().findFirst().orElse(null); + if (snapshot != null) { + response = JSON.parseObject(snapshot.getQuotationValue(), OpportunityQuotationGetResponse.class); + } + response.setApprovalStatus(opportunityQuotation.getApprovalStatus()); + ModuleFormConfigDTO moduleFormConfigDTO = moduleFormCacheService.getBusinessFormConfig(FormKey.QUOTATION.getKey(), opportunityQuotation.getOrganizationId()); + List moduleFieldValues = opportunityQuotationFieldService.getModuleFieldValuesByResourceId(id); + List resolveFieldValues = moduleFormService.resolveSnapshotFields(moduleFieldValues, moduleFormConfigDTO, opportunityQuotationFieldService, opportunityQuotation.getId()); + List fvs = opportunityQuotationFieldService.setBusinessRefFieldValue(List.of(response), moduleFormService.getFlattenFormFields(FormKey.QUOTATION.getKey(), opportunityQuotation.getOrganizationId()), + new HashMap<>(Map.of(response.getId(), resolveFieldValues))).get(response.getId()); + response.setModuleFields(fvs); + Map> optionMap = moduleFormService.getOptionMap(moduleFormConfigDTO, fvs); + Opportunity opportunity = opportunityBaseMapper.selectByPrimaryKey(response.getOpportunityId()); + if (opportunity != null) { + optionMap.put("opportunityId", List.of(new OptionDTO(opportunity.getId(), opportunity.getName()))); + response.setOpportunityName(opportunity.getName()); + } + response.setOptionMap(optionMap); + Map> attachmentMap = moduleFormService.getAttachmentMap(moduleFormConfigDTO, response.getModuleFields()); + response.setAttachmentMap(attachmentMap); + baseService.setCreateAndUpdateUserName(response); + UserDeptDTO userDeptDTO = baseService.getUserDeptMapByUserId(opportunityQuotation.getCreateUser(), opportunityQuotation.getOrganizationId()); + if (userDeptDTO != null) { + response.setDepartmentId(userDeptDTO.getDeptId()); + response.setDepartmentName(userDeptDTO.getDeptName()); + } + if (Strings.CI.equals(response.getApprovalStatus(), ApprovalStatus.APPROVING.name())) { + Map firstNodeApproved = baseService.getApprovingResourceFirstNodeApproved(List.of(response.getId()), orgId); + response.setFirstApproved(firstNodeApproved.get(response.getId())); + } + return response; + } + + /** + * 获取报价单详情 (⚠️反射调用; 勿修改入参, 返回, 方法名!) + * @param id 报价单ID + * @return 报价单详情 + */ + public OpportunityQuotationGetResponse getSimple(String id) { + OpportunityQuotation opportunityQuotation = opportunityQuotationMapper.selectByPrimaryKey(id); + if (opportunityQuotation == null) { + return null; + } + OpportunityQuotationGetResponse response = BeanUtils.copyBean(new OpportunityQuotationGetResponse(), opportunityQuotation); + ModuleFormConfigDTO quotationFormConf = moduleFormCacheService.getBusinessFormConfig(FormKey.QUOTATION.getKey(), opportunityQuotation.getOrganizationId()); + List fvs = opportunityQuotationFieldService.getModuleFieldValuesByResourceId(id); + moduleFormService.processBusinessFieldValues(response, fvs, quotationFormConf); + return response; + } + + + /** + * 获取字段详情 (⚠️反射调用; 勿修改入参, 返回, 方法名!) + * @param id 报价单ID + * @return 报价单详情 + */ + public OpportunityQuotationGetResponse getFieldValues(String id) { + OpportunityQuotationGetResponse response = new OpportunityQuotationGetResponse(); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(OpportunityQuotationSnapshot::getQuotationId, id); + OpportunityQuotationSnapshot snapshot = snapshotBaseMapper.selectListByLambda(wrapper).stream().findFirst().orElse(null); + if (snapshot != null) { + response = JSON.parseObject(snapshot.getQuotationValue(), OpportunityQuotationGetResponse.class); + } + return response; + } + + + + /** + * 批量获取报价单详情 (用于数据源批量查询优化) + * @param ids 报价单ID集合 + * @return 报价单详情列表 + */ + public List batchGetSimpleByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return Collections.emptyList(); + } + List quotations = opportunityQuotationMapper.selectByIds(ids); + if (CollectionUtils.isEmpty(quotations)) { + return Collections.emptyList(); + } + Map> fieldValueMap = opportunityQuotationFieldService.getResourceFieldMap(ids, true); + + return quotations.stream().map(quotation -> { + OpportunityQuotationGetResponse response = BeanUtils.copyBean(new OpportunityQuotationGetResponse(), quotation); + response.setModuleFields(fieldValueMap.get(quotation.getId())); + return response; + }).toList(); + } + + /** + * 撤销审批 + * + * @param id 报价单ID + * @param userId 用户ID + */ + public String revoke(String id, String userId, String orgId) { + OpportunityQuotation opportunityQuotation = opportunityQuotationMapper.selectByPrimaryKey(id); + if (opportunityQuotation == null) { + throw new GenericException(Translator.get("opportunity.quotation.not.exist")); + } + checkApprovalConfig(orgId); + + if (!Strings.CI.equals(opportunityQuotation.getCreateUser(), userId) || !Strings.CI.equals(opportunityQuotation.getApprovalStatus(), ApprovalState.APPROVING.toString())) { + return opportunityQuotation.getApprovalStatus(); + } + opportunityQuotation.setApprovalStatus(ApprovalState.REVOKED.toString()); + opportunityQuotation.setUpdateUser(userId); + opportunityQuotation.setUpdateTime(System.currentTimeMillis()); + opportunityQuotationMapper.update(opportunityQuotation); + + + + //更新快照 + updateSnapshot(id, ApprovalState.REVOKED.toString(), null); + return opportunityQuotation.getApprovalStatus(); + } + + private void checkApprovalConfig(String orgId) { + if (!dictService.isDictConfigEnable(DictModule.QUOTATION_APPROVAL.name(), orgId)) { + // 未开启审批 + throw new GenericException(CommonResultCode.APPROVAL_NOT_ENABLED_ERROR); + } + } + + /** + * 作废商机报价单 + * + * @param id 报价单ID + * @param userId 用户ID + */ + public void voidQuotation(String id, String userId, String orgId) { + OpportunityQuotation opportunityQuotation = opportunityQuotationMapper.selectByPrimaryKey(id); + if (opportunityQuotation == null) { + throw new GenericException(Translator.get("opportunity.quotation.not.exist")); + } + checkQuotationLinked(id, "opportunity.quotation.no.voided"); + opportunityQuotation.setInvalid(true); + opportunityQuotation.setUpdateUser(userId); + opportunityQuotation.setUpdateTime(System.currentTimeMillis()); + opportunityQuotationMapper.update(opportunityQuotation); + + // 修改快照中的作废状态 + voidedSnapshot(id); + + // 作废日志 + LogDTO logDTO = new LogDTO(orgId, opportunityQuotation.getId(), userId, LogType.VOIDED, LogModule.OPPORTUNITY_QUOTATION, opportunityQuotation.getName()); + logService.add(logDTO); + // 通知 + sendNotice(Translator.get("opportunity.quotation.status.voided"), opportunityQuotation, userId, orgId, NotificationConstants.Event.BUSINESS_QUOTATION_APPROVAL); + } + + /** + * 检查报价单是否被合同关联 + * + * @param id id + * @param key 提示词 + */ + private void checkQuotationLinked(String id, String key) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.like(ContractField::getFieldValue, id); + List contractFieldList = contractFieldMapper.selectListByLambda(wrapper); + LambdaQueryWrapper wrapperBlob = new LambdaQueryWrapper<>(); + wrapperBlob.like(ContractFieldBlob::getFieldValue, id); + List contractFieldBlobList = contractFieldBlobMapper.selectListByLambda(wrapperBlob); + + if (CollectionUtils.isNotEmpty(contractFieldList) || CollectionUtils.isNotEmpty(contractFieldBlobList)) { + throw new GenericException(Translator.get(key)); + } + } + + /** + * 审批商机报价单 + * + * @param request 新增请求参数 + * @param userId 用户ID + */ + public String approve(OpportunityQuotationEditRequest request, String userId, String orgId) { + checkApprovalConfig(orgId); + + //获取ApprovalState中APPROVED状态的id属性(以后改成获取自定义的审批状态) + ModuleFormConfigDTO moduleFormConfigDTO = request.getModuleFormConfigDTO(); + List approvalStatusList = Arrays.stream(ApprovalState.values()).map(ApprovalState::getId).filter(status -> ApprovalState.APPROVED.toString().equals(status)).toList(); + String noticeKey = approvalStatusList.contains(request.getApprovalStatus()) ? + Translator.get("opportunity.quotation.status.approved") : Translator.get("opportunity.quotation.status.unapproved"); + OpportunityQuotation oldOpportunityQuotation = opportunityQuotationMapper.selectByPrimaryKey(request.getId()); + if (oldOpportunityQuotation == null) { + throw new GenericException(Translator.get("opportunity.quotation.not.exist")); + } + String oldApprovalStatus = oldOpportunityQuotation.getApprovalStatus(); + OpportunityQuotation opportunityQuotation = updateApprovalState(oldOpportunityQuotation, request.getApprovalStatus(), userId); + if (opportunityQuotation == null) { + return request.getApprovalStatus(); + } + //更新快照状态 + updateSnapshot(request.getId(), request.getApprovalStatus(), moduleFormConfigDTO); + saveSateChangeLog(orgId, oldApprovalStatus, userId, LogType.APPROVAL, opportunityQuotation); + sendNotice(noticeKey, opportunityQuotation, userId, orgId, NotificationConstants.Event.BUSINESS_QUOTATION_APPROVAL); + return opportunityQuotation.getApprovalStatus(); + } + + private void updateSnapshot(String id, String approvalStatus, ModuleFormConfigDTO moduleFormConfigDTO) { + LambdaQueryWrapper delWrapper = new LambdaQueryWrapper<>(); + delWrapper.eq(OpportunityQuotationSnapshot::getQuotationId, id); + List opportunityQuotationSnapshots = snapshotBaseMapper.selectListByLambda(delWrapper); + OpportunityQuotationSnapshot first = opportunityQuotationSnapshots.getFirst(); + if (first != null) { + OpportunityQuotationGetResponse response = JSON.parseObject(first.getQuotationValue(), OpportunityQuotationGetResponse.class); + response.setApprovalStatus(approvalStatus); + if (moduleFormConfigDTO != null) { + first.setQuotationProp(JSON.toJSONString(moduleFormConfigDTO)); + } + first.setQuotationValue(JSON.toJSONString(response)); + snapshotBaseMapper.update(first); + } + } + + /** + * ⚠️反射调用: 由审批执行操作统一调用, 勿修改 + * @param param 参数 + */ + public void updateSnapshotApprovalStatus(ResourceSnapshotApprovalParam param) { + OpportunityQuotationSnapshot snapshotCriteria = new OpportunityQuotationSnapshot(); + snapshotCriteria.setQuotationId(param.getResourceId()); + OpportunityQuotationSnapshot snapshot = snapshotBaseMapper.selectOne(snapshotCriteria); + if (snapshot != null) { + OpportunityQuotationGetResponse response = JSON.parseObject(snapshot.getQuotationValue(), OpportunityQuotationGetResponse.class); + response.setApprovalStatus(param.getApprovalStatus()); + snapshot.setQuotationValue(JSON.toJSONString(response)); + snapshotBaseMapper.update(snapshot); + } + } + + /** + * ⚠️反射调用: 由审批执行后置操作统一调用, 勿修改 + * @param postFieldParam 参数 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public void updateApprovalPostField(ResourceApprovalPostUpdateParam postFieldParam) { + ModuleFormConfigDTO formConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.QUOTATION.getKey(), OrganizationContext.getOrganizationId()); + List fields = formConfig.getFields(); + Map fieldConfigMap = fields.stream().collect(Collectors.toMap(BaseField::getId, f -> f)); + OpportunityQuotation quotation = opportunityQuotationMapper.selectByPrimaryKey(postFieldParam.getResourceId()); + // 保存原始数据用于日志记录 + OpportunityQuotation originQuotation = BeanUtils.copyBean(new OpportunityQuotation(), quotation); + List originFields = opportunityQuotationFieldService.getModuleFieldValuesByResourceId(postFieldParam.getResourceId()); + List quotationFields = new ArrayList<>(); + List quotationFieldBlobs = new ArrayList<>(); + OpportunityQuotationSnapshot snapshotCriteria = new OpportunityQuotationSnapshot(); + snapshotCriteria.setQuotationId(postFieldParam.getResourceId()); + OpportunityQuotationSnapshot snapshot = snapshotBaseMapper.selectOne(snapshotCriteria); + OpportunityQuotationGetResponse response = new OpportunityQuotationGetResponse(); + if (snapshot != null) { + response = JSON.parseObject(snapshot.getQuotationValue(), OpportunityQuotationGetResponse.class); + } + for (ResourceApprovalFieldUpdateParam fieldUpdateParam : postFieldParam.getFields()) { + if (Strings.CS.equals(fieldUpdateParam.getFieldId(), "invalid") && fieldUpdateParam.getFieldValue() != null) { + opportunityQuotationFieldService.setResourceFieldValue(quotation, "invalid", fieldUpdateParam.getFieldValue()); + continue; + } + if (!fieldConfigMap.containsKey(fieldUpdateParam.getFieldId()) || fieldUpdateParam.getFieldValue() == null) { + return; + } + BaseField fieldConfig = fieldConfigMap.get(fieldUpdateParam.getFieldId()); + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(fieldConfig.getType()); + if (fieldConfig.hasBusinessKey()) { + // 业务主表字段 + opportunityQuotationFieldService.setResourceFieldValue(quotation, fieldConfig.getBusinessKey(), fieldUpdateParam.getFieldValue()); + } else { + // 快照自定义字段 + Optional findField = response.getModuleFields().stream().filter(fieldValue -> Strings.CI.equals(fieldValue.getFieldId(), fieldUpdateParam.getFieldId())).findAny(); + if (findField.isPresent()) { + findField.get().setFieldValue(fieldUpdateParam.getFieldValue()); + } else { + BaseModuleFieldValue fv = new BaseModuleFieldValue(); + fv.setFieldId(fieldUpdateParam.getFieldId()); + fv.setFieldValue(fieldUpdateParam.getFieldValue()); + response.getModuleFields().add(fv); + } + if (fieldConfig.isBlob()) { + // 自定义大表 + opportunityQuotationFieldService.getResourceFieldBlobMapper().deleteByLambda(new LambdaQueryWrapper() + .eq(OpportunityQuotationFieldBlob::getFieldId, fieldUpdateParam.getFieldId()).eq(OpportunityQuotationFieldBlob::getResourceId, postFieldParam.getResourceId())); + OpportunityQuotationFieldBlob field = new OpportunityQuotationFieldBlob(); + field.setId(IDGenerator.nextStr()); + field.setResourceId(postFieldParam.getResourceId()); + field.setFieldId(fieldUpdateParam.getFieldId()); + field.setFieldValue(customFieldResolver.convertToString(fieldConfig, fieldUpdateParam.getFieldValue())); + quotationFieldBlobs.add(field); + } else { + // 自定义表 + opportunityQuotationFieldService.getResourceFieldMapper().deleteByLambda(new LambdaQueryWrapper() + .eq(OpportunityQuotationField::getFieldId, fieldUpdateParam.getFieldId()).eq(OpportunityQuotationField::getResourceId, postFieldParam.getResourceId())); + OpportunityQuotationField field = new OpportunityQuotationField(); + field.setId(IDGenerator.nextStr()); + field.setResourceId(postFieldParam.getResourceId()); + field.setFieldId(fieldUpdateParam.getFieldId()); + field.setFieldValue(customFieldResolver.convertToString(fieldConfig, fieldUpdateParam.getFieldValue())); + quotationFields.add(field); + } + } + } + opportunityQuotationMapper.updateById(quotation); + if (CollectionUtils.isNotEmpty(quotationFields)) { + opportunityQuotationFieldService.getResourceFieldMapper().batchInsert(quotationFields); + } + if (CollectionUtils.isNotEmpty(quotationFields)) { + opportunityQuotationFieldService.getResourceFieldBlobMapper().batchInsert(quotationFieldBlobs); + } + // 更新快照 + if (snapshot != null) { + OpportunityQuotationGetResponse snapshotRes = getOpportunityQuotationGetResponse(quotation, response.getModuleFields(), formConfig); + snapshot.setQuotationValue(JSON.toJSONString(snapshotRes)); + snapshotBaseMapper.update(snapshot); + } + // 记录审批后置字段更新日志 + baseService.handleUpdateLogWithSubTable(originQuotation, quotation, originFields, opportunityQuotationFieldService.getModuleFieldValuesByResourceId(postFieldParam.getResourceId()), + postFieldParam.getResourceId(), quotation.getName(), Translator.get("products_info"), formConfig); + // 从 OperationLogContext 中获取日志信息并手动记录 + LogContextInfo contextInfo = OperationLogContext.getContext(); + if (contextInfo != null) { + String orgId = OrganizationContext.getOrganizationId(); + LogDTO logDTO = new LogDTO(orgId, postFieldParam.getResourceId(), postFieldParam.getOperator(), LogType.UPDATE, LogModule.OPPORTUNITY_QUOTATION, quotation.getName()); + logDTO.setOriginalValue(contextInfo.getOriginalValue()); + logDTO.setModifiedValue(contextInfo.getModifiedValue()); + logService.add(logDTO); + OperationLogContext.clear(); + } + } + + /** + * 作废报价快照 + * @param id + */ + private void voidedSnapshot(String id) { + LambdaQueryWrapper delWrapper = new LambdaQueryWrapper<>(); + delWrapper.eq(OpportunityQuotationSnapshot::getQuotationId, id); + List opportunityQuotationSnapshots = snapshotBaseMapper.selectListByLambda(delWrapper); + OpportunityQuotationSnapshot first = opportunityQuotationSnapshots.getFirst(); + if (first != null) { + OpportunityQuotationGetResponse response = JSON.parseObject(first.getQuotationValue(), OpportunityQuotationGetResponse.class); + response.setInvalid(true); + first.setQuotationValue(JSON.toJSONString(response)); + snapshotBaseMapper.update(first); + } + } + + /** + * 更新审批状态 + * + * @param opportunityQuotation 报价单实体 + * @param approvalStatus 审批状态 + * @param userId 用户ID + * @return 报价单 + */ + private OpportunityQuotation updateApprovalState(OpportunityQuotation opportunityQuotation, String approvalStatus, String userId) { + if ((Strings.CI.equals(approvalStatus, ApprovalState.APPROVED.toString()) || Strings.CI.equals(approvalStatus, ApprovalState.UNAPPROVED.toString())) && !Strings.CI.equals(opportunityQuotation.getApprovalStatus(), ApprovalState.APPROVING.toString())) { + return null; + } + opportunityQuotation.setApprovalStatus(approvalStatus); + opportunityQuotation.setUpdateUser(userId); + opportunityQuotation.setUpdateTime(System.currentTimeMillis()); + opportunityQuotationMapper.update(opportunityQuotation); + return opportunityQuotation; + } + + /** + * 发送通知 + * + * @param key 通知内容key + * @param opportunityQuotation 报价单实体 + * @param userId 用户ID + * @param orgId 组织ID + * @param event 事件类型 + */ + private void sendNotice(String key, OpportunityQuotation opportunityQuotation, String userId, String orgId, String event) { + Map paramMap = new HashMap<>(); + if (StringUtils.isNotBlank(key)) { + paramMap.put("state", key); + } + paramMap.put("name", opportunityQuotation.getName()); + commonNoticeSendService.sendNotice(NotificationConstants.Module.OPPORTUNITY, event, + paramMap, userId, orgId, List.of(opportunityQuotation.getCreateUser()), true); + } + + /** + * 保存状态变更日志 + * + * @param orgId 组织ID + * @param state 审批状态 + * @param userId 用户ID + * @param logType 日志类型 + * @param opportunityQuotation 报价单实体 + */ + private void saveSateChangeLog(String orgId, String state, String userId, String logType, OpportunityQuotation opportunityQuotation) { + LogDTO logDTO = new LogDTO(orgId, opportunityQuotation.getId(), userId, logType, LogModule.OPPORTUNITY_QUOTATION, opportunityQuotation.getName()); + if (state == null) { + logDTO.setOriginalValue(opportunityQuotation.getName()); + } else { + Map oldMap = new HashMap<>(); + oldMap.put("approvalStatus", state); + logDTO.setOriginalValue(oldMap); + Map newMap = new HashMap<>(); + newMap.put("approvalStatus", opportunityQuotation.getApprovalStatus()); + logDTO.setModifiedValue(newMap); + } + logService.add(logDTO); + } + + /** + * 删除商机报价单 + * + * @param id 报价单ID + * @param userId 用户ID + * @param organizationId 组织ID + */ + @HitApproval(formKey = FormKey.QUOTATION, executeType = ExecuteTimingEnum.DELETE, resourceId = "{#id}", operatorId = "{#userId}") + public void delete(String id, String userId, String organizationId) { + OpportunityQuotation opportunityQuotation = opportunityQuotationMapper.selectByPrimaryKey(id); + if (opportunityQuotation == null) { + return; + } + checkQuotationLinked(id, "opportunity.quotation.already.associated"); + opportunityQuotationFieldService.deleteByResourceId(id); + opportunityQuotationMapper.deleteByPrimaryKey(id); + + //删除快照 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(OpportunityQuotationSnapshot::getQuotationId, id); + snapshotBaseMapper.deleteByLambda(wrapper); + + //记录日志 + saveSateChangeLog(organizationId, null, userId, LogType.DELETE, opportunityQuotation); + + //发送通知 + sendNotice(null, opportunityQuotation, userId, organizationId, NotificationConstants.Event.BUSINESS_QUOTATION_DELETED); + } + + @Override + public void deleteForResource(String resourceId, String userId, String organizationId) { + delete(resourceId, userId, organizationId); + } + + @Override + public FormKey getFormKey() { + return FormKey.QUOTATION; + } + + /** + * 商机报价单列表 + * + * @param request 列表请求参数 + * @param organizationId 组织ID + * @return 商机报价单列表 + */ + public PagerWithOption> list(OpportunityQuotationPageRequest request, String organizationId, String userId, DeptDataPermissionDTO deptDataPermission, Boolean source) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extOpportunityQuotationMapper.list(request, organizationId, userId, deptDataPermission, source); + List results = buildList(list, organizationId); + // 处理自定义字段选项 + ModuleFormConfigDTO moduleFormConfigDTO = moduleFormCacheService.getBusinessFormConfig(FormKey.QUOTATION.getKey(), organizationId); + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(results, OpportunityQuotationListResponse::getModuleFields); + Map> optionMap = moduleFormService.getOptionMap(moduleFormConfigDTO, moduleFieldValues); + return PageUtils.setPageInfoWithOption(page, results, optionMap); + } + + /** + * 构建列表数据 + * + * @param listData 列表数据 + * @return 列表数据 + */ + private List buildList(List listData, String organizationId) { + // 查询列表数据的自定义字段 + Map> dataFieldMap = opportunityQuotationFieldService.getResourceFieldMap( + listData.stream().map(OpportunityQuotationListResponse::getId).collect(Collectors.toList()), true); + Map> resolvefieldValueMap = opportunityQuotationFieldService.setBusinessRefFieldValue(listData, + moduleFormService.getFlattenFormFields(FormKey.QUOTATION.getKey(), organizationId), dataFieldMap); + + // 列表项设置自定义字段&&用户名 + List createUserIds = listData.stream().map(OpportunityQuotationListResponse::getCreateUser).toList(); + Map userDeptMap = baseService.getUserDeptMapByUserIds(createUserIds, organizationId); + // 审批相关 + List approvingResourceIds = listData.stream().filter(item -> Strings.CI.contains(item.getApprovalStatus(), ApprovalStatus.APPROVING.name())).map(OpportunityQuotationListResponse::getId).toList(); + Map firstNodeApprovedMap = baseService.getApprovingResourceFirstNodeApproved(approvingResourceIds, organizationId); + listData.forEach(item -> { + item.setModuleFields(resolvefieldValueMap.get(item.getId())); + UserDeptDTO userDeptDTO = userDeptMap.get(item.getCreateUser()); + if (userDeptDTO != null) { + item.setDepartmentId(userDeptDTO.getDeptId()); + item.setDepartmentName(userDeptDTO.getDeptName()); + } + item.setFirstApproved(firstNodeApprovedMap.get(item.getId())); + }); + return baseService.setCreateAndUpdateUserName(listData); + } + + /** + * 更新商机报价单 + * + * @param request 更新请求参数 + * @param userId 更新用户ID + * @param orgId 组织ID + * @return 更新后的报价单实体 + */ + @OperationLog(module = LogModule.OPPORTUNITY_QUOTATION, type = LogType.UPDATE, resourceName = "{#request.name}", operator = "{#userId}") + @HitApproval(formKey = FormKey.QUOTATION, executeType = ExecuteTimingEnum.UPDATE, resourceId = "{#request.id}", updateType = "{#request.updateType}", operatorId = "{#userId}", comment = "{#request.comment}") + public OpportunityQuotation update(OpportunityQuotationEditRequest request, String userId, String orgId) { + String id = request.getId(); + List moduleFields = request.getModuleFields(); + ModuleFormConfigDTO moduleFormConfigDTO = request.getModuleFormConfigDTO(); + checkQuotationInfo(moduleFields, moduleFormConfigDTO); + ModuleFormConfigDTO saveModuleFormConfigDTO = JSON.parseObject(JSON.toJSONString(moduleFormConfigDTO), ModuleFormConfigDTO.class); + + OpportunityQuotation oldOpportunityQuotation = opportunityQuotationMapper.selectByPrimaryKey(id); + if (oldOpportunityQuotation == null) { + throw new GenericException(Translator.get("opportunity.quotation.not.exist")); + } + List originFields = new ArrayList<>(); + OpportunityQuotation opportunityQuotation = BeanUtils.copyBean(new OpportunityQuotation(), request); + opportunityQuotation.setUpdateTime(System.currentTimeMillis()); + opportunityQuotation.setUpdateUser(userId); + opportunityQuotation.setInvalid(oldOpportunityQuotation.getInvalid()); + opportunityQuotation.setCreateTime(oldOpportunityQuotation.getCreateTime()); + opportunityQuotation.setCreateUser(oldOpportunityQuotation.getCreateUser()); + opportunityQuotation.setApprovalStatus(oldOpportunityQuotation.getApprovalStatus()); + //判断总金额 + setAmount(request.getAmount(), opportunityQuotation); + // 设置子表格字段值 + moduleFields.add(new BaseModuleFieldValue("products", request.getProducts())); + updateFields(moduleFields, opportunityQuotation, orgId, userId); + opportunityQuotationMapper.update(opportunityQuotation); + + //删除快照 + LambdaQueryWrapper delWrapper = new LambdaQueryWrapper<>(); + delWrapper.eq(OpportunityQuotationSnapshot::getQuotationId, id); + List opportunityQuotationSnapshots = snapshotBaseMapper.selectListByLambda(delWrapper); + if (CollectionUtils.isNotEmpty(opportunityQuotationSnapshots)) { + OpportunityQuotationSnapshot first = opportunityQuotationSnapshots.getFirst(); + if (first != null) { + OpportunityQuotationGetResponse response = JSON.parseObject(first.getQuotationValue(), OpportunityQuotationGetResponse.class); + List moduleFields1 = response.getModuleFields(); + moduleFields1.add(new BaseModuleFieldValue("products", response.getProducts())); + originFields.addAll(moduleFields1); + } + } + snapshotBaseMapper.deleteByLambda(delWrapper); + //保存快照 + List resolveFieldValues = moduleFormService.resolveSnapshotFields(moduleFields, moduleFormConfigDTO, opportunityQuotationFieldService, opportunityQuotation.getId()); + OpportunityQuotationGetResponse response = getOpportunityQuotationGetResponse(opportunityQuotation, resolveFieldValues, moduleFormConfigDTO); + saveSnapshot(opportunityQuotation, saveModuleFormConfigDTO, response); + // 处理日志上下文 + baseService.handleUpdateLogWithSubTable(oldOpportunityQuotation, opportunityQuotation, originFields, moduleFields, id, opportunityQuotation.getName(), Translator.get("products_info"), moduleFormConfigDTO); + return opportunityQuotationMapper.selectByPrimaryKey(id); + } + + private void setAmount(String amount, OpportunityQuotation opportunityQuotation) { + if (StringUtils.isNotBlank(amount)) { + opportunityQuotation.setAmount(new BigDecimal(amount)); + if (opportunityQuotation.getAmount().compareTo(MAX_AMOUNT) > 0) { + throw new GenericException(Translator.get("opportunity.quotation.amount.exceed.max")); + } + } else { + opportunityQuotation.setAmount(BigDecimal.ZERO); + } + } + + /** + * 检查报价单信息 + * + * @param moduleFields 报价单字段值 + * @param moduleFormConfigDTO 报价单表单配置 + */ + private void checkQuotationInfo(List moduleFields, ModuleFormConfigDTO moduleFormConfigDTO) { + if (CollectionUtils.isEmpty(moduleFields)) { + throw new GenericException(Translator.get("opportunity.quotation.field.required")); + } + if (moduleFormConfigDTO == null) { + throw new GenericException(Translator.get("opportunity.quotation.form.config.required")); + } + } + + + + /** + * 更新自定义字段 + * + * @param fields 自定义字段集合 + * @param opportunityQuotation 报价单实体 + * @param orgId 当前组织 + * @param userId 当前用户 + */ + private void updateFields(List fields, OpportunityQuotation opportunityQuotation, String orgId, String userId) { + if (fields == null) { + return; + } + opportunityQuotationFieldService.deleteByResourceId(opportunityQuotation.getId()); + opportunityQuotationFieldService.saveModuleField(opportunityQuotation, orgId, userId, fields, true); + } + + /** + * 获取商机报价单模块标签页启用配置 + * + * @param userId 用户ID + * @param orgId 组织ID + * @return 模块标签页启用配置 + */ + public ResourceTabEnableDTO getTabEnableConfig(String userId, String orgId) { + List rolePermissions = permissionCache.getRolePermissions(userId, orgId); + return PermissionUtils.getTabEnableConfig(userId, PermissionConstants.OPPORTUNITY_QUOTATION_READ, rolePermissions); + } + + + /** + * 批量审批商机报价单 + * + * @param request 批量审批请求参数 + * @param userId 用户ID + * @param orgId 组织ID + * @return 审批状态 + */ + public BatchAffectSkipResponse batchApprove(OpportunityQuotationBatchRequest request, String userId, String orgId) { + checkApprovalConfig(orgId); + + List ids = request.getIds(); + String approvalStatus = request.getApprovalStatus(); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(OpportunityQuotation::getId, ids); + List list = opportunityQuotationMapper.selectListByLambda(wrapper); + if (CollectionUtils.isEmpty(list)) { + return BatchAffectSkipResponse.builder().success(0).fail(0).skip(0).build(); + } + + // 校验状态权限,过滤出有权限操作的报价单 + List permittedIds = approvalFlowService.filterResourcesWithPermission( + ApprovalFormTypeEnum.QUOTATION.getValue(), + list, + PermissionConstants.OPPORTUNITY_QUOTATION_APPROVAL, + orgId, + OpportunityQuotation::getId, + OpportunityQuotation::getApprovalStatus + ); + + Set permittedIdSet = new HashSet<>(permittedIds); + AtomicInteger permissionDeniedCount = new AtomicInteger(0); + + SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); + ExtOpportunityQuotationMapper batchUpdateMapper = sqlSession.getMapper(ExtOpportunityQuotationMapper.class); + ExtOpportunityQuotationSnapshotMapper extOpportunityQuotationSnapshotMapper = sqlSession.getMapper(ExtOpportunityQuotationSnapshotMapper.class); + + List logs = new ArrayList<>(); + List approvingIds = new ArrayList<>(); + AtomicInteger skipCount = new AtomicInteger(0); + list.stream().filter(item -> { + // 检查状态权限 + if (!permittedIdSet.contains(item.getId())) { + permissionDeniedCount.getAndIncrement(); + return false; + } + if (!Strings.CI.equals(item.getApprovalStatus(), ApprovalState.APPROVING.toString())) { + skipCount.getAndIncrement(); + return false; + } + return true; + }).forEach(item -> { + approvingIds.add(item.getId()); + var log = new LogDTO( + orgId, + item.getId(), + userId, + LogType.APPROVAL, + LogModule.OPPORTUNITY_QUOTATION, + item.getName() + ); + Map oldMap = new HashMap<>(); + oldMap.put("approvalStatus", item.getApprovalStatus()); + log.setOriginalValue(oldMap); + Map newMap = new HashMap<>(); + newMap.put("approvalStatus", approvalStatus); + log.setModifiedValue(newMap); + logs.add(log); + batchUpdateMapper.updateApprovalStatus(item.getId(), approvalStatus, userId, System.currentTimeMillis()); + }); + + //批量修改报价单快照 + LambdaQueryWrapper snapshotWrapper = new LambdaQueryWrapper<>(); + snapshotWrapper.in(OpportunityQuotationSnapshot::getQuotationId, approvingIds); + List opportunityQuotationSnapshots = snapshotBaseMapper.selectListByLambda(snapshotWrapper); + for (OpportunityQuotationSnapshot snapshot : opportunityQuotationSnapshots) { + OpportunityQuotationGetResponse response = JSON.parseObject(snapshot.getQuotationValue(), OpportunityQuotationGetResponse.class); + response.setApprovalStatus(approvalStatus); + snapshot.setQuotationValue(JSON.toJSONString(response)); + extOpportunityQuotationSnapshotMapper.update(snapshot); + + } + sqlSession.flushStatements(); + SqlSessionUtils.closeSqlSession(sqlSession, sqlSessionFactory); + logService.batchAdd(logs); + list.forEach( + item -> sendNotice((Strings.CI.equals(approvalStatus, ApprovalState.APPROVED.toString()) ? + Translator.get("opportunity.quotation.status.approved") : Translator.get("opportunity.quotation.status.unapproved")), item, userId, orgId, NotificationConstants.Event.BUSINESS_QUOTATION_APPROVAL) + ); + return BatchAffectSkipResponse.builder().success(approvingIds.size()).fail(permissionDeniedCount.get()).skip(skipCount.get()).build(); + } + + /** + * 批量更新报价单 + * + * @param request 批量编辑参数 + * @param userId 当前用户ID + * @param organizationId 当前组织ID + */ + public BatchAffectReasonResponse batchUpdate(ResourceBatchEditRequest request, String userId, String organizationId) { + BaseField field = opportunityQuotationFieldService.getAndCheckField(request.getFieldId(), organizationId); + moduleFormService.setFieldBusinessParam(field); + List originQuotations = opportunityQuotationMapper.selectByIds(request.getIds()); + if (CollectionUtils.isEmpty(originQuotations)) { + return BatchAffectReasonResponse.builder().success(0).fail(0).skip(0).errorMessages(Translator.get("opportunity.quotation.not.exist")).build(); + } + + // 校验状态权限,过滤出有权限操作的报价单 + List permittedIds = approvalFlowService.filterResourcesWithPermission( + ApprovalFormTypeEnum.QUOTATION.getValue(), + originQuotations, + PermissionConstants.OPPORTUNITY_QUOTATION_UPDATE, + organizationId, + OpportunityQuotation::getId, + OpportunityQuotation::getApprovalStatus + ); + + if (CollectionUtils.isEmpty(permittedIds)) { + return BatchAffectReasonResponse.builder().success(0).fail(originQuotations.size()).skip(0).errorMessages(Translator.get("no.operation.permission")).build(); + } + ApprovalResourceService approvalResourceService = CommonBeanFactory.getBean(ApprovalResourceService.class); + approvalResourceService.batchEditTriggerApproval(permittedIds, request.getFieldId(), FormKey.QUOTATION, organizationId, userId, field.getName(), request.getFieldValue()); + + // 只对有权限的报价单进行操作 + List permittedQuotations = originQuotations.stream() + .filter(q -> permittedIds.contains(q.getId())) + .collect(Collectors.toList()); + + ResourceBatchEditRequest filteredRequest = new ResourceBatchEditRequest(); + filteredRequest.setIds(permittedIds); + filteredRequest.setFieldId(request.getFieldId()); + filteredRequest.setFieldValue(request.getFieldValue()); + + opportunityQuotationFieldService.batchUpdate( + filteredRequest, + field, + permittedQuotations, + OpportunityQuotation.class, + LogModule.OPPORTUNITY_QUOTATION, + extOpportunityQuotationMapper::batchUpdate, + userId, + organizationId + ); + + ModuleFormConfigDTO moduleFormConfigDTO = moduleFormCacheService.getBusinessFormConfig(FormKey.QUOTATION.getKey(), organizationId); + ModuleFormConfigDTO saveModuleFormConfigDTO = JSON.parseObject(JSON.toJSONString(moduleFormConfigDTO), ModuleFormConfigDTO.class); + + LambdaQueryWrapper delWrapper = new LambdaQueryWrapper<>(); + delWrapper.in(OpportunityQuotationSnapshot::getQuotationId, permittedIds); + snapshotBaseMapper.deleteByLambda(delWrapper); + + List latestQuotations = opportunityQuotationMapper.selectByIds(permittedIds); + Map latestQuotationMap = latestQuotations.stream() + .collect(Collectors.toMap(OpportunityQuotation::getId, item -> item)); + Map> fieldMap = opportunityQuotationFieldService.getResourceFieldMap(permittedIds, true); + + List snapshots = new ArrayList<>(); + for (String id : permittedIds) { + OpportunityQuotation opportunityQuotation = latestQuotationMap.get(id); + if (opportunityQuotation == null) { + continue; + } + List quotationFields = fieldMap.getOrDefault(id, Collections.emptyList()); + List resolveFieldValues = moduleFormService.resolveSnapshotFields( + quotationFields, + moduleFormConfigDTO, + opportunityQuotationFieldService, + id + ); + OpportunityQuotationGetResponse response = getOpportunityQuotationGetResponse(opportunityQuotation, resolveFieldValues, moduleFormConfigDTO); + if (CollectionUtils.isNotEmpty(response.getModuleFields())) { + response.setModuleFields(response.getModuleFields().stream() + .filter(f -> f.getFieldValue() != null + && StringUtils.isNotBlank(f.getFieldValue().toString()) + && !"[]".equals(f.getFieldValue().toString())) + .toList()); + } + OpportunityQuotationSnapshot snapshot = new OpportunityQuotationSnapshot(); + snapshot.setId(IDGenerator.nextStr()); + snapshot.setQuotationId(id); + snapshot.setQuotationProp(JSON.toJSONString(saveModuleFormConfigDTO)); + snapshot.setQuotationValue(JSON.toJSONString(response)); + snapshots.add(snapshot); + } + if (CollectionUtils.isNotEmpty(snapshots)) { + snapshotBaseMapper.batchInsert(snapshots); + } + + return BatchAffectReasonResponse.builder().success(permittedIds.size()).fail(originQuotations.size() - permittedIds.size()).skip(0).errorMessages(Translator.get("batch.update.reason")).build(); + } + + + /** + * 批量作废商机报价单 + * + * @param request 批量作废请求参数 + * @param userId 用户ID + * @param organizationId 组织ID + * @return 批量作废响应参数 + */ + public BatchAffectReasonResponse batchVoidQuotation(OpportunityQuotationBatchVoidedRequest request, String userId, String organizationId) { + List ids = request.getIds(); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(OpportunityQuotation::getId, ids); + List list = opportunityQuotationMapper.selectListByLambda(wrapper); + if (CollectionUtils.isEmpty(list)) { + return BatchAffectReasonResponse.builder().success(0).fail(0).skip(0).errorMessages(Translator.get("opportunity.quotation.not.exist")).build(); + } + + // 校验状态权限,过滤出有权限操作的报价单 + List permittedIds = approvalFlowService.filterResourcesWithPermission( + ApprovalFormTypeEnum.QUOTATION.getValue(), + list, + PermissionConstants.OPPORTUNITY_QUOTATION_VOIDED, + organizationId, + OpportunityQuotation::getId, + OpportunityQuotation::getApprovalStatus + ); + + Set permittedIdSet = new HashSet<>(permittedIds); + AtomicInteger permissionDeniedCount = new AtomicInteger(0); + + // 过滤出有权限的报价单 + List permittedList = list.stream() + .filter(item -> { + if (!permittedIdSet.contains(item.getId())) { + permissionDeniedCount.getAndIncrement(); + return false; + } + return true; + }) + .collect(Collectors.toList()); + + // 校验商机报价单是否可以作废 + List validateList = validateVoidQuotation(permittedList); + if (CollectionUtils.isEmpty(validateList)) { + return BatchAffectReasonResponse.builder().success(0).fail(permissionDeniedCount.get() + (permittedList.size())).skip(0).errorMessages(Translator.get("opportunity.quotation.batch.no.voided")).build(); + } + + List logs = new ArrayList<>(); + List successIds = new ArrayList<>(); + List successList = new ArrayList<>(); + AtomicInteger skipCount = new AtomicInteger(); + validateList.stream().filter( + item -> { + if (BooleanUtils.isTrue(item.getInvalid())) { + skipCount.getAndIncrement(); + return false; + } + return true; + } + ).forEach(item -> { + successList.add(item); + successIds.add(item.getId()); + var log = new LogDTO( + organizationId, + item.getId(), + userId, + LogType.VOIDED, + LogModule.OPPORTUNITY_QUOTATION, + item.getName() + ); + logs.add(log); + + }); + if (CollectionUtils.isNotEmpty(successIds)) { + LambdaQueryWrapper snapshotWrapper = new LambdaQueryWrapper<>(); + snapshotWrapper.in(OpportunityQuotationSnapshot::getQuotationId, successIds); + List opportunityQuotationSnapshots = snapshotBaseMapper.selectListByLambda(snapshotWrapper); + SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); + ExtOpportunityQuotationMapper batchUpdateMapper = sqlSession.getMapper(ExtOpportunityQuotationMapper.class); + ExtOpportunityQuotationSnapshotMapper extOpportunityQuotationSnapshotMapper = sqlSession.getMapper(ExtOpportunityQuotationSnapshotMapper.class); + successIds.forEach(id -> batchUpdateMapper.voided(id, userId, System.currentTimeMillis())); + //批量修改报价单快照 + for (OpportunityQuotationSnapshot snapshot : opportunityQuotationSnapshots) { + OpportunityQuotationGetResponse response = JSON.parseObject(snapshot.getQuotationValue(), OpportunityQuotationGetResponse.class); + response.setInvalid(true); + snapshot.setQuotationValue(JSON.toJSONString(response)); + extOpportunityQuotationSnapshotMapper.update(snapshot); + } + sqlSession.flushStatements(); + SqlSessionUtils.closeSqlSession(sqlSession, sqlSessionFactory); + logService.batchAdd(logs); + successList.forEach( + item -> sendNotice(Translator.get("opportunity.quotation.status.voided"), item, userId, organizationId, NotificationConstants.Event.BUSINESS_QUOTATION_APPROVAL) + ); + } + + return BatchAffectReasonResponse.builder().success(successIds.size()).fail(permissionDeniedCount.get() + (permittedList.size() - validateList.size())).skip(skipCount.get()).errorMessages(Translator.get("opportunity.quotation.batch.no.voided")).build(); + } + + + /** + * 验证不可作废的报价单 + * + * @param list 报价单列表 + * @return 不可作废的报价单列表 + */ + private List validateVoidQuotation(List list) { + List contractFields = contractFieldMapper.selectAll(null); + + List contractFieldBlobs = contractFieldBlobMapper.selectAll(null); + Set existingIdSet = new HashSet<>(); + contractFields.forEach(cf -> + existingIdSet.addAll(extractIdsFromFieldValue(cf.getFieldValue())) + ); + contractFieldBlobs.forEach(cfb -> + existingIdSet.addAll(extractIdsFromFieldValue(cfb.getFieldValue())) + ); + return list.stream() + .filter(item -> !existingIdSet.contains(item.getId())).toList(); + + } + + /** + * 从字段值中提取ID集合 + * + * @param fieldValue 字段值 + * @return ID集合 + */ + private Set extractIdsFromFieldValue(Object fieldValue) { + if (fieldValue == null) { + return Collections.emptySet(); + } + String text = String.valueOf(fieldValue).trim(); + Set result = new HashSet<>(); + // 尝试数组 + if (text.startsWith("[") && text.endsWith("]")) { + try { + // 修复 ['123','456'] → ["123","456"] + text = text.replace('\'', '"'); + + JsonNode node = mapper.readTree(text); + if (node.isArray()) { + for (JsonNode item : node) { + result.add(item.asText()); + } + return result; + } + } catch (Exception ignore) { + } + } + // 尝试 CSV,例如 "123,456" + if (text.contains(",")) { + for (String part : text.split(",")) { + result.add(part.trim()); + } + return result; + } + // 单值 + result.add(text); + return result; + } + + + /** + * 获取表单快照 + * + * @param id 报价单ID + * @param orgId 组织ID + * @return 表单配置DTO + */ + public ModuleFormConfigDTO getFormSnapshot(String id, String orgId) { + ModuleFormConfigDTO moduleFormConfigDTO; + OpportunityQuotation opportunityQuotation = opportunityQuotationMapper.selectByPrimaryKey(id); + if (opportunityQuotation == null) { + throw new GenericException(Translator.get("opportunity.quotation.not.exist")); + } + //获取ApprovalState中所有状态的id属性(以后改成获取自定义的审批状态) + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(OpportunityQuotationSnapshot::getQuotationId, id); + OpportunityQuotationSnapshot snapshot = snapshotBaseMapper.selectListByLambda(wrapper).stream().findFirst().orElse(null); + if (snapshot != null) { + moduleFormConfigDTO = JSON.parseObject(snapshot.getQuotationProp(), ModuleFormConfigDTO.class); + } else { + moduleFormConfigDTO = moduleFormCacheService.getBusinessFormConfig(FormKey.QUOTATION.getKey(), orgId); + } + return moduleFormConfigDTO; + } + + public String getQuotationName(String id) { + OpportunityQuotation opportunityQuotation = opportunityQuotationMapper.selectByPrimaryKey(id); + return Optional.ofNullable(opportunityQuotation).map(OpportunityQuotation::getName).orElse(null); + } + + /** + * 通过名称获取报价单集合 + * + * @param names 名称集合 + * @return 报价单集合 + */ + public List getQuotationListByNames(List names) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.in(OpportunityQuotation::getName, names); + return opportunityQuotationMapper.selectListByLambda(lambdaQueryWrapper); + } + + public String getQuotationNameByIds(List ids) { + List opportunityQuotations = opportunityQuotationMapper.selectByIds(ids); + if (org.apache.commons.collections4.CollectionUtils.isNotEmpty(opportunityQuotations)) { + List names = opportunityQuotations.stream().map(OpportunityQuotation::getName).toList(); + return String.join(",", names); + } + return StringUtils.EMPTY; + } + + public void download(String id, String userId, String organizationId) { + OpportunityQuotation opportunityQuotation = opportunityQuotationMapper.selectByPrimaryKey(id); + if (opportunityQuotation == null) { + throw new GenericException(Translator.get("opportunity.quotation.not.exist")); + } + saveSateChangeLog(organizationId, null, userId, LogType.DOWNLOAD, opportunityQuotation); + } + + /** + * 处理旧版本审批状态 (APPROVING, VOIDED => NONE), 作废字段单独处理 + */ + public void handleOldApprovalData() { + List quotations = opportunityQuotationMapper.selectAll(null); + quotations.forEach(quotation -> { + quotation.setInvalid(Strings.CI.equals(quotation.getApprovalStatus(), "VOIDED")); + if (Strings.CI.equalsAny(quotation.getApprovalStatus(), "VOIDED", "APPROVING")) { + quotation.setApprovalStatus(ApprovalStatus.NONE.name()); + } + opportunityQuotationMapper.updateById(quotation); + OpportunityQuotationSnapshot snapshotCriteria = new OpportunityQuotationSnapshot(); + snapshotCriteria.setQuotationId(quotation.getId()); + OpportunityQuotationSnapshot snapshot = snapshotBaseMapper.selectOne(snapshotCriteria); + if (snapshot != null) { + OpportunityQuotationGetResponse response = JSON.parseObject(snapshot.getQuotationValue(), OpportunityQuotationGetResponse.class); + response.setApprovalStatus(quotation.getApprovalStatus()); + response.setInvalid(quotation.getInvalid()); + snapshot.setQuotationValue(JSON.toJSONString(response)); + snapshotBaseMapper.update(snapshot); + } + }); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityQuotationalLogService.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityQuotationalLogService.java new file mode 100644 index 0000000..b2f7b23 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityQuotationalLogService.java @@ -0,0 +1,59 @@ +package cn.cordys.crm.opportunity.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.service.BaseModuleLogService; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(rollbackFor = Exception.class) +public class OpportunityQuotationalLogService extends BaseModuleLogService { + + @Override + public List handleLogField(List differences, String orgId) { + differences = super.handleModuleLogField(differences, orgId, FormKey.QUOTATION.getKey()); + + for (JsonDifferenceDTO differ : differences) { + + if (Strings.CS.equals(differ.getColumn(), "product")) { + setProductName(differ); + continue; + } + + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.QUOTATION_OPPORTUNITY.getBusinessKey())) { + setOpportunityName(differ); + continue; + } + + + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.QUOTATION_TOTAL_AMOUNT.getBusinessKey())) { + differ.setColumnName(Translator.get("log.amount")); + } + + if (differ.getColumn().contains("-")) { + differ.setColumnName(differ.getColumn()); + + } + + if (Strings.CS.equals(differ.getColumn(), "approvalStatus")) { + differ.setColumnName(Translator.get("log.approvalStatus")); + if (differ.getOldValue() != null) { + differ.setOldValueName(Translator.get("log.approvalStatus." + differ.getOldValueName().toString())); + } + if (differ.getNewValue() != null) { + differ.setNewValueName(Translator.get("log.approvalStatus." + differ.getNewValueName().toString())); + } + } + + } + return differences; + } + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityResourceAccessContextProvider.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityResourceAccessContextProvider.java new file mode 100644 index 0000000..d0a20e4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityResourceAccessContextProvider.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.opportunity.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.permission.ResourceAccessContext; +import cn.cordys.common.permission.ResourceAccessContextProvider; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class OpportunityResourceAccessContextProvider implements ResourceAccessContextProvider { + + @Resource + private BaseMapper opportunityMapper; + + @Override + public String getFormType() { + return FormKey.OPPORTUNITY.getKey(); + } + + @Override + public ResourceAccessContext getAccessContext(String resourceId, String orgId) { + Opportunity opportunity = opportunityMapper.selectByPrimaryKey(resourceId); + if (opportunity == null) { + return null; + } + ResourceAccessContext context = new ResourceAccessContext(); + context.setOwnerId(opportunity.getOwner()); + return context; + } + + @Override + public Map batchGetOwnerIds(List resourceIds, String orgId) { + if (resourceIds == null || resourceIds.isEmpty()) { + return Map.of(); + } + return opportunityMapper.selectByIds(resourceIds).stream() + .filter(opportunity -> opportunity.getOwner() != null) + .collect(Collectors.toMap(Opportunity::getId, Opportunity::getOwner, (a, b) -> a)); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityRuleService.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityRuleService.java new file mode 100644 index 0000000..20d1d71 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityRuleService.java @@ -0,0 +1,336 @@ +package cn.cordys.crm.opportunity.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.condition.CombineSearch; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.common.utils.RecycleConditionUtils; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.opportunity.domain.OpportunityRule; +import cn.cordys.crm.opportunity.dto.OpportunityRuleDTO; +import cn.cordys.crm.opportunity.dto.request.OpportunityRuleAddRequest; +import cn.cordys.crm.opportunity.dto.request.OpportunityRuleUpdateRequest; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityRuleMapper; +import cn.cordys.crm.system.constants.RecycleConditionColumnKey; +import cn.cordys.crm.system.constants.RecycleConditionOperator; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.RuleConditionDTO; +import cn.cordys.crm.system.service.UserExtendService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@Transactional(rollbackFor = Exception.class) +public class OpportunityRuleService { + + @Resource + private BaseMapper userMapper; + @Resource + private BaseMapper opportunityRuleMapper; + @Resource + private ExtOpportunityRuleMapper extOpportunityRuleMapper; + @Resource + private UserExtendService userExtendService; + + /** + * 分页获取商机规则 + * + * @param request 分页参数 + * + * @return 商机规则列表 + */ + public List page(BasePageRequest request, String organizationId) { + List rules = extOpportunityRuleMapper.list(request, organizationId); + if (CollectionUtils.isEmpty(rules)) { + return new ArrayList<>(); + } + List userIds = rules.stream().flatMap(rule -> Stream.of(rule.getCreateUser(), rule.getUpdateUser())).toList(); + List createOrUpdateUsers = userMapper.selectByIds(userIds.toArray(new String[0])); + Map userMap = createOrUpdateUsers.stream().collect(Collectors.toMap(User::getId, User::getName)); + rules.forEach(rule -> { + delOldTime(rule); + rule.setMembers(userExtendService.getScope(JSON.parseArray(rule.getScopeId(), String.class))); + rule.setOwners(userExtendService.getScope(JSON.parseArray(rule.getOwnerId(), String.class))); + rule.setCreateUserName(userMap.get(rule.getCreateUser())); + rule.setUpdateUserName(userMap.get(rule.getUpdateUser())); + }); + return rules; + } + + private void delOldTime(OpportunityRuleDTO rule) { + List ruleConditionDTOS = JSON.parseArray(rule.getCondition(), RuleConditionDTO.class); + if (CollectionUtils.isNotEmpty(ruleConditionDTOS)) { + for (RuleConditionDTO condition : ruleConditionDTOS) { + if (Strings.CS.equals(condition.getColumn(), RecycleConditionColumnKey.CREATE_TIME) + && Strings.CS.equals(condition.getOperator(), RecycleConditionOperator.DYNAMICS.name())) { + String[] split = condition.getValue().split(","); + if (StringUtils.isNotBlank(condition.getValue()) && split.length == 2) { + String dateValue = split[0]; + String dateUnit = split[1]; + dateUnit = switch (dateUnit) { + case "day" -> "BEFORE_DAY"; + case "month" -> "BEFORE_MONTH"; + case "week" -> "BEFORE_WEEK"; + default -> dateUnit; + }; + condition.setValue("CUSTOM," + dateValue + "," + dateUnit); + } + } + } + } + } + + /** + * 新增商机规则 + * + * @param request 请求参数 + * @param currentUserId 当前用户ID + * @param organizationId 当前组织ID + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.ADD) + public void add(OpportunityRuleAddRequest request, String currentUserId, String organizationId) { + OpportunityRule rule = new OpportunityRule(); + BeanUtils.copyBean(rule, request); + rule.setId(IDGenerator.nextStr()); + rule.setOrganizationId(organizationId); + rule.setOwnerId(JSON.toJSONString(request.getOwnerIds())); + rule.setScopeId(JSON.toJSONString(request.getScopeIds())); + rule.setCondition(JSON.toJSONString(request.getConditions())); + rule.setCreateTime(System.currentTimeMillis()); + rule.setCreateUser(currentUserId); + rule.setUpdateTime(System.currentTimeMillis()); + rule.setUpdateUser(currentUserId); + + opportunityRuleMapper.insert(rule); + + // 添加日志上下文 + OperationLogContext.setContext(LogContextInfo.builder() + .modifiedValue(rule) + .resourceId(rule.getId()) + .resourceName(Translator.get("module.opportunity.rule.setting") + ": " + rule.getName()) + .build()); + } + + /** + * 修改商机规则 + * + * @param request 请求参数 + * @param currentUserId 当前用户ID + * @param organizationId 当前组织ID + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE, resourceId = "{#request.id}") + public void update(OpportunityRuleUpdateRequest request, String currentUserId, String organizationId) { + OpportunityRule oldRule = checkRuleExit(request.getId()); + OpportunityRule rule = new OpportunityRule(); + BeanUtils.copyBean(rule, request); + rule.setOrganizationId(organizationId); + rule.setOwnerId(JSON.toJSONString(request.getOwnerIds())); + rule.setScopeId(JSON.toJSONString(request.getScopeIds())); + rule.setCondition(JSON.toJSONString(request.getConditions())); + rule.setUpdateTime(System.currentTimeMillis()); + rule.setUpdateUser(currentUserId); + + opportunityRuleMapper.updateById(rule); + + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceName(Translator.get("module.opportunity.rule.setting") + ": " + oldRule.getName()) + .originalValue(oldRule) + .modifiedValue(opportunityRuleMapper.selectByPrimaryKey(request.getId())) + .build() + ); + } + + /** + * 删除商机规则 + * + * @param id 规则ID + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + OpportunityRule rule = checkRuleExit(id); + opportunityRuleMapper.deleteByPrimaryKey(id); + // 设置操作对象 + OperationLogContext.setResourceName(Translator.get("module.opportunity.rule.setting") + ": " + rule.getName()); + } + + /** + * 启用/禁用商机规则 + * + * @param id 规则ID + * @param currentUserId 当前用户ID + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE, resourceId = "{#id}") + public void switchStatus(String id, String currentUserId) { + OpportunityRule rule = checkRuleExit(id); + rule.setEnable(!rule.getEnable()); + rule.setUpdateUser(currentUserId); + rule.setUpdateTime(System.currentTimeMillis()); + opportunityRuleMapper.updateById(rule); + + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceName(Translator.get("module.opportunity.rule.setting") + ": " + rule.getName()) + .originalValue(rule) + .modifiedValue(opportunityRuleMapper.selectByPrimaryKey(id)) + .build() + ); + } + + /** + * 获取负责人默认规则 + * + * @param ownerIds 负责人ID集合 + * @param organizationId 组织ID + * + * @return 默认规则 + */ + public Map getOwnersDefaultRuleMap(List ownerIds, String organizationId) { + Map ruleMap = new HashMap<>(4); + List allRules = extOpportunityRuleMapper.getAllRule(organizationId); + Map> ownerScopeMap = userExtendService.getMultiScopeMap(ownerIds, organizationId); + ownerIds.forEach(ownerId -> { + List rules = matchMultiScope(ownerScopeMap.get(ownerId), allRules); + if (CollectionUtils.isEmpty(rules)) { + return; + } + ruleMap.put(ownerId, rules.getFirst()); + }); + + return ruleMap; + } + + /** + * 匹配多个范围的商机关闭规则 + * + * @param scopeIds 范围ID集合 + * @param rules 规则列表 + * + * @return 命中范围的规则列表 + */ + public List matchMultiScope(List scopeIds, List rules) { + /* + * 命中规则任意范围即返回(默认按照创建时间作为优先级) + */ + if (CollectionUtils.isEmpty(scopeIds) || CollectionUtils.isEmpty(rules)) { + return Collections.emptyList(); + } + return rules.stream() + .filter(rule -> { + List poolScopes = JSON.parseArray(rule.getScopeId(), String.class); + return CollectionUtils.isNotEmpty(poolScopes) && CollectionUtils.containsAny(scopeIds, poolScopes); + }) + .sorted(Comparator.comparing(OpportunityRule::getCreateTime).reversed()) + .collect(Collectors.toList()); + } + + /** + * 计算剩余归属天数 + * + * @param rule 商机规则 + * @param createTime 商机创建时间 + * + * @return 剩余归属天数 + */ + public Integer calcReservedDay(OpportunityRule rule, Long createTime) { + if (rule == null || !rule.getAuto()) { + return null; + } + + // 判断商机是否存在创建时间 + List conditions = JSON.parseArray(rule.getCondition(), RuleConditionDTO.class); + return RecycleConditionUtils.calcRecycleDays(conditions, createTime); + } + + /** + * 获取负责人最佳匹配规则 + * + * @param rules 规则列表 + * + * @return 匹配集合 + */ + public Map, OpportunityRule> getOwnersBestMatchRuleMap(List rules) { + Map, OpportunityRule> ruleMap = new HashMap<>(4); + rules.sort(Comparator.comparing(OpportunityRule::getCreateTime).reversed()); + for (OpportunityRule rule : rules) { + List exitOwnerIds = ruleMap.keySet().stream().flatMap(List::stream).toList(); + List scopeIds = JSON.parseArray(rule.getScopeId(), String.class); + List ownerIds = userExtendService.getScopeOwnerIds(scopeIds, rule.getOrganizationId()); + List defaultOwnerIds = ownerIds.stream().distinct().filter(ownerId -> !exitOwnerIds.contains(ownerId)).toList(); + if (CollectionUtils.isEmpty(defaultOwnerIds)) { + continue; + } + ruleMap.put(defaultOwnerIds, rule); + } + return ruleMap; + } + + /** + * 判断客户是否需要回收 + * + * @return 是否回收 + */ + public boolean checkClosed(Opportunity opportunity, OpportunityRule rule) { + boolean allMatch = Strings.CS.equals(CombineSearch.SearchMode.AND.name(), rule.getOperator()); + List conditions = JSON.parseArray(rule.getCondition(), RuleConditionDTO.class); + if (allMatch) { + return conditions.stream().allMatch(condition -> isConditionMatched(condition, opportunity)); + } else { + return conditions.stream().anyMatch(condition -> isConditionMatched(condition, opportunity)); + } + } + + /** + * 是否条件匹配 + * + * @param condition 条件 + * @param opportunity 商机 + * + * @return 是否匹配 + */ + private boolean isConditionMatched(RuleConditionDTO condition, Opportunity opportunity) { + if (StringUtils.isEmpty(condition.getValue())) { + return false; + } + if (Strings.CS.equals(condition.getColumn(), RecycleConditionColumnKey.OPPORTUNITY_STAGE)) { + if (Strings.CS.equals(condition.getOperator(), RecycleConditionOperator.IN.name())) { + return condition.getValue().contains(opportunity.getStage()); + } else { + return !condition.getValue().contains(opportunity.getStage()); + } + } else { + return RecycleConditionUtils.matchTime(condition, opportunity.getCreateTime()); + } + } + + /** + * 校验商机规则是否存在 + * + * @param id 规则ID + * + * @return 商机规则 + */ + private OpportunityRule checkRuleExit(String id) { + OpportunityRule rule = opportunityRuleMapper.selectByPrimaryKey(id); + if (rule == null) { + throw new RuntimeException(Translator.get("opportunity.rule.not_exist")); + } + return rule; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityService.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityService.java new file mode 100644 index 0000000..8e0bf04 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityService.java @@ -0,0 +1,890 @@ +package cn.cordys.crm.opportunity.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.domain.BaseResourceSubField; +import cn.cordys.common.dto.*; +import cn.cordys.common.dto.chart.ChartResult; +import cn.cordys.common.dto.stage.StageSortRequest; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.permission.PermissionUtils; +import cn.cordys.common.service.BaseChartService; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.dto.response.CustomerContactListAllResponse; +import cn.cordys.crm.customer.mapper.ExtCustomerContactMapper; +import cn.cordys.crm.customer.service.CustomerContactService; +import cn.cordys.crm.opportunity.constants.OpportunityStageType; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.opportunity.domain.OpportunityField; +import cn.cordys.crm.opportunity.domain.OpportunityFieldBlob; +import cn.cordys.crm.opportunity.domain.OpportunityRule; +import cn.cordys.crm.opportunity.dto.request.*; +import cn.cordys.crm.opportunity.dto.response.OpportunityDetailResponse; +import cn.cordys.crm.opportunity.dto.response.OpportunityListResponse; +import cn.cordys.crm.opportunity.dto.response.OpportunitySearchStatisticResponse; +import cn.cordys.crm.opportunity.dto.response.StageConfigResponse; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityMapper; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityStageConfigMapper; +import cn.cordys.crm.product.mapper.ExtProductMapper; +import cn.cordys.crm.product.service.ProductService; +import cn.cordys.crm.system.constants.DictModule; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.constants.SheetKey; +import cn.cordys.crm.system.domain.Dict; +import cn.cordys.crm.system.dto.DictConfigDTO; +import cn.cordys.crm.system.dto.field.SelectField; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.excel.CustomImportAfterDoConsumer; +import cn.cordys.crm.system.excel.handler.CustomHeadColWidthStyleStrategy; +import cn.cordys.crm.system.excel.handler.CustomTemplateWriteHandler; +import cn.cordys.crm.system.excel.listener.CustomFieldCheckEventListener; +import cn.cordys.crm.system.excel.listener.CustomFieldImportEventListener; +import cn.cordys.crm.system.notice.CommonNoticeSendService; +import cn.cordys.crm.system.service.DictService; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.excel.utils.EasyExcelExporter; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import cn.idev.excel.FastExcelFactory; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.ibatis.session.ExecutorType; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.mybatis.spring.SqlSessionUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class OpportunityService { + + public static final String SUCCESS = "SUCCESS"; + public static final Long DEFAULT_POS = 1L; + @Resource + private ExtOpportunityMapper extOpportunityMapper; + @Resource + private BaseService baseService; + @Resource + private OpportunityFieldService opportunityFieldService; + @Resource + private BaseChartService baseChartService; + @Resource + private LogService logService; + @Resource + private BaseMapper opportunityMapper; + @Autowired + private OpportunityRuleService opportunityRuleService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private ExtProductMapper extProductMapper; + @Resource + private CommonNoticeSendService commonNoticeSendService; + @Resource + private PermissionCache permissionCache; + @Resource + private CustomerContactService customerContactService; + @Resource + private ProductService productService; + @Resource + private ExtCustomerContactMapper extCustomerContactMapper; + @Resource + private DictService dictService; + @Resource + private BaseMapper opportunityFieldMapper; + @Resource + private BaseMapper opportunityFieldBlobMapper; + @Resource + private SqlSessionFactory sqlSessionFactory; + @Resource + private ExtOpportunityStageConfigMapper extOpportunityStageConfigMapper; + @Resource + private DataScopeService dataScopeService; + + public PagerWithOption> list(OpportunityPageRequest request, String userId, String orgId, + DeptDataPermissionDTO deptDataPermission, Boolean source) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extOpportunityMapper.list(request, orgId, userId, deptDataPermission, source); + List buildList = buildListData(list, orgId); + + Map> optionMap = buildOptionMap(orgId, list, buildList); + + + return PageUtils.setPageInfoWithOption(page, buildList, optionMap); + } + + public OpportunitySearchStatisticResponse searchStatistic(OpportunitySearchStatisticRequest request, String userId, String orgId, + DeptDataPermissionDTO deptDataPermission) { + OpportunitySearchStatisticResponse response = extOpportunityMapper.searchStatistic(request, orgId, userId, deptDataPermission); + return Optional.ofNullable(response).orElse(new OpportunitySearchStatisticResponse()); + } + + public Map> buildOptionMap(String orgId, List list, List buildList) { + // 处理自定义字段选项数据 + ModuleFormConfigDTO customerFormConfig = getFormConfig(orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, OpportunityListResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, moduleFieldValues); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(buildList, + OpportunityListResponse::getOwner, OpportunityListResponse::getOwnerName); + optionMap.put(BusinessModuleField.OPPORTUNITY_OWNER.getBusinessKey(), ownerFieldOption); + + // 联系人 + List contactFieldOption = moduleFormService.getBusinessFieldOption(buildList, + OpportunityListResponse::getContactId, OpportunityListResponse::getContactName); + if (CollectionUtils.isNotEmpty(contactFieldOption)) { + optionMap.put(BusinessModuleField.OPPORTUNITY_CONTACT.getBusinessKey(), contactFieldOption); + } + + List productOption = extProductMapper.getOptions(orgId); + if (CollectionUtils.isNotEmpty(productOption)) { + optionMap.put(BusinessModuleField.OPPORTUNITY_PRODUCTS.getBusinessKey(), productOption); + } + + return optionMap; + + } + + private ModuleFormConfigDTO getFormConfig(String orgId) { + return moduleFormCacheService.getBusinessFormConfig(FormKey.OPPORTUNITY.getKey(), orgId); + } + + public List buildListData(List list, String orgId) { + if (CollectionUtils.isEmpty(list)) { + return list; + } + List opportunityIds = list.stream().map(OpportunityListResponse::getId) + .collect(Collectors.toList()); + Map> opportunityFiledMap = opportunityFieldService.getResourceFieldMap(opportunityIds, true); + Map> fvMap = opportunityFieldService.setBusinessRefFieldValue(list, moduleFormService.getFlattenFormFields(FormKey.OPPORTUNITY.getKey(), orgId), opportunityFiledMap); + + List ownerIds = list.stream() + .map(OpportunityListResponse::getOwner) + .distinct() + .toList(); + + List followerIds = list.stream() + .map(OpportunityListResponse::getFollower) + .distinct() + .toList(); + List createUserIds = list.stream() + .map(OpportunityListResponse::getCreateUser) + .distinct() + .toList(); + List updateUserIds = list.stream() + .map(OpportunityListResponse::getUpdateUser) + .distinct() + .toList(); + List userIds = Stream.of(ownerIds, followerIds, createUserIds, updateUserIds) + .flatMap(Collection::stream) + .distinct() + .toList(); + Map userNameMap = baseService.getUserNameMap(userIds); + + List contactIds = list.stream() + .map(OpportunityListResponse::getContactId) + .distinct() + .toList(); + Map contactMap = baseService.getContactMap(contactIds); + + Map ownersDefaultRuleMap = opportunityRuleService.getOwnersDefaultRuleMap(ownerIds, orgId); + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + + List stageConfigList = extOpportunityStageConfigMapper.getStageConfigList(orgId); + Map endConfigMaps = stageConfigList.stream().filter(config -> + Strings.CI.equals(config.getType(), OpportunityStageType.END.name()) + ).collect(Collectors.toMap(StageConfigResponse::getId, Function.identity())); + + // 失败原因 + DictConfigDTO dictConf = dictService.getDictConf(DictModule.OPPORTUNITY_FAIL_RS.name(), orgId); + List dictList = dictConf.getDictList(); + Map dictMap = dictList.stream().collect(Collectors.toMap(Dict::getId, Dict::getName)); + + list.forEach(opportunityListResponse -> { + // 获取自定义字段 + List opportunityFields = fvMap.get(opportunityListResponse.getId()); + // 计算保留天数(成功失败阶段不计算) + opportunityListResponse.setReservedDays(endConfigMaps.containsKey(opportunityListResponse.getStage()) ? + null : opportunityRuleService.calcReservedDay(ownersDefaultRuleMap.get(opportunityListResponse.getOwner()), opportunityListResponse.getCreateTime())); + opportunityListResponse.setModuleFields(opportunityFields); + + opportunityListResponse.setFollowerName(userNameMap.get(opportunityListResponse.getFollower())); + opportunityListResponse.setCreateUserName(userNameMap.get(opportunityListResponse.getCreateUser())); + opportunityListResponse.setUpdateUserName(userNameMap.get(opportunityListResponse.getUpdateUser())); + opportunityListResponse.setOwnerName(userNameMap.get(opportunityListResponse.getOwner())); + opportunityListResponse.setContactName(contactMap.get(opportunityListResponse.getContactId())); + + UserDeptDTO userDeptDTO = userDeptMap.get(opportunityListResponse.getOwner()); + if (userDeptDTO != null) { + opportunityListResponse.setDepartmentId(userDeptDTO.getDeptId()); + opportunityListResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + + opportunityListResponse.setFailureReason(dictMap.get(opportunityListResponse.getFailureReason())); + }); + return baseService.setCreateAndUpdateUserName(list); + } + + + /** + * 新建商机 + * + * @param request + * @param operatorId + * @param orgId + * @return + */ + @OperationLog(module = LogModule.OPPORTUNITY_INDEX, type = LogType.ADD) + public Opportunity add(OpportunityAddRequest request, String operatorId, String orgId) { + productService.checkProductList(request.getProducts()); + List stageConfigList = extOpportunityStageConfigMapper.getStageConfigList(orgId); + Long nextPos = getNextPos(orgId, stageConfigList.getFirst().getId()); + Opportunity opportunity = new Opportunity(); + String id = IDGenerator.nextStr(); + opportunity.setId(id); + opportunity.setName(request.getName()); + opportunity.setCustomerId(request.getCustomerId()); + opportunity.setAmount(request.getAmount()); + opportunity.setPossible(request.getPossible()); + opportunity.setProducts(request.getProducts()); + opportunity.setOrganizationId(orgId); + opportunity.setStage(stageConfigList.getFirst().getId()); + opportunity.setPos(nextPos); + opportunity.setContactId(request.getContactId()); + opportunity.setOwner(request.getOwner()); + opportunity.setCreateTime(System.currentTimeMillis()); + opportunity.setCreateUser(operatorId); + opportunity.setUpdateTime(System.currentTimeMillis()); + opportunity.setUpdateUser(operatorId); + opportunity.setExpectedEndTime(request.getExpectedEndTime()); + opportunity.setFollower(request.getFollower()); + opportunity.setFollowTime(request.getFollowTime()); + if (StringUtils.isBlank(request.getOwner())) { + opportunity.setOwner(operatorId); + } + + //自定义字段 + opportunityFieldService.saveModuleField(opportunity, orgId, operatorId, request.getModuleFields(), false); + opportunityMapper.insert(opportunity); + + baseService.handleAddLogWithResourceName(opportunity, request.getModuleFields()); + + // 消息通知 + commonNoticeSendService.sendNotice(NotificationConstants.Module.OPPORTUNITY, + NotificationConstants.Event.BUSINESS_ADD, opportunity.getName(), operatorId, + orgId, List.of(opportunity.getOwner()), true); + return opportunity; + } + + private Long getNextPos(String orgId, String stage) { + Long pos = extOpportunityMapper.selectNextPos(orgId, stage); + return pos == null ? 1 : pos + 1; + } + + + /** + * 更新商机 + * + * @param request + * @param userId + * @param orgId + */ + @OperationLog(module = LogModule.OPPORTUNITY_INDEX, type = LogType.UPDATE, resourceId = "{#request.id}") + public Opportunity update(OpportunityUpdateRequest request, String userId, String orgId) { + Opportunity oldOpportunity = opportunityMapper.selectByPrimaryKey(request.getId()); + Optional.ofNullable(oldOpportunity).ifPresentOrElse(item -> { + Opportunity newOpportunity = BeanUtils.copyBean(new Opportunity(), item); + productService.checkProductList(request.getProducts()); + //更新商机 + Opportunity updateOpportunity = newOpportunity(newOpportunity, request, userId); + // 获取模块字段 + List originCustomerFields = opportunityFieldService.getModuleFieldValuesByResourceId(request.getId()); + if (BooleanUtils.isTrue(request.getAgentInvoke())) { + opportunityFieldService.updateModuleFieldByAgent(updateOpportunity, originCustomerFields, request.getModuleFields(), orgId, userId); + } else { + // 更新模块字段 + updateModuleField(updateOpportunity, request.getModuleFields(), orgId, userId); + } + extOpportunityMapper.updateIncludeNullById(updateOpportunity); + baseService.handleUpdateLog(oldOpportunity, newOpportunity, originCustomerFields, request.getModuleFields(), oldOpportunity.getId(), oldOpportunity.getName()); + }, () -> { + throw new GenericException("opportunity_not_found"); + }); + return oldOpportunity; + } + + + private Opportunity newOpportunity(Opportunity item, OpportunityUpdateRequest request, String userId) { + item.setName(request.getName()); + item.setCustomerId(request.getCustomerId()); + item.setAmount(request.getAmount()); + item.setPossible(request.getPossible()); + item.setProducts(request.getProducts()); + item.setContactId(request.getContactId()); + item.setOwner(request.getOwner()); + item.setUpdateTime(System.currentTimeMillis()); + item.setUpdateUser(userId); + item.setExpectedEndTime(request.getExpectedEndTime()); + return item; + } + + + private void updateModuleField(Opportunity opportunity, List moduleFields, String orgId, String userId) { + if (moduleFields == null) { + // 如果为 null,则不更新 + return; + } + // 先删除 + opportunityFieldService.deleteByResourceId(opportunity.getId()); + // 再保存 + opportunityFieldService.saveModuleField(opportunity, orgId, userId, moduleFields, true); + } + + + /** + * 删除商机 + * + * @param id + */ + @OperationLog(module = LogModule.OPPORTUNITY_INDEX, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id, String userId, String orgId) { + Opportunity opportunity = opportunityMapper.selectByPrimaryKey(id); + Optional.ofNullable(opportunity).ifPresentOrElse(item -> { + opportunityMapper.deleteByPrimaryKey(opportunity.getId()); + opportunityFieldService.deleteByResourceId(opportunity.getId()); + }, () -> { + throw new GenericException("opportunity_not_found"); + }); + // 添加日志上下文 + OperationLogContext.setResourceName(opportunity.getName()); + + commonNoticeSendService.sendNotice(NotificationConstants.Module.OPPORTUNITY, + NotificationConstants.Event.BUSINESS_DELETED, opportunity.getName(), userId, + orgId, List.of(opportunity.getOwner()), true); + } + + + /** + * 商机转移 (与商机阶段无关, 都可转移) + * @param request 请求参数 + * @param userId 用户ID + * @param orgId 组织ID + */ + public void transfer(OpportunityTransferRequest request, String userId, String orgId) { + List opportunityList = opportunityMapper.selectByIds(request.getIds()); + if (CollectionUtils.isEmpty(opportunityList)) { + return; + } + List ids = opportunityList.stream().map(Opportunity::getId).toList(); + SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); + ExtOpportunityMapper batchUpdateMapper = sqlSession.getMapper(ExtOpportunityMapper.class); + for (int i = 0; i < ids.size(); i++) { + batchUpdateMapper.transfer(request.getOwner(), userId, ids.get(i), System.currentTimeMillis()); + } + sqlSession.flushStatements(); + SqlSessionUtils.closeSqlSession(sqlSession, sqlSessionFactory); + + // 记录日志 + List logs = new ArrayList<>(); + opportunityList.forEach(opportunity -> { + Customer originCustomer = new Customer(); + originCustomer.setOwner(opportunity.getOwner()); + Customer modifieCustomer = new Customer(); + modifieCustomer.setOwner(request.getOwner()); + LogDTO logDTO = new LogDTO(orgId, opportunity.getId(), userId, LogType.UPDATE, LogModule.OPPORTUNITY_INDEX, opportunity.getName()); + logDTO.setOriginalValue(originCustomer); + logDTO.setModifiedValue(modifieCustomer); + logs.add(logDTO); + + extCustomerContactMapper.updateContactById(opportunity.getContactId(), request.getOwner()); + }); + + logService.batchAdd(logs); + sendTransferNotice(opportunityList, request.getOwner(), userId, orgId); + } + + private void sendTransferNotice(List opportunityList, String toUser, String userId, String orgId) { + opportunityList.forEach(opportunity -> commonNoticeSendService.sendNotice(NotificationConstants.Module.OPPORTUNITY, + NotificationConstants.Event.BUSINESS_TRANSFER, opportunity.getName(), userId, + orgId, List.of(toUser), true)); + } + + /** + * 批量删除商机 + * + * @param ids + * @param userId + */ + public void batchDelete(List ids, String userId, String orgId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(Opportunity::getId, ids); + wrapper.nq(Opportunity::getStage, SUCCESS); + List opportunityList = opportunityMapper.selectListByLambda(wrapper); + List toDoIds = opportunityList.stream().map(Opportunity::getId).toList(); + if (CollectionUtils.isEmpty(toDoIds)) { + return; + } + opportunityMapper.deleteByIds(toDoIds); + opportunityFieldService.deleteByResourceIds(toDoIds); + List logs = new ArrayList<>(); + opportunityList.forEach(opportunity -> { + LogDTO logDTO = new LogDTO(opportunity.getOrganizationId(), opportunity.getId(), userId, LogType.DELETE, LogModule.OPPORTUNITY_INDEX, opportunity.getName()); + logDTO.setOriginalValue(opportunity); + logs.add(logDTO); + }); + logService.batchAdd(logs); + + // 消息通知 + opportunityList.forEach(opportunity -> + commonNoticeSendService.sendNotice(NotificationConstants.Module.OPPORTUNITY, + NotificationConstants.Event.BUSINESS_DELETED, opportunity.getName(), userId, + orgId, List.of(opportunity.getOwner()), true) + ); + } + + + public OpportunityDetailResponse getWithDataPermissionCheck(String id, String userId, String orgId) { + OpportunityDetailResponse getResponse = get(id); + if (getResponse == null) { + throw new GenericException(Translator.get("opportunity_not_found")); + } + dataScopeService.hasDataPermission(userId, orgId, getResponse.getOwner(), PermissionConstants.OPPORTUNITY_MANAGEMENT_READ); + return getResponse; + } + + + /** + * @param id 商机ID + * @return 商机详情 + */ + public OpportunityDetailResponse get(String id) { + OpportunityDetailResponse response = extOpportunityMapper.getDetail(id); + if (response == null) { + return null; + } + List fieldValueList = opportunityFieldService.getModuleFieldValuesByResourceId(id); + fieldValueList = opportunityFieldService.setBusinessRefFieldValue(List.of(response), + moduleFormService.getFlattenFormFields(FormKey.OPPORTUNITY.getKey(), response.getOrganizationId()), new HashMap<>(Map.of(id, fieldValueList))).get(id); + response.setModuleFields(fieldValueList); + List userIds = Stream.of(Arrays.asList(response.getCreateUser(), response.getUpdateUser(), response.getOwner(), response.getFollower())) + .flatMap(Collection::stream) + .distinct() + .toList(); + Map userNameMap = baseService.getUserNameMap(userIds); + Map contactMap = baseService.getContactMap(StringUtils.isEmpty(response.getContactId()) ? null : List.of(response.getContactId())); + Map ownersDefaultRuleMap = opportunityRuleService.getOwnersDefaultRuleMap(List.of(response.getOwner()), response.getOrganizationId()); + Map userDeptMap = baseService.getUserDeptMapByUserIds(List.of(response.getOwner()), response.getOrganizationId()); + + response.setCreateUserName(userNameMap.get(response.getCreateUser())); + response.setUpdateUserName(userNameMap.get(response.getUpdateUser())); + response.setOwnerName(userNameMap.get(response.getOwner())); + response.setContactName(contactMap.get(response.getContactId())); + response.setFollowerName(userNameMap.get(response.getFollower())); + + List stageConfigList = extOpportunityStageConfigMapper.getStageConfigList(response.getOrganizationId()); + Map endConfigMaps = stageConfigList.stream().filter(config -> + Strings.CI.equals(config.getType(), OpportunityStageType.END.name()) + ).collect(Collectors.toMap(StageConfigResponse::getId, Function.identity())); + + // 计算保留天数(成功失败阶段不计算) + response.setReservedDays(endConfigMaps.containsKey(response.getStage()) ? + null : opportunityRuleService.calcReservedDay(ownersDefaultRuleMap.get(response.getOwner()), response.getCreateTime())); + UserDeptDTO userDeptDTO = userDeptMap.get(response.getOwner()); + if (userDeptDTO != null) { + response.setDepartmentId(userDeptDTO.getDeptId()); + response.setDepartmentName(userDeptDTO.getDeptName()); + } + + // 失败原因 + DictConfigDTO dictConf = dictService.getDictConf(DictModule.OPPORTUNITY_FAIL_RS.name(), response.getOrganizationId()); + List dictList = dictConf.getDictList(); + Map dictMap = dictList.stream().collect(Collectors.toMap(Dict::getId, Dict::getName)); + response.setFailureReason(dictMap.get(response.getFailureReason())); + + + ModuleFormConfigDTO customerFormConfig = getFormConfig(response.getOrganizationId()); + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, fieldValueList); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(response, + OpportunityDetailResponse::getOwner, OpportunityDetailResponse::getOwnerName); + optionMap.put(BusinessModuleField.CUSTOMER_OWNER.getBusinessKey(), ownerFieldOption); + + // 联系人 + List contactFieldOption = moduleFormService.getBusinessFieldOption(response, + OpportunityDetailResponse::getContactId, OpportunityDetailResponse::getContactName); + if (CollectionUtils.isNotEmpty(contactFieldOption)) { + optionMap.put(BusinessModuleField.OPPORTUNITY_CONTACT.getBusinessKey(), contactFieldOption); + } + + List customerOption = moduleFormService.getBusinessFieldOption(response, + OpportunityDetailResponse::getCustomerId, OpportunityDetailResponse::getCustomerName); + optionMap.put(BusinessModuleField.OPPORTUNITY_CUSTOMER_NAME.getBusinessKey(), customerOption); + + List productOption = extProductMapper.getOptions(response.getOrganizationId()); + if (CollectionUtils.isNotEmpty(productOption)) { + optionMap.put(BusinessModuleField.OPPORTUNITY_PRODUCTS.getBusinessKey(), productOption); + } + + response.setOptionMap(optionMap); + + // 附件信息 + response.setAttachmentMap(moduleFormService.getAttachmentMap(customerFormConfig, fieldValueList)); + + return response; + } + + /** + * 获取商机详情 (⚠️反射调用; 勿修改入参, 返回, 方法名!) + * @param id 商机ID + * @return 商机详情 + */ + public OpportunityDetailResponse getSimple(String id) { + OpportunityDetailResponse response = extOpportunityMapper.getDetail(id); + if (response == null) { + return null; + } + List fvs = opportunityFieldService.getModuleFieldValuesByResourceId(id); + response.setModuleFields(fvs); + return response; + } + + /** + * 批量获取商机详情 (用于数据源批量查询优化) + * @param ids 商机ID集合 + * @return 商机详情列表 + */ + public List batchGetSimpleByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return Collections.emptyList(); + } + // 批量查询资源基本信息 + List opportunities = opportunityMapper.selectByIds(ids); + if (CollectionUtils.isEmpty(opportunities)) { + return Collections.emptyList(); + } + // 批量查询自定义字段值 + Map> fieldValueMap = opportunityFieldService.getResourceFieldMap(ids, true); + + // 组装结果 + return opportunities.stream().map(opportunity -> { + OpportunityDetailResponse response = BeanUtils.copyBean(new OpportunityDetailResponse(), opportunity); + response.setModuleFields(fieldValueMap.get(opportunity.getId())); + return response; + }).toList(); + } + + + /** + * 标记商机阶段 + * + * @param request + * @param orgId + */ + @OperationLog(module = LogModule.OPPORTUNITY_INDEX, type = LogType.UPDATE, resourceId = "{#request.id}") + public void updateStage(OpportunityStageRequest request, String orgId) { + final Opportunity oldOpportunity = opportunityMapper.selectByPrimaryKey(request.getId()); + if (oldOpportunity == null) { + throw new GenericException(Translator.get("opportunity_not_found")); + } + + final List stageConfigList = extOpportunityStageConfigMapper.getStageConfigList(orgId); + + final Optional successOpt = stageConfigList.stream() + .filter(cfg -> Strings.CI.equals(cfg.getType(), OpportunityStageType.END.name()) + && Strings.CI.equals(cfg.getRate(), "100")) + .findFirst(); + + final Optional failOpt = stageConfigList.stream() + .filter(cfg -> Strings.CI.equals(cfg.getType(), OpportunityStageType.END.name()) + && Strings.CI.equals(cfg.getRate(), "0")) + .findFirst(); + + final Map stageMap = stageConfigList.stream() + .collect(Collectors.toMap(StageConfigResponse::getId, StageConfigResponse::getName)); + + final Opportunity newOpportunity = new Opportunity(); + newOpportunity.setId(request.getId()); + newOpportunity.setLastStage(oldOpportunity.getStage()); + newOpportunity.setStage(request.getStage()); + + final boolean isSuccessStage = successOpt.map(cfg -> Strings.CI.equals(request.getStage(), cfg.getId())).orElse(false); + final boolean isFailStage = failOpt.map(cfg -> Strings.CI.equals(request.getStage(), cfg.getId())).orElse(false); + + if (isSuccessStage || isFailStage) { + newOpportunity.setActualEndTime(System.currentTimeMillis()); + } + if (isFailStage) { + newOpportunity.setFailureReason(request.getFailureReason()); + } + + final Long nextPos = getNextPos(oldOpportunity.getOrganizationId(), request.getStage()); + newOpportunity.setPos(nextPos); + + opportunityMapper.update(newOpportunity); + + final Map originalVal = new HashMap<>(1); + originalVal.put("stage", stageMap.get(oldOpportunity.getStage())); + final Map modifiedVal = new HashMap<>(1); + modifiedVal.put("stage", stageMap.get(request.getStage())); + + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceName(oldOpportunity.getName()) + .originalValue(originalVal) + .modifiedValue(modifiedVal) + .build() + ); + } + + public ResourceTabEnableDTO getTabEnableConfig(String userId, String orgId) { + List rolePermissions = permissionCache.getRolePermissions(userId, orgId); + return PermissionUtils.getTabEnableConfig(userId, PermissionConstants.OPPORTUNITY_MANAGEMENT_READ, rolePermissions); + } + + public CustomerContactListAllResponse getContactList(String opportunityId, String orgId) { + Opportunity opportunity = opportunityMapper.selectByPrimaryKey(opportunityId); + if (opportunity == null) { + throw new GenericException(Translator.get("opportunity_not_found")); + } + return customerContactService.getOpportunityContactList(opportunity.getContactId(), orgId); + } + + public String getOpportunityName(String id) { + Opportunity opportunity = opportunityMapper.selectByPrimaryKey(id); + return Optional.ofNullable(opportunity).map(Opportunity::getName).orElse(null); + } + + public List getOpportunityListByNames(List names) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.in(Opportunity::getName, names); + return opportunityMapper.selectListByLambda(lambdaQueryWrapper); + } + + public String getOpportunityNameByIds(List ids) { + List opportunityList = opportunityMapper.selectByIds(ids); + if (CollectionUtils.isNotEmpty(opportunityList)) { + List names = opportunityList.stream().map(Opportunity::getName).toList(); + return String.join(",", JSON.parseArray(JSON.toJSONString(names), String.class)); + } + return StringUtils.EMPTY; + } + + /** + * 下载导入的模板 + * + * @param response 响应 + */ + public void downloadImportTpl(HttpServletResponse response, String currentOrg) { + new EasyExcelExporter() + .exportMultiSheetTplWithSharedHandler(response, moduleFormService.getCustomImportHeadsNoRef(FormKey.OPPORTUNITY.getKey(), currentOrg), + Translator.get("opportunity.import_tpl.name"), Translator.get(SheetKey.DATA), Translator.get(SheetKey.COMMENT), + new CustomTemplateWriteHandler(moduleFormService.getAllCustomImportFields(FormKey.OPPORTUNITY.getKey(), currentOrg)), new CustomHeadColWidthStyleStrategy()); + } + + /** + * 导入检查 + * + * @param file 导入文件 + * @param currentOrg 当前组织 + * @return 导入检查信息 + */ + public ImportResponse importPreCheck(MultipartFile file, String currentOrg) { + if (file == null) { + throw new GenericException(Translator.get("file_cannot_be_null")); + } + return checkImportExcel(file, currentOrg); + } + + /** + * 商机导入 + * + * @param file 导入文件 + * @param currentOrg 当前组织 + * @param currentUser 当前用户 + * @return 导入返回信息 + */ + public ImportResponse realImport(MultipartFile file, String currentOrg, String currentUser) { + try { + List stageConfigList = extOpportunityStageConfigMapper.getStageConfigList(currentOrg); + + List fields = moduleFormService.getAllFields(FormKey.OPPORTUNITY.getKey(), currentOrg); + long nextPos = getNextPos(currentOrg, stageConfigList.getFirst().getId()); + CustomImportAfterDoConsumer afterDo = (opportunities, opportunityFields, opportunityFieldBlobs) -> { + List logs = new ArrayList<>(); + for (int i = 0; i < opportunities.size(); i++) { + Opportunity opportunity = opportunities.get(i); + opportunity.setStage(stageConfigList.getFirst().getId()); + opportunity.setPos(nextPos + i); + logs.add(new LogDTO(currentOrg, opportunity.getId(), currentUser, LogType.ADD, LogModule.OPPORTUNITY_INDEX, opportunity.getName())); + // 消息通知(异步) + commonNoticeSendService.sendNotice(NotificationConstants.Module.OPPORTUNITY, + NotificationConstants.Event.BUSINESS_ADD, opportunity.getName(), currentUser, + currentOrg, List.of(opportunity.getOwner()), true); + } + opportunityMapper.batchInsert(opportunities); + opportunityFieldMapper.batchInsert(opportunityFields.stream().map(field -> BeanUtils.copyBean(new OpportunityField(), field)).toList()); + opportunityFieldBlobMapper.batchInsert(opportunityFieldBlobs.stream().map(field -> BeanUtils.copyBean(new OpportunityFieldBlob(), field)).toList()); + // record logs + logService.batchAdd(logs); + }; + CustomFieldImportEventListener eventListener = new CustomFieldImportEventListener<>(fields, Opportunity.class, currentOrg, currentUser, + "opportunity_field", afterDo, 2000, null, null); + FastExcelFactory.read(file.getInputStream(), eventListener).headRowNumber(1).ignoreEmptyRow(true).sheet().doRead(); + return ImportResponse.builder().errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccessCount()).failCount(eventListener.getErrList().size()).build(); + } catch (Exception e) { + log.error("opportunity import error: ", e.getMessage()); + throw new GenericException(e.getMessage()); + } + } + + /** + * 检查导入的文件 + * + * @param file 文件 + * @param currentOrg 当前组织 + * @return 检查信息 + */ + private ImportResponse checkImportExcel(MultipartFile file, String currentOrg) { + try { + List fields = moduleFormService.getAllCustomImportFields(FormKey.OPPORTUNITY.getKey(), currentOrg); + CustomFieldCheckEventListener eventListener = new CustomFieldCheckEventListener(fields, "opportunity", "opportunity_field", currentOrg); + FastExcelFactory.read(file.getInputStream(), eventListener).headRowNumber(1).ignoreEmptyRow(true).sheet().doRead(); + return ImportResponse.builder().errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccess()).failCount(eventListener.getErrList().size()).build(); + } catch (Exception e) { + log.error("opportunity import pre-check error: {}", e.getMessage()); + throw new GenericException(e.getMessage()); + } + } + + public void batchUpdate(ResourceBatchEditRequest request, String userId, String organizationId) { + BaseField field = opportunityFieldService.getAndCheckField(request.getFieldId(), organizationId); + if (Strings.CS.equals(field.getBusinessKey(), BusinessModuleField.OPPORTUNITY_OWNER.getBusinessKey())) { + // 修改负责人,走批量转移接口 + OpportunityTransferRequest batchTransferRequest = new OpportunityTransferRequest(); + batchTransferRequest.setIds(request.getIds()); + batchTransferRequest.setOwner(request.getFieldValue().toString()); + transfer(batchTransferRequest, userId, organizationId); + return; + } + + if (Strings.CS.equals(field.getBusinessKey(), BusinessModuleField.OPPORTUNITY_PRODUCTS.getBusinessKey())) { + productService.checkProductList((List) request.getFieldValue()); + } + + List originOpportunities = opportunityMapper.selectByIds(request.getIds()); + + opportunityFieldService.batchUpdate(request, field, originOpportunities, Opportunity.class, LogModule.OPPORTUNITY_INDEX, extOpportunityMapper::batchUpdate, userId, organizationId); + } + + + /** + * 阶段看板拖拽排序 + * + * @param request + * @param userId + */ + public void sort(StageSortRequest request, String userId) { + //拖拽节点 + Opportunity opportunity = opportunityMapper.selectByPrimaryKey(request.getDragNodeId()); + if (opportunity == null) { + throw new GenericException(Translator.get("opportunity_not_found")); + } + Long pos = DEFAULT_POS; + if (StringUtils.isNotBlank(request.getDropNodeId())) { + //放入节点 + Opportunity dropNode = opportunityMapper.selectByPrimaryKey(request.getDropNodeId()); + pos = dropNode.getPos(); + if (request.getDropPosition() == -1) { + + extOpportunityMapper.moveUpStageOpportunity(pos, request.getStage(), DEFAULT_POS); + pos = pos + 1; + } else { + extOpportunityMapper.moveDownStageOpportunity(pos, request.getStage(), DEFAULT_POS); + } + } + Opportunity dragOpportunity = new Opportunity(); + dragOpportunity.setId(request.getDragNodeId()); + dragOpportunity.setPos(pos); + dragOpportunity.setStage(request.getStage()); + dragOpportunity.setLastStage(opportunity.getStage()); + dragOpportunity.setUpdateUser(userId); + dragOpportunity.setUpdateTime(System.currentTimeMillis()); + opportunityMapper.updateById(dragOpportunity); + } + + public List chart(ChartAnalysisRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { + ModuleFormConfigDTO formConfig = getFormConfig(orgId); + formConfig.getFields().addAll(BaseChartService.getChartBaseFields()); + formConfig.getFields().addAll(getChartFields(orgId)); + ChartAnalysisDbRequest chartAnalysisDbRequest = ConditionFilterUtils.parseChartAnalysisRequest(request, formConfig); + List chartResults = extOpportunityMapper.chart(chartAnalysisDbRequest, userId, orgId, deptDataPermission); + return baseChartService.translateAxisName(formConfig, chartAnalysisDbRequest, chartResults); + } + + public List getChartFields(String orgId) { + SelectField stageField = new SelectField(); + stageField.setType(FieldType.SELECT.name()); + stageField.setId("stage"); + stageField.setBusinessKey("stage"); + List options = extOpportunityStageConfigMapper.getStageConfigList(orgId) + .stream() + .map(config -> { + OptionProp optionDTO = new OptionProp(); + optionDTO.setLabel(config.getName()); + optionDTO.setValue(config.getId()); + return optionDTO; + }).collect(Collectors.toList()); + stageField.setOptions(options); + + return List.of(stageField); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityStageService.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityStageService.java new file mode 100644 index 0000000..72f31fb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityStageService.java @@ -0,0 +1,247 @@ +package cn.cordys.crm.opportunity.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.dto.stage.StageRollBackRequest; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.opportunity.constants.OpportunityStageType; +import cn.cordys.crm.opportunity.domain.OpportunityStageConfig; +import cn.cordys.crm.opportunity.dto.request.OpportunityStageAddRequest; +import cn.cordys.crm.opportunity.dto.request.StageUpdateRequest; +import cn.cordys.crm.opportunity.dto.response.StageConfigListResponse; +import cn.cordys.crm.opportunity.dto.response.StageConfigResponse; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityMapper; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityStageConfigMapper; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@Transactional(rollbackFor = Exception.class) +public class OpportunityStageService { + + public static final Long DEFAULT_POS = 1L; + @Resource + private BaseMapper opportunityStageConfigMapper; + @Resource + private ExtOpportunityStageConfigMapper extOpportunityStageConfigMapper; + @Resource + private ExtOpportunityMapper extOpportunityMapper; + + /** + * 商机阶段配置列表 + * + * @param orgId + * @return + */ + public StageConfigListResponse getStageConfigList(String orgId) { + StageConfigListResponse stageConfigListResponse = new StageConfigListResponse(); + List stageConfigList = extOpportunityStageConfigMapper.getStageConfigList(orgId); + buildList(stageConfigList, stageConfigListResponse); + return stageConfigListResponse; + } + + private void buildList(List stageConfigList, StageConfigListResponse response) { + response.setStageConfigList(stageConfigList); + if (CollectionUtils.isNotEmpty(stageConfigList)) { + var first = stageConfigList.getFirst(); + response.setEndRollBack(first.getEndRollBack()); + response.setAfootRollBack(first.getAfootRollBack()); + stageConfigList.forEach(sc -> sc.setStageHasData(extOpportunityMapper.countByStage(sc.getId()) > 0)); + } + } + + /** + * 添加商机配置 + * + * @param request + * @param userId + * @param orgId + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.ADD) + public String addStageConfig(OpportunityStageAddRequest request, String userId, String orgId) { + checkConfigCount(orgId); + Long pos = DEFAULT_POS; + Boolean afootRollBack = true; + Boolean endRollBack = false; + //源节点 + OpportunityStageConfig target = opportunityStageConfigMapper.selectByPrimaryKey(request.getTargetId()); + if (target != null) { + pos = target.getPos(); + //target正常不会为空 + if (request.getDropPosition() == -1) { + extOpportunityStageConfigMapper.moveUpStageConfig(pos, orgId, DEFAULT_POS); + } else { + extOpportunityStageConfigMapper.moveDownStageConfig(pos, orgId, DEFAULT_POS); + pos = pos + 1; + } + afootRollBack = target.getAfootRollBack(); + endRollBack = target.getEndRollBack(); + } + + OpportunityStageConfig stageConfig = new OpportunityStageConfig(); + stageConfig.setId(IDGenerator.nextStr()); + stageConfig.setName(request.getName()); + stageConfig.setType(request.getType()); + stageConfig.setRate(request.getRate()); + stageConfig.setAfootRollBack(afootRollBack); + stageConfig.setEndRollBack(endRollBack); + stageConfig.setPos(pos); + stageConfig.setOrganizationId(orgId); + stageConfig.setCreateUser(userId); + stageConfig.setUpdateUser(userId); + stageConfig.setCreateTime(System.currentTimeMillis()); + stageConfig.setUpdateTime(System.currentTimeMillis()); + opportunityStageConfigMapper.insert(stageConfig); + + OperationLogContext.setContext(LogContextInfo.builder() + .modifiedValue(stageConfig) + .resourceId(stageConfig.getId()) + .resourceName(Translator.get("opportunity_stage_setting").concat(":").concat(request.getName())) + .build()); + + return stageConfig.getId(); + } + + + /** + * 商机阶段配置校验 + * + * @param orgId + */ + private void checkConfigCount(String orgId) { + if (extOpportunityStageConfigMapper.countStageConfig(orgId) >= 10) { + throw new GenericException(Translator.get("opportunity_stage_config_list")); + } + } + + + /** + * 删除 + * + * @param id + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id, String orgId) { + OpportunityStageConfig stageConfig = deletePreCheck(id, orgId); + opportunityStageConfigMapper.deleteByPrimaryKey(id); + // 设置操作对象 + OperationLogContext.setResourceName(Translator.get("opportunity_stage_setting").concat(":").concat(stageConfig.getName())); + } + + private OpportunityStageConfig deletePreCheck(String id, String orgId) { + OpportunityStageConfig stageConfig = opportunityStageConfigMapper.selectByPrimaryKey(id); + if (stageConfig == null) { + throw new GenericException(Translator.get("opportunity_stage_delete")); + } + + if (extOpportunityStageConfigMapper.countByType(OpportunityStageType.AFOOT.name(), orgId) <= 1) { + throw new GenericException(Translator.get("opportunity_stage_at_least_one")); + } + return stageConfig; + } + + + /** + * 更新商机阶段回退设置 + * + * @param request + * @param orgId + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE) + public void updateRollBack(StageRollBackRequest request, String orgId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(OpportunityStageConfig::getOrganizationId, orgId); + List stageConfigList = opportunityStageConfigMapper.selectListByLambda(wrapper); + extOpportunityStageConfigMapper.updateRollBack(request, orgId); + + Map originalVal = new HashMap<>(1); + originalVal.put("afootRollBack", Translator.get("log.enable.".concat(stageConfigList.getFirst().getAfootRollBack().toString()))); + originalVal.put("endRollBack", Translator.get("log.enable.".concat(stageConfigList.getFirst().getEndRollBack().toString()))); + Map modifiedVal = new HashMap<>(1); + modifiedVal.put("afootRollBack", Translator.get("log.enable.".concat(request.getAfootRollBack().toString()))); + modifiedVal.put("endRollBack", Translator.get("log.enable.".concat(request.getEndRollBack().toString()))); + OperationLogContext.setContext(LogContextInfo.builder() + .originalValue(originalVal) + .resourceName(Translator.get("opportunity_stage_setting")) + .modifiedValue(modifiedVal) + .resourceId(orgId) + .build()); + } + + + /** + * 更新配置 + * + * @param request + * @param userId + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE) + public void update(StageUpdateRequest request, String userId) { + OpportunityStageConfig oldStageConfig = opportunityStageConfigMapper.selectByPrimaryKey(request.getId()); + if (oldStageConfig == null) { + throw new GenericException(Translator.get("opportunity_stage_not_exist")); + } + extOpportunityStageConfigMapper.updateStageConfig(request, userId); + + Map originalVal = new HashMap<>(1); + originalVal.put("stage", oldStageConfig.getName()); + originalVal.put("rate", oldStageConfig.getRate()); + Map modifiedVal = new HashMap<>(1); + modifiedVal.put("stage", request.getName()); + modifiedVal.put("rate", request.getRate()); + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(request.getId()) + .resourceName(Translator.get("opportunity_stage_setting")) + .originalValue(originalVal) + .modifiedValue(modifiedVal) + .build() + ); + } + + + /** + * 排序 + * + * @param ids + * @param orgId + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE) + public void sort(List ids, String orgId) { + List oldStageConfigList = extOpportunityStageConfigMapper.getStageConfigList(orgId); + List oldNames = oldStageConfigList.stream().map(StageConfigResponse::getName).toList(); + + for (int i = 0; i < ids.size(); i++) { + extOpportunityStageConfigMapper.updatePos(ids.get(i), (long) (i + 1)); + } + + List newStageConfigList = extOpportunityStageConfigMapper.getStageConfigList(orgId); + List newNames = newStageConfigList.stream().map(StageConfigResponse::getName).toList(); + + Map> originalVal = new HashMap<>(1); + originalVal.put("stageSort", oldNames); + Map> modifiedVal = new HashMap<>(1); + modifiedVal.put("stageSort", newNames); + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(orgId) + .resourceName(Translator.get("opportunity_stage_setting")) + .originalValue(originalVal) + .modifiedValue(modifiedVal) + .build() + ); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/controller/OrderController.java b/backend/crm/src/main/java/cn/cordys/crm/order/controller/OrderController.java new file mode 100644 index 0000000..da27430 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/controller/OrderController.java @@ -0,0 +1,156 @@ +package cn.cordys.crm.order.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.FormKeyConstants; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.common.dto.condition.BaseCondition; +import cn.cordys.common.dto.stage.StageSortRequest; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.CsPermission; +import cn.cordys.common.permission.CsBatchPermission; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.order.domain.Order; +import cn.cordys.crm.order.dto.request.OrderAddRequest; +import cn.cordys.crm.order.dto.request.OrderPageRequest; +import cn.cordys.crm.order.dto.request.OrderStageRequest; +import cn.cordys.crm.order.dto.request.OrderUpdateRequest; +import cn.cordys.crm.order.dto.response.OrderGetResponse; +import cn.cordys.crm.order.dto.response.OrderListResponse; +import cn.cordys.crm.order.dto.response.OrderStatisticResponse; +import cn.cordys.crm.order.service.OrderService; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.BatchAffectReasonResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + + +@Tag(name = "订单") +@RestController +@RequestMapping("/order") +public class OrderController { + @Resource + private OrderService orderService; + @Resource + private DataScopeService dataScopeService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + + @GetMapping("/module/form") + @CsPermission(PermissionConstants.ORDER_READ) + @Operation(summary = "获取表单配置") + public ModuleFormConfigDTO getModuleFormConfig() { + return moduleFormCacheService.getBusinessFormConfig(FormKey.ORDER.getKey(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/add") + @CsPermission(PermissionConstants.ORDER_ADD) + @Operation(summary = "创建") + public Order add(@Validated @RequestBody OrderAddRequest request) { + return orderService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @CsPermission(value = PermissionConstants.ORDER_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.ORDER) + @Operation(summary = "更新") + public Order update(@Validated @RequestBody OrderUpdateRequest request) { + return orderService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update/stage") + @CsPermission(value = PermissionConstants.ORDER_UPDATE, resourceId = "{#request.id}", formType = FormKeyConstants.ORDER) + @Operation(summary = "更新订单阶段") + public void updateStage(@Validated @RequestBody OrderStageRequest request) { + orderService.updateStage(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch/update") + @CsBatchPermission(value = PermissionConstants.ORDER_UPDATE, resourceId = "{#request.ids}", formType = FormKeyConstants.ORDER) + @Operation(summary = "批量更新订单") + public BatchAffectReasonResponse batchUpdate(@Validated @RequestBody ResourceBatchEditRequest request) { + return orderService.batchUpdate(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @CsPermission(value = PermissionConstants.ORDER_DELETE, resourceId = "{#id}", formType = FormKeyConstants.ORDER) + @Operation(summary = "删除") + public void delete(@PathVariable("id") String id) { + orderService.delete(id, SessionUtils.getUserId()); + } + + @GetMapping("/get/snapshot/{id}") + @CsPermission(value = PermissionConstants.ORDER_READ, resourceId = "{#id}", formType = FormKeyConstants.ORDER) + @Operation(summary = "获取详情快照") + public OrderGetResponse getSnapshot(@PathVariable("id") String id) { + return orderService.getSnapshot(id, OrganizationContext.getOrganizationId()); + } + + @GetMapping("/get/{id}") + @CsPermission(value = PermissionConstants.ORDER_READ, resourceId = "{#id}", formType = FormKeyConstants.ORDER) + @Operation(summary = "详情") + public OrderGetResponse get(@PathVariable("id") String id) { + return orderService.get(id, OrganizationContext.getOrganizationId()); + } + + @GetMapping("/module/form/snapshot/{id}") + @CsPermission(value = PermissionConstants.ORDER_READ, resourceId = "{#id}", formType = FormKeyConstants.ORDER) + @Operation(summary = "获取表单快照配置") + public ModuleFormConfigDTO getFormSnapshot(@PathVariable("id") String id) { + return orderService.getFormSnapshot(id, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/page") + @CsPermission(PermissionConstants.ORDER_READ) + @Operation(summary = "列表") + public PagerWithOption> list(@Validated @RequestBody OrderPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.ORDER.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.ORDER_READ); + return orderService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission, false); + } + + @GetMapping("/tab") + @CsPermission(PermissionConstants.ORDER_READ) + @Operation(summary = "tab是否显示") + public ResourceTabEnableDTO getTabEnableConfig() { + return orderService.getTabEnableConfig(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/download/{id}") + @CsPermission(value = PermissionConstants.ORDER_DOWNLOAD, resourceId = "{#id}", formType = FormKeyConstants.ORDER) + @Operation(summary = "下载订单日志记录") + public void download(@PathVariable("id") String id) { + orderService.download(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/statistic") + @CsPermission(PermissionConstants.ORDER_READ) + @Operation(summary = "订单统计") + public OrderStatisticResponse searchStatistic(@Validated @RequestBody BaseCondition request) { + ConditionFilterUtils.parseCondition(request, FormKey.ORDER.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.ORDER_READ); + return orderService.searchStatistic(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + + @PostMapping("/sort") + @Operation(summary = "订单看板拖拽排序") + public void sortModule(@Validated @RequestBody StageSortRequest request) { + orderService.sort(request, SessionUtils.getUserId()); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/controller/OrderStageController.java b/backend/crm/src/main/java/cn/cordys/crm/order/controller/OrderStageController.java new file mode 100644 index 0000000..80851a2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/controller/OrderStageController.java @@ -0,0 +1,92 @@ +package cn.cordys.crm.order.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.stage.*; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.order.service.OrderStageService; +import cn.cordys.crm.system.service.StageAdvancedConfigService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "订单状态流设置") +@RestController +@RequestMapping("/order/stage") +public class OrderStageController { + + @Resource + private OrderStageService orderStageService; + @Resource + private StageAdvancedConfigService stageAdvancedConfigService; + + @GetMapping("/get") + @Operation(summary = "订单状态配置列表") + public StageConfigsResponse getStageConfigList() { + return orderStageService.getStageConfigList(OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/add") + @Operation(summary = "添加订单状态流") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public String add(@RequestBody StageAddRequest request) { + return orderStageService.addStageConfig(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @Operation(summary = "删除订单状态流") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void delete(@PathVariable("id") @Validated String id) { + orderStageService.delete(id, OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/update-rollback") + @Operation(summary = "订单状态流回退设置") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void update(@Validated @RequestBody StageRollBackRequest request) { + orderStageService.updateRollBack(request, OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/update") + @Operation(summary = "更新订单阶段配置") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void update(@Validated @RequestBody StageUpdateRequest request) { + orderStageService.update(request, SessionUtils.getUserId()); + } + + + @PostMapping("/sort") + @Operation(summary = "订单阶段排序") + @RequiresPermissions(PermissionConstants.MODULE_SETTING_UPDATE) + public void sort(@RequestBody List ids) { + orderStageService.sort(ids, OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/circulation-type/{type}") + @Operation(summary = "基础/高级流转切换") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void circulationType(@PathVariable String type) { + stageAdvancedConfigService.switchType(type, FormKey.ORDER.getKey(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/advanced/config") + @Operation(summary = "订单流转配置保存") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void advancedConfigAdd(@RequestBody StageAdvancedConfigRequest request) { + stageAdvancedConfigService.saveAdvancedConfig(request, FormKey.ORDER.getKey(), OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/controller/OrderUserViewController.java b/backend/crm/src/main/java/cn/cordys/crm/order/controller/OrderUserViewController.java new file mode 100644 index 0000000..275d6ab --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/controller/OrderUserViewController.java @@ -0,0 +1,93 @@ +package cn.cordys.crm.order.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.UserViewResourceType; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import cn.cordys.crm.system.dto.response.UserViewListResponse; +import cn.cordys.crm.system.dto.response.UserViewResponse; +import cn.cordys.crm.system.service.UserViewService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "订单视图") +@RestController +@RequestMapping("/order/view") +public class OrderUserViewController { + + @Resource + private UserViewService userViewService; + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.ORDER_READ) + @Operation(summary = "添加订单视图") + public UserView add(@Validated @RequestBody UserViewAddRequest request) { + return userViewService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), UserViewResourceType.ORDER.name()); + } + + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.ORDER_READ) + @Operation(summary = "编辑订单视图") + public UserView update(@Validated @RequestBody UserViewUpdateRequest request) { + return userViewService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.ORDER_READ) + @Operation(summary = "删除订单视图") + public void delete(@PathVariable String id) { + userViewService.delete(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/detail/{id}") + @RequiresPermissions(PermissionConstants.ORDER_READ) + @Operation(summary = "订单视图详情") + public UserViewResponse viewDetail(@PathVariable String id) { + return userViewService.getViewDetail(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), FormKey.ORDER.getKey()); + } + + + @GetMapping("/list") + @RequiresPermissions(PermissionConstants.ORDER_READ) + @Operation(summary = "订单视图列表") + public List queryList() { + return userViewService.list(UserViewResourceType.ORDER.name(), SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/fixed/{id}") + @RequiresPermissions(PermissionConstants.ORDER_READ) + @Operation(summary = "视图固定/取消固定") + public void fixed(@PathVariable String id) { + userViewService.fixed(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/edit/pos") + @Operation(summary = "视图-拖拽排序") + @RequiresPermissions(PermissionConstants.ORDER_READ) + public void editPos(@Validated @RequestBody PosRequest request) { + userViewService.editPos(request, SessionUtils.getUserId(), UserViewResourceType.ORDER.name()); + } + + + @GetMapping("/enable/{id}") + @RequiresPermissions(PermissionConstants.ORDER_READ) + @Operation(summary = "视图-启用/禁用") + public void enable(@PathVariable String id) { + userViewService.enable(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/domain/Order.java b/backend/crm/src/main/java/cn/cordys/crm/order/domain/Order.java new file mode 100644 index 0000000..ae9dc5b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/domain/Order.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.order.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@Table(name = "sales_order") +public class Order extends BaseModel { + + @Schema(description = "合同名称") + private String name; + + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "合同id") + private String contractId; + + @Schema(description = "合同负责人") + private String owner; + + @Schema(description = "金额") + private BigDecimal amount; + + @Schema(description = "编号") + private String number; + + @Schema(description = "状态") + private String stage; + + @Schema(description = "审核状态") + private String approvalStatus; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "自定义排序") + private Long pos; + + @Schema(description = "是否审批通过过") + private Boolean approved; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/domain/OrderField.java b/backend/crm/src/main/java/cn/cordys/crm/order/domain/OrderField.java new file mode 100644 index 0000000..251ac58 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/domain/OrderField.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.order.domain; + +import cn.cordys.common.domain.BaseResourceSubField; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "sales_order_field") +public class OrderField extends BaseResourceSubField { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/domain/OrderFieldBlob.java b/backend/crm/src/main/java/cn/cordys/crm/order/domain/OrderFieldBlob.java new file mode 100644 index 0000000..6a8046b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/domain/OrderFieldBlob.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.order.domain; + +import cn.cordys.common.domain.BaseResourceSubField; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "sales_order_field_blob") +public class OrderFieldBlob extends BaseResourceSubField { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/domain/OrderSnapshot.java b/backend/crm/src/main/java/cn/cordys/crm/order/domain/OrderSnapshot.java new file mode 100644 index 0000000..c7417c9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/domain/OrderSnapshot.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.order.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + + +@Data +@Table(name = "sales_order_snapshot") +public class OrderSnapshot implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "合同id") + private String orderId; + + @Schema(description = "表单属性快照") + private String orderProp; + + @Schema(description = "表单值快照") + private String orderValue; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/domain/OrderStageConfig.java b/backend/crm/src/main/java/cn/cordys/crm/order/domain/OrderStageConfig.java new file mode 100644 index 0000000..9579a66 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/domain/OrderStageConfig.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.order.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "sales_order_stage_config") +public class OrderStageConfig extends BaseModel { + + @Schema(description = "订单状态") + private String name; + + @Schema(description = "订单类型") + private String type; + + @Schema(description = "进行中回退设置") + private Boolean afootRollBack; + + @Schema(description = "完结回退设置") + private Boolean endRollBack; + + @Schema(description = "顺序") + private Long pos; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "流转类型(NORMAL-普通,ADVANCED-高级)") + private String circulationType; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/dto/request/OrderAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/order/dto/request/OrderAddRequest.java new file mode 100644 index 0000000..6b3045b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/dto/request/OrderAddRequest.java @@ -0,0 +1,43 @@ +package cn.cordys.crm.order.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class OrderAddRequest { + + @NotBlank + @Size(max = 255) + @Schema(description = "合同名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Size(max = 32) + @Schema(description = "客户id", requiredMode = Schema.RequiredMode.REQUIRED) + private String customerId; + + @Size(max = 32) + @Schema(description = "合同id", requiredMode = Schema.RequiredMode.REQUIRED) + private String contractId; + + @Size(max = 32) + @Schema(description = "负责人", requiredMode = Schema.RequiredMode.REQUIRED) + private String owner; + + @Schema(description = "累计金额") + private String amount; + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "表单配置") + private ModuleFormConfigDTO moduleFormConfigDTO; + + @Schema(description = "编号") + private String number; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/dto/request/OrderPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/order/dto/request/OrderPageRequest.java new file mode 100644 index 0000000..8930404 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/dto/request/OrderPageRequest.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.order.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +@Data +public class OrderPageRequest extends BasePageRequest { + + @Schema(description = "看板模式") + private Boolean board = false; + public String getCustomerId() { + return null; + } + public String getContractId() { + return null; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/dto/request/OrderStageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/order/dto/request/OrderStageRequest.java new file mode 100644 index 0000000..b1ff733 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/dto/request/OrderStageRequest.java @@ -0,0 +1,24 @@ +package cn.cordys.crm.order.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class OrderStageRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 32) + private String id; + + @NotBlank + private String stage; + + @Schema(description = "更新字段") + private List fields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/dto/request/OrderUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/order/dto/request/OrderUpdateRequest.java new file mode 100644 index 0000000..108a34a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/dto/request/OrderUpdateRequest.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.order.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class OrderUpdateRequest extends OrderAddRequest { + + @NotBlank + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + @Size(max = 32) + private String id; + + @Schema(description = "是否提审更新 normal-正常更新 approval-评审更新") + private String updateType; + + @Schema(description = "变更说明") + private String comment; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/dto/response/OrderGetResponse.java b/backend/crm/src/main/java/cn/cordys/crm/order/dto/response/OrderGetResponse.java new file mode 100644 index 0000000..42c33a8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/dto/response/OrderGetResponse.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.order.dto.response; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class OrderGetResponse extends OrderListResponse { + + @Schema(description = "选项集合") + private Map> optionMap; + + @Schema(description = "附件集合") + private Map> attachmentMap; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/dto/response/OrderListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/order/dto/response/OrderListResponse.java new file mode 100644 index 0000000..55e1232 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/dto/response/OrderListResponse.java @@ -0,0 +1,48 @@ +package cn.cordys.crm.order.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.order.domain.Order; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class OrderListResponse extends Order { + + @Schema(description = "客户名称") + private String customerName; + + @Schema(description = "合同名称") + private String contractName; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "部门id") + private String departmentId; + + @Schema(description = "部门名称") + private String departmentName; + + @Schema(description = "关联的客户是否在公海") + private Boolean inCustomerPool; + + @Schema(description = "客户公海id") + private String poolId; + + @Schema(description = "商机阶段") + private String stageName; + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "审批第一个节点是否通过") + private Boolean firstApproved; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/dto/response/OrderStatisticResponse.java b/backend/crm/src/main/java/cn/cordys/crm/order/dto/response/OrderStatisticResponse.java new file mode 100644 index 0000000..ce28669 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/dto/response/OrderStatisticResponse.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.order.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class OrderStatisticResponse { + + @Schema(description = "总金额") + private Double amount = 0.0; + + @Schema(description = "平均金额") + private Double averageAmount = 0.0; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderMapper.java b/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderMapper.java new file mode 100644 index 0000000..25f9a1e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderMapper.java @@ -0,0 +1,37 @@ +package cn.cordys.crm.order.mapper; + +import cn.cordys.common.dto.BatchUpdateDbParam; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.condition.BaseCondition; +import cn.cordys.crm.order.dto.request.OrderPageRequest; +import cn.cordys.crm.order.dto.response.OrderGetResponse; +import cn.cordys.crm.order.dto.response.OrderListResponse; +import cn.cordys.crm.order.dto.response.OrderStatisticResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtOrderMapper { + + + List list(@Param("request") OrderPageRequest request, @Param("orgId") String orgId, + @Param("userId") String userId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission, @Param("source") boolean source); + + OrderGetResponse getDetail(@Param("id") String id); + + List getListByIds(@Param("ids") List ids, @Param("userId") String userId, @Param("orgId") String orgId, @Param("dataPermission") DeptDataPermissionDTO deptDataPermission); + + void batchUpdate(@Param("request") BatchUpdateDbParam request); + + int countByStage(@Param("stage") String stage); + + OrderStatisticResponse searchStatistic(@Param("request") BaseCondition request, @Param("orgId") String orgId, @Param("userId") String userId, @Param("dataPermission") DeptDataPermissionDTO dataPermission); + + void moveUpStageOrder(@Param("end") Long end, @Param("stage") String stage, @Param("pos") Long pos); + + void moveDownStageOrder(@Param("end") Long end, @Param("stage") String stage, @Param("pos") Long pos); + + Long selectNextPos(@Param("orgId") String orgId, @Param("stage") String stage); + + void updateOldApprovalStatusNone(); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderMapper.xml new file mode 100644 index 0000000..1392389 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderMapper.xml @@ -0,0 +1,552 @@ + + + + + + + + + + update sales_order + + + owner = #{request.fieldValue}, + + + name = #{request.fieldValue}, + + + customer_id = #{request.fieldValue}, + + + contract_id = #{request.fieldValue}, + + + amount = #{request.fieldValue}, + + + number = #{request.fieldValue}, + + + stage = #{request.fieldValue}, + + update_time = #{request.updateTime}, + update_user = #{request.updateUser} + + where id in + + #{id} + + + + + + + + + + + + + + + + + + + + left join sales_order_field_blob ${tablePrefix}_${i} + on so.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + left join sales_order_field ${tablePrefix}_${i} + on so.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + + left join sys_organization_user ${tablePrefix}_${i} + on ${tablePrefix}_${i}.user_id = so.owner + + + + + + + + + + + left join sales_order_field sort_table + on so.id = sort_table.resource_id and sort_table.field_id = #{sortName} + left join sys_module_field module_table on sort_table.field_id = module_table.id + + + left join sys_organization_user souc_sort on souc_sort.user_id = so.owner + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + so.amount is null + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + order by + + + so.`name` ${sortType}, so.id ASC + + + FIELD(so.approval_status, 'NONE', 'PENDING', 'APPROVING', 'APPROVED', 'REVOKED', 'UNAPPROVED') ${sortType}, so.id ASC + + + c.`name` ${sortType}, so.id ASC + + + cc.`name` ${sortType}, so.id ASC + + + so.`owner` ${sortType}, so.id ASC + + + so.`amount` ${sortType}, so.id ASC + + + so.`stage` ${sortType}, so.id ASC + + + so.`number` ${sortType}, so.id ASC + + + so.`create_time` ${sortType}, so.id ASC + + + so.`update_time` ${sortType}, so.id ASC + + + so.`create_user` ${sortType}, so.id ASC + + + so.`update_user` ${sortType}, so.id ASC + + + souc_sort.`id` ${sortType}, so.id ${sortType} + + + + CASE + WHEN module_table.type = 'INPUT_NUMBER' OR module_table.type = 'FORMULA' + THEN sort_table.field_value+0 + END ${sortType}, + sort_table.field_value ${sortType}, + so.id ${sortType} + + + + + order by so.create_time desc, so.id asc + + + + + + + + + + + + + + + update sales_order set pos = pos + #{pos} where pos > #{end} and stage = #{stage} + + + + update sales_order set pos = pos + #{pos} where pos >= #{end} and stage = #{stage} + + + + update sales_order set approval_status = 'NONE'; + + diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderSnapshotMapper.java b/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderSnapshotMapper.java new file mode 100644 index 0000000..53a0018 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderSnapshotMapper.java @@ -0,0 +1,9 @@ +package cn.cordys.crm.order.mapper; + +import cn.cordys.crm.order.domain.OrderSnapshot; +import org.apache.ibatis.annotations.Param; + +public interface ExtOrderSnapshotMapper { + + void update(@Param("snapshot") OrderSnapshot snapshot); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderSnapshotMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderSnapshotMapper.xml new file mode 100644 index 0000000..5918d83 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderSnapshotMapper.xml @@ -0,0 +1,13 @@ + + + + + + + update contract_snapshot + set contract_id = #{snapshot.contractId}, + contract_prop = #{snapshot.contractProp}, + contract_value = #{snapshot.contractValue} + where id = #{snapshot.id} + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderStageConfigMapper.java b/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderStageConfigMapper.java new file mode 100644 index 0000000..03f5973 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderStageConfigMapper.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.order.mapper; + +import cn.cordys.common.dto.stage.StageConfigResponse; +import cn.cordys.common.dto.stage.StageRollBackRequest; +import cn.cordys.common.dto.stage.StageUpdateRequest; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtOrderStageConfigMapper { + + + List getStageConfigList(@Param("orgId") String orgId); + + int countStageConfig(@Param("orgId") String orgId); + + void moveUpStageConfig(@Param("start") Long start, @Param("orgId") String orgId, @Param("pos") Long pos); + + void moveDownStageConfig(@Param("start") Long start, @Param("orgId") String orgId, @Param("pos") Long pos); + + void updateRollBack(@Param("request") StageRollBackRequest request, @Param("orgId") String orgId); + + void updateStageConfig(@Param("request") StageUpdateRequest request, @Param("userId") String userId); + + void updatePos(@Param("id") String id, @Param("pos") Long pos); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderStageConfigMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderStageConfigMapper.xml new file mode 100644 index 0000000..4cf04e2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/mapper/ExtOrderStageConfigMapper.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + update sales_order_stage_config + set pos = pos + #{pos} + where pos >= #{start} + and organization_id = #{orgId} + + + + update sales_order_stage_config + set pos = pos + #{pos} + where pos > #{start} + and organization_id = #{orgId} + + + + + UPDATE sales_order_stage_config + + + afoot_roll_back =#{request.afootRollBack}, + + + end_roll_back = #{request.endRollBack}, + + + WHERE organization_id = #{orgId} + + + + update sales_order_stage_config + set name = #{request.name}, + update_user = #{userId}, + update_time = unix_timestamp() * 1000 + where id = #{request.id} + + + + + update sales_order_stage_config set pos = #{pos} where id = #{id} + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/service/OrderFieldService.java b/backend/crm/src/main/java/cn/cordys/crm/order/service/OrderFieldService.java new file mode 100644 index 0000000..8d5b89b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/service/OrderFieldService.java @@ -0,0 +1,35 @@ +package cn.cordys.crm.order.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.crm.order.domain.OrderField; +import cn.cordys.crm.order.domain.OrderFieldBlob; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(rollbackFor = Exception.class) +public class OrderFieldService extends BaseResourceFieldService { + + @Resource + private BaseMapper orderFieldMapper; + @Resource + private BaseMapper orderFieldBlobMapper; + + @Override + protected String getFormKey() { + return FormKey.ORDER.getKey(); + } + + @Override + protected BaseMapper getResourceFieldMapper() { + return orderFieldMapper; + } + + @Override + protected BaseMapper getResourceFieldBlobMapper() { + return orderFieldBlobMapper; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/service/OrderLogService.java b/backend/crm/src/main/java/cn/cordys/crm/order/service/OrderLogService.java new file mode 100644 index 0000000..a7b227b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/service/OrderLogService.java @@ -0,0 +1,90 @@ +package cn.cordys.crm.order.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.contract.domain.Contract; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.system.service.BaseModuleLogService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.function.Consumer; + +@Service +@Transactional(rollbackFor = Exception.class) +public class OrderLogService extends BaseModuleLogService { + + @Resource + private BaseMapper customerMapper; + @Resource + private BaseMapper contractMapper; + + @Override + public List handleLogField(List differences, String orgId) { + differences = super.handleModuleLogField(differences, orgId, FormKey.ORDER.getKey()); + for (var differ : differences) { + var column = differ.getColumn(); + if (Strings.CS.equals(column, BusinessModuleField.ORDER_OWNER.getBusinessKey())) { + setUserFieldName(differ); + continue; + } + if (Strings.CS.equals(column, BusinessModuleField.ORDER_CUSTOMER.getBusinessKey())) { + resolveCustomerName(differ); + continue; + } + if (Strings.CS.equals(column, BusinessModuleField.ORDER_CONTRACT.getBusinessKey())) { + resolveContractName(differ); + continue; + } + if (Strings.CS.equals(column, "orderStage")) { + differ.setColumnName(Translator.get("order.stage")); + continue; + } + if (column != null && column.contains("-")) { + differ.setColumnName(column); + } + if (Strings.CI.equals(column, "approvalStatus")) { + setApprovalName(differ); + } + } + return differences; + } + + private void resolveCustomerName(JsonDifferenceDTO differ) { + if (differ.getOldValue() != null) { + resolveCustomer(differ.getOldValue().toString(), differ::setOldValueName); + } + if (differ.getNewValue() != null) { + resolveCustomer(differ.getNewValue().toString(), differ::setNewValueName); + } + } + + private void resolveContractName(JsonDifferenceDTO differ) { + if (differ.getOldValue() != null) { + resolveContract(differ.getOldValue().toString(), differ::setOldValueName); + } + if (differ.getNewValue() != null) { + resolveContract(differ.getNewValue().toString(), differ::setNewValueName); + } + } + + private void resolveCustomer(String id, Consumer nameConsumer) { + var customer = customerMapper.selectByPrimaryKey(id); + if (customer != null) { + nameConsumer.accept(customer.getName()); + } + } + + private void resolveContract(String id, Consumer nameConsumer) { + Contract contract = contractMapper.selectByPrimaryKey(id); + if (contract != null) { + nameConsumer.accept(contract.getName()); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/service/OrderResourceAccessContextProvider.java b/backend/crm/src/main/java/cn/cordys/crm/order/service/OrderResourceAccessContextProvider.java new file mode 100644 index 0000000..c4c7344 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/service/OrderResourceAccessContextProvider.java @@ -0,0 +1,47 @@ +package cn.cordys.crm.order.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.permission.ResourceAccessContext; +import cn.cordys.common.permission.ResourceAccessContextProvider; +import cn.cordys.crm.order.domain.Order; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class OrderResourceAccessContextProvider implements ResourceAccessContextProvider { + + @Resource + private BaseMapper orderMapper; + + @Override + public String getFormType() { + return FormKey.ORDER.getKey(); + } + + @Override + public ResourceAccessContext getAccessContext(String resourceId, String orgId) { + Order order = orderMapper.selectByPrimaryKey(resourceId); + if (order == null) { + return null; + } + ResourceAccessContext context = new ResourceAccessContext(); + context.setOwnerId(order.getOwner()); + context.setApprovalStatus(order.getApprovalStatus()); + return context; + } + + @Override + public Map batchGetOwnerIds(List resourceIds, String orgId) { + if (resourceIds == null || resourceIds.isEmpty()) { + return Map.of(); + } + return orderMapper.selectByIds(resourceIds).stream() + .filter(order -> order.getOwner() != null) + .collect(Collectors.toMap(Order::getId, Order::getOwner, (a, b) -> a)); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/service/OrderService.java b/backend/crm/src/main/java/cn/cordys/crm/order/service/OrderService.java new file mode 100644 index 0000000..01ce705 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/service/OrderService.java @@ -0,0 +1,1046 @@ +package cn.cordys.crm.order.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.*; +import cn.cordys.common.dto.condition.BaseCondition; +import cn.cordys.common.dto.stage.CirculationFieldValue; +import cn.cordys.common.dto.stage.StageConfigResponse; +import cn.cordys.common.dto.stage.StageSortRequest; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.permission.PermissionUtils; +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.common.resolver.field.ModuleFieldResolverFactory; +import cn.cordys.common.response.result.CrmHttpResultCode; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.approval.annotation.HitApproval; +import cn.cordys.crm.approval.constants.ApprovalFormTypeEnum; +import cn.cordys.crm.approval.constants.ApprovalStatus; +import cn.cordys.crm.approval.constants.ExecuteTimingEnum; +import cn.cordys.crm.approval.dto.ResourceApprovalFieldUpdateParam; +import cn.cordys.crm.approval.dto.ResourceApprovalPostUpdateParam; +import cn.cordys.crm.approval.dto.ResourceSnapshotApprovalParam; +import cn.cordys.crm.approval.handler.ApprovalResourceHandler; +import cn.cordys.crm.approval.service.ApprovalFlowService; +import cn.cordys.crm.approval.service.ApprovalResourceService; +import cn.cordys.crm.contract.domain.Contract; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.order.domain.Order; +import cn.cordys.crm.order.domain.OrderField; +import cn.cordys.crm.order.domain.OrderFieldBlob; +import cn.cordys.crm.order.domain.OrderSnapshot; +import cn.cordys.crm.order.dto.request.OrderAddRequest; +import cn.cordys.crm.order.dto.request.OrderPageRequest; +import cn.cordys.crm.order.dto.request.OrderStageRequest; +import cn.cordys.crm.order.dto.request.OrderUpdateRequest; +import cn.cordys.crm.order.dto.response.OrderGetResponse; +import cn.cordys.crm.order.dto.response.OrderListResponse; +import cn.cordys.crm.order.dto.response.OrderStatisticResponse; +import cn.cordys.crm.order.mapper.ExtOrderMapper; +import cn.cordys.crm.order.mapper.ExtOrderStageConfigMapper; +import cn.cordys.crm.system.constants.CirculationFieldValueTypeEnum; +import cn.cordys.crm.system.constants.CirculationTypeEnum; +import cn.cordys.crm.system.domain.StageAdvancedConfig; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.BatchAffectReasonResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.mapper.ExtStageAdvancedConfigMapper; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.crm.system.service.StageAdvancedConfigService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class OrderService implements ApprovalResourceHandler { + + @Resource + private OrderFieldService orderFieldService; + @Resource + private BaseMapper orderMapper; + @Resource + private BaseService baseService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private BaseMapper snapshotBaseMapper; + @Resource + private ExtOrderMapper extOrderMapper; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private PermissionCache permissionCache; + @Resource + private BaseMapper customerMapper; + @Resource + private BaseMapper contractMapper; + @Resource + private LogService logService; + @Resource + private ExtOrderStageConfigMapper extOrderStageConfigMapper; + @Resource + private BaseMapper customerBaseMapper; + @Resource + private ApprovalFlowService approvalFlowService; + @Resource + private StageAdvancedConfigService stageAdvancedConfigService; + @Resource + private ExtStageAdvancedConfigMapper extStageAdvancedConfigMapper; + + private static final BigDecimal MAX_AMOUNT = new BigDecimal("9999999999"); + public static final Long DEFAULT_POS = 1L; + + /** + * 新建订单 + * + * @param request + * @param operatorId + * @param orgId + * @return + */ + @OperationLog(module = LogModule.ORDER_INDEX, type = LogType.ADD) + @HitApproval(formKey = FormKey.ORDER, executeType = ExecuteTimingEnum.CREATE, operatorId = "{#operatorId}") + public Order add(OrderAddRequest request, String operatorId, String orgId) { + List moduleFields = request.getModuleFields(); + ModuleFormConfigDTO moduleFormConfigDTO = request.getModuleFormConfigDTO(); + if (CollectionUtils.isEmpty(moduleFields)) { + throw new GenericException(Translator.get("order.field.required")); + } + if (moduleFormConfigDTO == null) { + throw new GenericException(Translator.get("order.form.config.required")); + } + List stageConfigList = extOrderStageConfigMapper.getStageConfigList(orgId); + Long nextPos = getNextPos(orgId, stageConfigList.getFirst().getId()); + ModuleFormConfigDTO saveModuleFormConfigDTO = JSON.parseObject(JSON.toJSONString(moduleFormConfigDTO), ModuleFormConfigDTO.class); + Order order = new Order(); + BeanUtils.copyBean(order, request); + order.setId(IDGenerator.nextStr()); + order.setStage(stageConfigList.getFirst().getId()); + order.setPos(nextPos); + order.setApprovalStatus(ApprovalStatus.NONE.name()); + order.setOrganizationId(orgId); + order.setCreateTime(System.currentTimeMillis()); + order.setCreateUser(operatorId); + order.setUpdateTime(System.currentTimeMillis()); + order.setUpdateUser(operatorId); + + //判断总金额 + setAmount(request.getAmount(), order); + + //自定义字段 + orderFieldService.saveModuleField(order, orgId, operatorId, moduleFields, false); + orderMapper.insert(order); + + baseService.handleAddLogWithSubTable(order, moduleFields, Translator.get("products_info"), moduleFormConfigDTO); + + // 保存表单配置快照 + List resolveFieldValues = moduleFormService.resolveSnapshotFields(moduleFields, moduleFormConfigDTO, orderFieldService, order.getId()); + OrderGetResponse response = get(order, resolveFieldValues, moduleFormConfigDTO); + saveSnapshot(order, saveModuleFormConfigDTO, response); + + return order; + } + + + private Long getNextPos(String orgId, String stage) { + Long pos = extOrderMapper.selectNextPos(orgId, stage); + return pos == null ? 1 : pos + 1; + } + + /** + * 保存订单快照 + * + * @param order + * @param moduleFormConfigDTO + * @param response + */ + private void saveSnapshot(Order order, ModuleFormConfigDTO moduleFormConfigDTO, OrderGetResponse response) { + //移除response中moduleFields 集合里 的 BaseModuleFieldValue 的 fieldId="products"的数据,避免快照数据过大 + if (CollectionUtils.isNotEmpty(response.getModuleFields())) { + response.setModuleFields(response.getModuleFields().stream() + .filter(field -> (field.getFieldValue() != null && StringUtils.isNotBlank(field.getFieldValue().toString()) && !"[]".equals(field.getFieldValue().toString()))).toList()); + } + OrderSnapshot snapshot = new OrderSnapshot(); + snapshot.setId(IDGenerator.nextStr()); + snapshot.setOrderId(order.getId()); + snapshot.setOrderProp(JSON.toJSONString(moduleFormConfigDTO)); + snapshot.setOrderValue(JSON.toJSONString(response)); + snapshotBaseMapper.insert(snapshot); + } + + private OrderGetResponse get(Order order, List orderFields, ModuleFormConfigDTO orderFormConfig) { + OrderGetResponse orderGetResponse = BeanUtils.copyBean(new OrderGetResponse(), order); + orderGetResponse = baseService.setCreateUpdateOwnerUserName(orderGetResponse); + + String id = order.getId(); + // 获取模块字段 + moduleFormService.processBusinessFieldValues(orderGetResponse, orderFields, orderFormConfig); + orderFields = orderFieldService.setBusinessRefFieldValue(List.of(orderGetResponse), + moduleFormService.getFlattenFormFields(FormKey.ORDER.getKey(), order.getOrganizationId()), new HashMap<>(Map.of(id, orderFields))).get(id); + + Map> optionMap = moduleFormService.getOptionMap(orderFormConfig, orderFields); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(orderGetResponse, + OrderGetResponse::getOwner, OrderGetResponse::getOwnerName); + optionMap.put(BusinessModuleField.ORDER_OWNER.getBusinessKey(), ownerFieldOption); + + Customer customer = customerMapper.selectByPrimaryKey(order.getCustomerId()); + Contract contract = contractMapper.selectByPrimaryKey(order.getContractId()); + + Map stageNameMap = extOrderStageConfigMapper.getStageConfigList(order.getOrganizationId()).stream() + .collect(Collectors.toMap(StageConfigResponse::getId, + StageConfigResponse::getName)); + orderGetResponse.setStageName(stageNameMap.get(order.getStage())); + + if (customer != null) { + orderGetResponse.setCustomerName(customer.getName()); + optionMap.put(BusinessModuleField.ORDER_CUSTOMER.getBusinessKey(), Collections.singletonList(new OptionDTO(customer.getId(), customer.getName()))); + } + if (contract != null) { + orderGetResponse.setContractName(contract.getName()); + optionMap.put(BusinessModuleField.ORDER_CONTRACT.getBusinessKey(), Collections.singletonList(new OptionDTO(contract.getId(), contract.getName()))); + } + + orderGetResponse.setOptionMap(optionMap); + orderGetResponse.setModuleFields(orderFields); + + if (orderGetResponse.getOwner() != null) { + UserDeptDTO userDeptDTO = baseService.getUserDeptMapByUserId(orderGetResponse.getOwner(), order.getOrganizationId()); + if (userDeptDTO != null) { + orderGetResponse.setDepartmentId(userDeptDTO.getDeptId()); + orderGetResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + } + + // 附件信息 + orderGetResponse.setAttachmentMap(moduleFormService.getAttachmentMap(orderFormConfig, orderFields)); + return orderGetResponse; + } + + /** + * 获取订单详情 + * + * @param id + * @return + */ + public OrderGetResponse get(String id, String orgId) { + Order order = orderMapper.selectByPrimaryKey(id); + // 获取模块字段 + ModuleFormConfigDTO orderFormConfig = getFormConfig(order.getOrganizationId()); + List orderFields = orderFieldService.getModuleFieldValuesByResourceId(id); + OrderGetResponse getResponse = get(order, orderFields, orderFormConfig); + if (Strings.CI.equals(getResponse.getApprovalStatus(), ApprovalStatus.APPROVING.name())) { + Map firstNodeApproved = baseService.getApprovingResourceFirstNodeApproved(List.of(getResponse.getId()), orgId); + getResponse.setFirstApproved(firstNodeApproved.get(getResponse.getId())); + } + return getResponse; + } + + /** + * 获取订单详情(⚠️反射调用; 勿修改入参, 返回, 方法名!) + * + * @param id 订单ID + * @return 订单详情 + */ + public OrderGetResponse getSimple(String id) { + Order order = orderMapper.selectByPrimaryKey(id); + if (order == null) { + return null; + } + OrderGetResponse response = BeanUtils.copyBean(new OrderGetResponse(), order); + List fvs = orderFieldService.getModuleFieldValuesByResourceId(id); + ModuleFormConfigDTO orderFormConfig = getFormConfig(order.getOrganizationId()); + moduleFormService.processBusinessFieldValues(response, fvs, orderFormConfig); + return response; + } + + /** + * 获取字段详情 (⚠️反射调用; 勿修改入参, 返回, 方法名!) + * + * @param id 订单ID + * @return 订单详情 + */ + public OrderGetResponse getFieldValues(String id) { + OrderGetResponse response = new OrderGetResponse(); + Order order = orderMapper.selectByPrimaryKey(id); + if (order == null) { + return null; + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(OrderSnapshot::getOrderId, id); + OrderSnapshot snapshot = snapshotBaseMapper.selectListByLambda(wrapper).stream().findFirst().orElse(null); + if (snapshot != null) { + response = JSON.parseObject(snapshot.getOrderValue(), OrderGetResponse.class); + if (StringUtils.isNotBlank(order.getCustomerId())) { + Customer customer = customerBaseMapper.selectByPrimaryKey(order.getCustomerId()); + if (customer != null) { + response.setInCustomerPool(customer.getInSharedPool()); + response.setPoolId(customer.getPoolId()); + } + } + } + return response; + } + + /** + * 批量获取订单详情 (用于数据源批量查询优化) + * + * @param ids 订单ID集合 + * @return 订单详情列表 + */ + public List batchGetSimpleByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return Collections.emptyList(); + } + List orders = orderMapper.selectByIds(ids); + if (CollectionUtils.isEmpty(orders)) { + return Collections.emptyList(); + } + Map> fieldValueMap = orderFieldService.getResourceFieldMap(ids, true); + + return orders.stream().map(order -> { + OrderGetResponse response = BeanUtils.copyBean(new OrderGetResponse(), order); + response.setModuleFields(fieldValueMap.get(order.getId())); + return response; + }).toList(); + } + + /** + * 编辑订单 + * + * @param request + * @param userId + * @param orgId + * @return + */ + @OperationLog(module = LogModule.ORDER_INDEX, type = LogType.UPDATE, resourceId = "{#request.id}") + @HitApproval(formKey = FormKey.ORDER, executeType = ExecuteTimingEnum.UPDATE, resourceId = "{#request.id}", updateType = "{#request.updateType}", operatorId = "{#userId}", comment = "{#request.comment}") + public Order update(OrderUpdateRequest request, String userId, String orgId) { + Order oldOrder = orderMapper.selectByPrimaryKey(request.getId()); + List moduleFields = request.getModuleFields(); + ModuleFormConfigDTO moduleFormConfigDTO = request.getModuleFormConfigDTO(); + if (CollectionUtils.isEmpty(moduleFields)) { + throw new GenericException(Translator.get("order.field.required")); + } + if (moduleFormConfigDTO == null) { + throw new GenericException(Translator.get("order.form.config.required")); + } + ModuleFormConfigDTO saveModuleFormConfigDTO = JSON.parseObject(JSON.toJSONString(moduleFormConfigDTO), ModuleFormConfigDTO.class); + Optional.ofNullable(oldOrder).ifPresentOrElse(item -> { + + List originFields = orderFieldService.getModuleFieldValuesByResourceId(request.getId()); + Order order = BeanUtils.copyBean(new Order(), request); + order.setUpdateTime(System.currentTimeMillis()); + order.setUpdateUser(userId); + // 保留不可更改的字段 + order.setNumber(oldOrder.getNumber()); + order.setCreateUser(oldOrder.getCreateUser()); + order.setCreateTime(oldOrder.getCreateTime()); + order.setStage(oldOrder.getStage()); + order.setApprovalStatus(oldOrder.getApprovalStatus()); + //判断总金额 + setAmount(request.getAmount(), order); + updateFields(moduleFields, order, orgId, userId); + orderMapper.update(order); + //删除快照 + LambdaQueryWrapper delWrapper = new LambdaQueryWrapper<>(); + delWrapper.eq(OrderSnapshot::getOrderId, request.getId()); + List orderSnapshots = snapshotBaseMapper.selectListByLambda(delWrapper); + if (CollectionUtils.isNotEmpty(orderSnapshots)) { + OrderSnapshot first = orderSnapshots.getFirst(); + if (first != null) { + OrderGetResponse response = JSON.parseObject(first.getOrderValue(), OrderGetResponse.class); + List originModuleFields = response.getModuleFields(); + originFields.addAll(originModuleFields); + } + } + snapshotBaseMapper.deleteByLambda(delWrapper); + //保存快照 + List resolveFieldValues = moduleFormService.resolveSnapshotFields(moduleFields, moduleFormConfigDTO, orderFieldService, order.getId()); + // get 方法需要使用orgId + order.setOrganizationId(orgId); + OrderGetResponse response = get(order, resolveFieldValues, moduleFormConfigDTO); + saveSnapshot(order, saveModuleFormConfigDTO, response); + // 处理日志上下文 + baseService.handleUpdateLogWithSubTable(oldOrder, order, originFields, moduleFields, request.getId(), order.getName(), Translator.get("products_info"), moduleFormConfigDTO); + }, () -> { + throw new GenericException(CrmHttpResultCode.NOT_FOUND); + }); + return orderMapper.selectByPrimaryKey(request.getId()); + } + + private void setAmount(String amount, Order order) { + if (StringUtils.isNotBlank(amount)) { + order.setAmount(new BigDecimal(amount)); + if (order.getAmount().compareTo(MAX_AMOUNT) > 0) { + throw new GenericException(Translator.get("order.amount.exceed.max")); + } + } else { + order.setAmount(BigDecimal.ZERO); + } + } + + + /** + * 更新自定义字段 + * + * @param moduleFields + * @param order + * @param orgId + * @param userId + */ + private void updateFields(List moduleFields, Order order, String orgId, String userId) { + if (moduleFields == null) { + return; + } + orderFieldService.deleteByResourceId(order.getId()); + orderFieldService.saveModuleField(order, orgId, userId, moduleFields, true); + } + + + /** + * 删除订单 + * + * @param id 订单ID + */ + @OperationLog(module = LogModule.ORDER_INDEX, type = LogType.DELETE, resourceId = "{#id}") + @HitApproval(formKey = FormKey.ORDER, executeType = ExecuteTimingEnum.DELETE, resourceId = "{#id}", operatorId = "{#userId}") + public void delete(String id, String userId) { + Order order = orderMapper.selectByPrimaryKey(id); + if (order == null) { + throw new GenericException(CrmHttpResultCode.NOT_FOUND); + } + + orderFieldService.deleteByResourceId(id); + orderMapper.deleteByPrimaryKey(id); + + //删除快照 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(OrderSnapshot::getOrderId, id); + snapshotBaseMapper.deleteByLambda(wrapper); + // 添加日志上下文 + OperationLogContext.setResourceName(order.getName()); + } + + @Override + public void deleteForResource(String resourceId, String userId, String organizationId) { + delete(resourceId, userId); + } + + @Override + public FormKey getFormKey() { + return FormKey.ORDER; + } + + + /** + * ⚠️反射调用; 勿修改入参, 返回, 方法名! + * + * @param id 订单ID + * @return 订单详情 + */ + public OrderGetResponse getSnapshot(String id, String orgId) { + OrderGetResponse response = new OrderGetResponse(); + Order order = orderMapper.selectByPrimaryKey(id); + if (order == null) { + return null; + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(OrderSnapshot::getOrderId, id); + OrderSnapshot snapshot = snapshotBaseMapper.selectListByLambda(wrapper).stream().findFirst().orElse(null); + if (snapshot != null) { + response = JSON.parseObject(snapshot.getOrderValue(), OrderGetResponse.class); + if (StringUtils.isNotBlank(order.getCustomerId())) { + Customer customer = customerBaseMapper.selectByPrimaryKey(order.getCustomerId()); + if (customer != null) { + response.setInCustomerPool(customer.getInSharedPool()); + response.setPoolId(customer.getPoolId()); + } + } + if (Strings.CI.equals(response.getApprovalStatus(), ApprovalStatus.APPROVING.name())) { + Map firstNodeApproved = baseService.getApprovingResourceFirstNodeApproved(List.of(response.getId()), orgId); + response.setFirstApproved(firstNodeApproved.get(response.getId())); + } + } + response.setApproved(order.getApproved()); + return response; + } + + /** + * ⚠️反射调用: 由审批执行操作统一调用, 勿修改 + * + * @param param 参数 + */ + public void updateSnapshotApprovalStatus(ResourceSnapshotApprovalParam param) { + OrderSnapshot snapshotCriteria = new OrderSnapshot(); + snapshotCriteria.setOrderId(param.getResourceId()); + OrderSnapshot snapshot = snapshotBaseMapper.selectOne(snapshotCriteria); + if (snapshot != null) { + OrderGetResponse response = JSON.parseObject(snapshot.getOrderValue(), OrderGetResponse.class); + response.setApprovalStatus(param.getApprovalStatus()); + snapshot.setOrderValue(JSON.toJSONString(response)); + snapshotBaseMapper.update(snapshot); + } + } + + /** + * ⚠️反射调用: 由审批执行后置操作统一调用, 勿修改 + * + * @param postFieldParam 参数 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public void updateApprovalPostField(ResourceApprovalPostUpdateParam postFieldParam) { + ModuleFormConfigDTO formConfig = getFormConfig(OrganizationContext.getOrganizationId()); + List fields = formConfig.getFields(); + Map fieldConfigMap = fields.stream().collect(Collectors.toMap(BaseField::getId, f -> f)); + Order order = orderMapper.selectByPrimaryKey(postFieldParam.getResourceId()); + // 保存原始数据用于日志记录 + Order originOrder = BeanUtils.copyBean(new Order(), order); + List originFields = orderFieldService.getModuleFieldValuesByResourceId(postFieldParam.getResourceId()); + List orderFields = new ArrayList<>(); + List orderFieldBlobs = new ArrayList<>(); + OrderSnapshot snapshotCriteria = new OrderSnapshot(); + snapshotCriteria.setOrderId(postFieldParam.getResourceId()); + OrderSnapshot snapshot = snapshotBaseMapper.selectOne(snapshotCriteria); + OrderGetResponse response = new OrderGetResponse(); + if (snapshot != null) { + response = JSON.parseObject(snapshot.getOrderValue(), OrderGetResponse.class); + } + + ResourceApprovalFieldUpdateParam stageField = postFieldParam.getFields().stream().filter(param -> Strings.CS.equals(param.getFieldId(), "stage") && param.getFieldValue() != null).findFirst().orElse(null); + handleStageSetting(stageField, order, postFieldParam); + + for (ResourceApprovalFieldUpdateParam fieldUpdateParam : postFieldParam.getFields()) { + if (Strings.CS.equals(fieldUpdateParam.getFieldId(), "stage") && fieldUpdateParam.getFieldValue() != null) { + orderFieldService.setResourceFieldValue(order, "stage", fieldUpdateParam.getFieldValue()); + continue; + } + if (!fieldConfigMap.containsKey(fieldUpdateParam.getFieldId()) || fieldUpdateParam.getFieldValue() == null) { + return; + } + BaseField fieldConfig = fieldConfigMap.get(fieldUpdateParam.getFieldId()); + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(fieldConfig.getType()); + if (fieldConfig.hasBusinessKey()) { + // 业务主表字段 + orderFieldService.setResourceFieldValue(order, fieldConfig.getBusinessKey(), fieldUpdateParam.getFieldValue()); + } else { + // 自定义字段 + Optional findField = response.getModuleFields().stream().filter(fieldValue -> Strings.CI.equals(fieldValue.getFieldId(), fieldUpdateParam.getFieldId())).findAny(); + if (findField.isPresent()) { + findField.get().setFieldValue(fieldUpdateParam.getFieldValue()); + } else { + BaseModuleFieldValue fv = new BaseModuleFieldValue(); + fv.setFieldId(fieldUpdateParam.getFieldId()); + fv.setFieldValue(fieldUpdateParam.getFieldValue()); + response.getModuleFields().add(fv); + } + if (fieldConfig.isBlob()) { + // 自定义大表 + orderFieldService.getResourceFieldBlobMapper().deleteByLambda(new LambdaQueryWrapper() + .eq(OrderFieldBlob::getFieldId, fieldUpdateParam.getFieldId()).eq(OrderFieldBlob::getResourceId, postFieldParam.getResourceId())); + OrderFieldBlob field = new OrderFieldBlob(); + field.setId(IDGenerator.nextStr()); + field.setResourceId(postFieldParam.getResourceId()); + field.setFieldId(fieldUpdateParam.getFieldId()); + field.setFieldValue(customFieldResolver.convertToString(fieldConfig, fieldUpdateParam.getFieldValue())); + orderFieldBlobs.add(field); + } else { + // 自定义表 + orderFieldService.getResourceFieldMapper().deleteByLambda(new LambdaQueryWrapper() + .eq(OrderField::getFieldId, fieldUpdateParam.getFieldId()).eq(OrderField::getResourceId, postFieldParam.getResourceId())); + OrderField field = new OrderField(); + field.setId(IDGenerator.nextStr()); + field.setResourceId(postFieldParam.getResourceId()); + field.setFieldId(fieldUpdateParam.getFieldId()); + field.setFieldValue(customFieldResolver.convertToString(fieldConfig, fieldUpdateParam.getFieldValue())); + orderFields.add(field); + } + } + } + orderMapper.updateById(order); + if (CollectionUtils.isNotEmpty(orderFields)) { + orderFieldService.getResourceFieldMapper().batchInsert(orderFields); + } + if (CollectionUtils.isNotEmpty(orderFieldBlobs)) { + orderFieldService.getResourceFieldBlobMapper().batchInsert(orderFieldBlobs); + } + // 更新快照 + if (snapshot != null) { + OrderGetResponse snapshotRes = get(order, response.getModuleFields(), formConfig); + snapshot.setOrderValue(JSON.toJSONString(snapshotRes)); + snapshotBaseMapper.update(snapshot); + } + // 记录审批后置字段更新日志 + baseService.handleUpdateLogWithSubTable(originOrder, order, originFields, orderFieldService.getModuleFieldValuesByResourceId(postFieldParam.getResourceId()), + postFieldParam.getResourceId(), order.getName(), Translator.get("products_info"), formConfig); + // 从 OperationLogContext 中获取日志信息并手动记录 + LogContextInfo contextInfo = OperationLogContext.getContext(); + if (contextInfo != null) { + String orgId = OrganizationContext.getOrganizationId(); + LogDTO logDTO = new LogDTO(orgId, postFieldParam.getResourceId(), postFieldParam.getOperator(), LogType.UPDATE, LogModule.ORDER_INDEX, order.getName()); + logDTO.setOriginalValue(contextInfo.getOriginalValue()); + logDTO.setModifiedValue(contextInfo.getModifiedValue()); + logService.add(logDTO); + OperationLogContext.clear(); + } + } + + + /** + * 审批后置操作更新阶段配置 + * + * @param stageField + * @param originOrder + * @param postFieldParam + */ + private void handleStageSetting(ResourceApprovalFieldUpdateParam stageField, Order originOrder, ResourceApprovalPostUpdateParam postFieldParam) { + if (stageField == null) { + return; + } + if (!stageAdvancedConfigService.checkStage(originOrder.getStage(), stageField.getFieldValue().toString(), FormKey.ORDER.getKey())) { + return; + } + StageConfigResponse first = extOrderStageConfigMapper.getStageConfigList(originOrder.getOrganizationId()).getFirst(); + if (Strings.CI.equals(first.getCirculationType(), CirculationTypeEnum.ADVANCED.name())) { + StageAdvancedConfig config = extStageAdvancedConfigMapper.getConfigByOriginAndTarget(originOrder.getStage(), stageField.getFieldValue().toString(), FormKey.ORDER.name()); + List circulationFieldValues = JSON.parseObject(config.getFieldConfig(), new TypeReference>() { + }); + List fields = new ArrayList<>(); + circulationFieldValues.forEach(field -> { + if (Strings.CI.equals(field.getValueType(), CirculationFieldValueTypeEnum.FIXED_VALUE.name())) { + if (field.getFieldValue() != null) { + ResourceApprovalFieldUpdateParam param = new ResourceApprovalFieldUpdateParam(); + param.setEnable(true); + param.setFieldId(field.getFieldId()); + param.setFieldValue(field.getFieldValue()); + fields.add(param); + } + } + }); + postFieldParam.getFields().addAll(fields); + } + } + + + /** + * 订单列表 + * + * @param request + * @param userId + * @param orgId + * @param deptDataPermission + * @return + */ + public PagerWithOption> list(OrderPageRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission, Boolean source) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extOrderMapper.list(request, orgId, userId, deptDataPermission, source); + List results = buildList(list, orgId); + ModuleFormConfigDTO customerFormConfig = getFormConfig(orgId); + Map> optionMap = buildOptionMap(list, results, customerFormConfig); + + return PageUtils.setPageInfoWithOption(page, results, optionMap); + } + + private Map> buildOptionMap(List list, List buildList, + ModuleFormConfigDTO formConfig) { + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, OrderListResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(formConfig, moduleFieldValues); + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(buildList, + OrderListResponse::getOwner, OrderListResponse::getOwnerName); + optionMap.put(BusinessModuleField.ORDER_OWNER.getBusinessKey(), ownerFieldOption); + return optionMap; + } + + private ModuleFormConfigDTO getFormConfig(String orgId) { + return moduleFormCacheService.getBusinessFormConfig(FormKey.ORDER.getKey(), orgId); + } + + public List buildList(List list, String orgId) { + if (CollectionUtils.isEmpty(list)) { + return list; + } + + List orderIds = list.stream().map(OrderListResponse::getId) + .collect(Collectors.toList()); + Map> orderFiledMap = orderFieldService.getResourceFieldMap(orderIds, true); + Map> resolvefieldValueMap = orderFieldService.setBusinessRefFieldValue(list, moduleFormService.getFlattenFormFields(FormKey.ORDER.getKey(), orgId), orderFiledMap); + + + List ownerIds = list.stream() + .map(OrderListResponse::getOwner) + .distinct() + .toList(); + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + + Map stageNameMap = extOrderStageConfigMapper.getStageConfigList(orgId).stream() + .collect(Collectors.toMap(StageConfigResponse::getId, + StageConfigResponse::getName)); + + List approvingResourceIds = list.stream().filter(item -> Strings.CI.contains(item.getApprovalStatus(), ApprovalStatus.APPROVING.name())).map(OrderListResponse::getId).toList(); + Map firstNodeApprovedMap = baseService.getApprovingResourceFirstNodeApproved(approvingResourceIds, orgId); + + list.forEach(item -> { + UserDeptDTO userDeptDTO = userDeptMap.get(item.getOwner()); + if (userDeptDTO != null) { + item.setDepartmentId(userDeptDTO.getDeptId()); + item.setDepartmentName(userDeptDTO.getDeptName()); + } + item.setStageName(stageNameMap.get(item.getStage())); + // 获取自定义字段 + List orderFields = resolvefieldValueMap.get(item.getId()); + item.setModuleFields(orderFields); + item.setFirstApproved(firstNodeApprovedMap.get(item.getId())); + }); + return baseService.setCreateUpdateOwnerUserName(list); + } + + + /** + * 获取表单快照 + * + * @param id + * @param orgId + * @return + */ + public ModuleFormConfigDTO getFormSnapshot(String id, String orgId) { + Order order = orderMapper.selectByPrimaryKey(id); + if (order == null) { + throw new GenericException(CrmHttpResultCode.NOT_FOUND); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(OrderSnapshot::getOrderId, id); + OrderSnapshot snapshot = snapshotBaseMapper.selectListByLambda(wrapper).stream().findFirst().orElse(null); + if (snapshot != null) { + return JSON.parseObject(snapshot.getOrderProp(), ModuleFormConfigDTO.class); + } else { + return moduleFormCacheService.getBusinessFormConfig(FormKey.ORDER.getKey(), orgId); + } + } + + + public ResourceTabEnableDTO getTabEnableConfig(String userId, String orgId) { + List rolePermissions = permissionCache.getRolePermissions(userId, orgId); + return PermissionUtils.getTabEnableConfig(userId, PermissionConstants.ORDER_READ, rolePermissions); + } + + private void updateStageSnapshot(String id, String stage) { + if (StringUtils.isBlank(stage)) { + return; + } + LambdaQueryWrapper delWrapper = new LambdaQueryWrapper<>(); + delWrapper.eq(OrderSnapshot::getOrderId, id); + List orderSnapshots = snapshotBaseMapper.selectListByLambda(delWrapper); + OrderSnapshot first = orderSnapshots.getFirst(); + if (first != null) { + OrderGetResponse response = JSON.parseObject(first.getOrderValue(), OrderGetResponse.class); + response.setStage(stage); + first.setOrderValue(JSON.toJSONString(response)); + snapshotBaseMapper.update(first); + } + } + + public Order selectByPrimaryKey(String id) { + return orderMapper.selectByPrimaryKey(id); + } + + + @OperationLog(module = LogModule.ORDER_INDEX, type = LogType.UPDATE, resourceId = "{#request.id}") + public void updateStage(OrderStageRequest request, String userId, String orgId) { + Order order = orderMapper.selectByPrimaryKey(request.getId()); + if (order == null) { + throw new GenericException(CrmHttpResultCode.NOT_FOUND); + } + + List stageConfigList = extOrderStageConfigMapper.getStageConfigList(orgId); + Map stageMap = stageConfigList.stream() + .collect(Collectors.toMap(StageConfigResponse::getId, StageConfigResponse::getName)); + + if (!stageAdvancedConfigService.checkStage(order.getStage(), request.getStage(), FormKey.ORDER.getKey())) { + return; + } + order.setStage(request.getStage()); + orderMapper.update(order); + + updateFieldAndSnapshot(order, request.getFields(), userId); + + final Map originalVal = new HashMap<>(1); + originalVal.put("orderStage", stageMap.get(order.getStage())); + final Map modifiedVal = new HashMap<>(1); + modifiedVal.put("orderStage", stageMap.get(request.getStage())); + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceName(order.getName()) + .originalValue(originalVal) + .modifiedValue(modifiedVal) + .build() + ); + } + + private void updateFieldAndSnapshot(Order order, List requestFields, String userId) { + if (CollectionUtils.isNotEmpty(requestFields)) { + ModuleFormConfigDTO businessFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.ORDER.getKey(), order.getOrganizationId()); + List fields = businessFormConfig.getFields(); + requestFields.forEach(field -> { + BaseField baseField = fields.stream().filter(customField -> customField.getId().equals(field.getFieldId())).findFirst().orElse(null); + ResourceBatchEditRequest updateRequest = new ResourceBatchEditRequest(); + updateRequest.setIds(List.of(order.getId())); + updateRequest.setFieldId(field.getFieldId()); + updateRequest.setFieldValue(field.getFieldValue()); + orderFieldService.batchUpdate(updateRequest, baseField, List.of(order), Order.class, LogModule.ORDER_INDEX, extOrderMapper::batchUpdate, userId, order.getOrganizationId()); + }); + } + OrderSnapshot snapshotCriteria = new OrderSnapshot(); + snapshotCriteria.setOrderId(order.getId()); + OrderSnapshot snapshot = snapshotBaseMapper.selectOne(snapshotCriteria); + if (snapshot != null) { + ModuleFormConfigDTO orderFormConfig = getFormConfig(order.getOrganizationId()); + List orderFields = orderFieldService.getModuleFieldValuesByResourceId(order.getId()); + Order newOrder = orderMapper.selectByPrimaryKey(order.getId()); + OrderGetResponse snapshotRes = get(newOrder, orderFields, orderFormConfig); + snapshot.setOrderValue(JSON.toJSONString(snapshotRes)); + snapshotBaseMapper.update(snapshot); + } + } + + /** + * 批量更新订单 + * + * @param request 批量编辑参数 + * @param userId 当前用户ID + * @param orgId 当前组织ID + */ + public BatchAffectReasonResponse batchUpdate(ResourceBatchEditRequest request, String userId, String orgId) { + BaseField field = orderFieldService.getAndCheckField(request.getFieldId(), orgId); + moduleFormService.setFieldBusinessParam(field); + List originOrders = orderMapper.selectByIds(request.getIds()); + if (CollectionUtils.isEmpty(originOrders)) { + return BatchAffectReasonResponse.builder().success(0).fail(0).skip(0).errorMessages(Translator.get("order_not_exist")).build(); + } + + // 校验状态权限,过滤出有权限操作的订单 + List permittedIds = approvalFlowService.filterResourcesWithPermission( + ApprovalFormTypeEnum.ORDER.getValue(), + originOrders, + PermissionConstants.ORDER_UPDATE, + orgId, + Order::getId, + Order::getApprovalStatus + ); + + if (CollectionUtils.isEmpty(permittedIds)) { + return BatchAffectReasonResponse.builder().success(0).fail(originOrders.size()).skip(0).errorMessages(Translator.get("no.operation.permission")).build(); + } + ApprovalResourceService approvalResourceService = CommonBeanFactory.getBean(ApprovalResourceService.class); + approvalResourceService.batchEditTriggerApproval(permittedIds, request.getFieldId(), FormKey.ORDER, orgId, userId, field.getName(), request.getFieldValue()); + + List permittedOrders = originOrders.stream() + .filter(o -> permittedIds.contains(o.getId())) + .collect(Collectors.toList()); + + ResourceBatchEditRequest filteredRequest = new ResourceBatchEditRequest(); + filteredRequest.setIds(permittedIds); + filteredRequest.setFieldId(request.getFieldId()); + filteredRequest.setFieldValue(request.getFieldValue()); + + orderFieldService.batchUpdate(filteredRequest, field, permittedOrders, Order.class, LogModule.ORDER_INDEX, extOrderMapper::batchUpdate, userId, orgId); + + ModuleFormConfigDTO moduleFormConfigDTO = getFormConfig(orgId); + ModuleFormConfigDTO saveModuleFormConfigDTO = JSON.parseObject(JSON.toJSONString(moduleFormConfigDTO), ModuleFormConfigDTO.class); + + LambdaQueryWrapper delWrapper = new LambdaQueryWrapper<>(); + delWrapper.in(OrderSnapshot::getOrderId, permittedIds); + snapshotBaseMapper.deleteByLambda(delWrapper); + + List latestOrders = orderMapper.selectByIds(permittedIds); + Map latestOrderMap = latestOrders.stream().collect(Collectors.toMap(Order::getId, item -> item)); + Map> fieldMap = orderFieldService.getResourceFieldMap(permittedIds, true); + + List snapshots = new ArrayList<>(); + for (String id : permittedIds) { + Order order = latestOrderMap.get(id); + if (order == null) { + continue; + } + List orderFields = fieldMap.getOrDefault(id, new ArrayList<>()); + List resolveFieldValues = moduleFormService.resolveSnapshotFields(orderFields, moduleFormConfigDTO, orderFieldService, id); + OrderGetResponse response = get(order, resolveFieldValues, moduleFormConfigDTO); + if (CollectionUtils.isNotEmpty(response.getModuleFields())) { + response.setModuleFields(response.getModuleFields().stream() + .filter(f -> f.getFieldValue() != null + && StringUtils.isNotBlank(f.getFieldValue().toString()) + && !"[]".equals(f.getFieldValue().toString())) + .toList()); + } + OrderSnapshot snapshot = new OrderSnapshot(); + snapshot.setId(IDGenerator.nextStr()); + snapshot.setOrderId(id); + snapshot.setOrderProp(JSON.toJSONString(saveModuleFormConfigDTO)); + snapshot.setOrderValue(JSON.toJSONString(response)); + snapshots.add(snapshot); + } + if (CollectionUtils.isNotEmpty(snapshots)) { + snapshotBaseMapper.batchInsert(snapshots); + } + + return BatchAffectReasonResponse.builder().success(permittedIds.size()).fail(originOrders.size() - permittedIds.size()).skip(0).errorMessages(Translator.get("batch.update.reason")).build(); + } + + public void download(String id, String userId, String organizationId) { + OrderGetResponse getResponse = get(id, organizationId); + if (getResponse == null) { + throw new GenericException(Translator.get("order_not_exist")); + } + + LogDTO logDTO = new LogDTO(organizationId, id, userId, LogType.DOWNLOAD, LogModule.ORDER_INDEX, getResponse.getName()); + logDTO.setOriginalValue(getResponse.getName()); + logService.add(logDTO); + } + + + /** + * 统计 + * + * @param request + * @param userId + * @param orgId + * @param deptDataPermission + * @return + */ + public OrderStatisticResponse searchStatistic(BaseCondition request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { + OrderStatisticResponse response = extOrderMapper.searchStatistic(request, orgId, userId, deptDataPermission); + return Optional.ofNullable(response).orElse(new OrderStatisticResponse()); + } + + + /** + * 通过ID集合获取订单名称 + * + * @param ids id集合 + * @return 工商表头名称 + */ + public Object getOrderNameByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return StringUtils.EMPTY; + } + List records = orderMapper.selectByIds(ids); + if (CollectionUtils.isNotEmpty(records)) { + List names = records.stream().map(Order::getName).toList(); + return String.join(",", names); + } + return StringUtils.EMPTY; + } + + + /** + * 通过名称获取订单集合 + * + * @param names 名称 + * @return 订单名称 + */ + public List getOrderListByNames(List names) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.in(Order::getName, names); + return orderMapper.selectListByLambda(lambdaQueryWrapper); + } + + public Object getOrderName(String id) { + Order order = orderMapper.selectByPrimaryKey(id); + if (order != null) { + return order.getName(); + } + return null; + } + + + /** + * 阶段看板排序 + * + * @param request + * @param userId + */ + public void sort(StageSortRequest request, String userId) { + //拖拽节点 + Order order = orderMapper.selectByPrimaryKey(request.getDragNodeId()); + if (order == null) { + throw new GenericException(Translator.get("order_not_exist")); + } + Long pos = DEFAULT_POS; + if (StringUtils.isNotBlank(request.getDropNodeId())) { + //放入节点 + Order dropNode = orderMapper.selectByPrimaryKey(request.getDropNodeId()); + pos = dropNode.getPos(); + if (request.getDropPosition() == -1) { + + extOrderMapper.moveUpStageOrder(pos, request.getStage(), DEFAULT_POS); + pos = pos + 1; + } else { + extOrderMapper.moveDownStageOrder(pos, request.getStage(), DEFAULT_POS); + } + } + + if (!stageAdvancedConfigService.checkStage(order.getStage(), request.getStage(), FormKey.ORDER.getKey())) { + return; + } + + order.setPos(pos); + order.setStage(request.getStage()); + orderMapper.updateById(order); + updateFieldAndSnapshot(order, request.getFields(), userId); + + } + + /** + * 处理旧版本审批状态 (APPROVING => NONE) + */ + public void handleOldApprovalData() { + List orders = orderMapper.selectAll(null); + orders.forEach(order -> { + ResourceSnapshotApprovalParam param = ResourceSnapshotApprovalParam + .builder() + .resourceId(order.getId()) + .approvalStatus(ApprovalStatus.NONE.name()) + .build(); + updateSnapshotApprovalStatus(param); + }); + extOrderMapper.updateOldApprovalStatusNone(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/order/service/OrderStageService.java b/backend/crm/src/main/java/cn/cordys/crm/order/service/OrderStageService.java new file mode 100644 index 0000000..607a7c9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/order/service/OrderStageService.java @@ -0,0 +1,286 @@ +package cn.cordys.crm.order.service; + + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.stage.*; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.order.domain.OrderStageConfig; +import cn.cordys.crm.order.mapper.ExtOrderMapper; +import cn.cordys.crm.order.mapper.ExtOrderStageConfigMapper; +import cn.cordys.crm.system.domain.StageAdvancedConfig; +import cn.cordys.crm.system.mapper.ExtStageAdvancedConfigMapper; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class OrderStageService { + + public static final Long DEFAULT_POS = 1L; + @Resource + private ExtOrderStageConfigMapper extOrderStageConfigMapper; + @Resource + private ExtOrderMapper extOrderMapper; + @Resource + private BaseMapper orderStageConfigMapper; + @Resource + private ExtStageAdvancedConfigMapper extStageAdvancedConfigMapper; + + + /** + * 订单状态配置列表 + * + * @param orgId + * @return + */ + public StageConfigsResponse getStageConfigList(String orgId) { + StageConfigsResponse stageConfigListResponse = new StageConfigsResponse(); + List stageConfigList = extOrderStageConfigMapper.getStageConfigList(orgId); + List advancedConfigs = extStageAdvancedConfigMapper.selectConfigByType(orgId, FormKey.ORDER.getKey()); + buildList(stageConfigList, stageConfigListResponse, advancedConfigs); + return stageConfigListResponse; + } + + private void buildList(List stageConfigList, StageConfigsResponse response, List advancedConfigs) { + response.setStageConfigList(stageConfigList); + if (CollectionUtils.isNotEmpty(stageConfigList)) { + var first = stageConfigList.getFirst(); + response.setEndRollBack(first.getEndRollBack()); + response.setAfootRollBack(first.getAfootRollBack()); + stageConfigList.forEach(sc -> sc.setStageHasData(extOrderMapper.countByStage(sc.getId()) > 0)); + response.setCirculationType(first.getCirculationType()); + } + if (CollectionUtils.isNotEmpty(advancedConfigs)) { + Map> originIdMaps = advancedConfigs.stream().collect(Collectors.groupingBy(StageAdvancedConfig::getOriginId)); + List configs = new ArrayList<>(); + originIdMaps.forEach((key, value) -> { + CirculationSetting configResponse = new CirculationSetting(); + List targetList = new ArrayList<>(); + configResponse.setOriginId(key); + value.forEach(item -> { + Target target = new Target(); + target.setTargetId(item.getTargetId()); + target.setEnable(item.getEnable()); + List circulationFieldValues = JSON.parseObject(item.getFieldConfig(), new TypeReference>() { + }); + target.setCirculationFieldValues(circulationFieldValues); + targetList.add(target); + }); + configResponse.setTargets(targetList); + configResponse.setModuleType(value.getFirst().getModuleType()); + configs.add(configResponse); + }); + + Map configMap = configs.stream() + .collect(Collectors.toMap( + CirculationSetting::getOriginId, + Function.identity() + )); + + List sortedConfigs = stageConfigList.stream() + .map(stage -> configMap.get(stage.getId())) + .filter(Objects::nonNull) + .toList(); + + response.setAdvancedConfigs(sortedConfigs); + } + + } + + + /** + * 添加订单状态配置 + * + * @param request + * @param userId + * @param orgId + * @return + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.ADD) + public String addStageConfig(StageAddRequest request, String userId, String orgId) { + checkConfigCount(orgId); + Long pos = DEFAULT_POS; + Boolean afootRollBack = true; + Boolean endRollBack = false; + //源节点 + OrderStageConfig target = orderStageConfigMapper.selectByPrimaryKey(request.getTargetId()); + if (target != null) { + pos = target.getPos(); + //target正常不会为空 + if (request.getDropPosition() == -1) { + extOrderStageConfigMapper.moveUpStageConfig(pos, orgId, DEFAULT_POS); + } else { + extOrderStageConfigMapper.moveDownStageConfig(pos, orgId, DEFAULT_POS); + pos = pos + 1; + } + afootRollBack = target.getAfootRollBack(); + endRollBack = target.getEndRollBack(); + } + + OrderStageConfig stageConfig = new OrderStageConfig(); + stageConfig.setId(IDGenerator.nextStr()); + stageConfig.setName(request.getName()); + stageConfig.setType(request.getType()); + stageConfig.setAfootRollBack(afootRollBack); + stageConfig.setEndRollBack(endRollBack); + stageConfig.setPos(pos); + stageConfig.setOrganizationId(orgId); + stageConfig.setCirculationType(target.getCirculationType()); + stageConfig.setCreateUser(userId); + stageConfig.setUpdateUser(userId); + stageConfig.setCreateTime(System.currentTimeMillis()); + stageConfig.setUpdateTime(System.currentTimeMillis()); + orderStageConfigMapper.insert(stageConfig); + + OperationLogContext.setContext(LogContextInfo.builder() + .modifiedValue(stageConfig) + .resourceId(stageConfig.getId()) + .resourceName(Translator.get("order_stage_setting").concat(":").concat(request.getName())) + .build()); + + return stageConfig.getId(); + + } + + /** + * 配置数量校验 + * + * @param orgId + */ + private void checkConfigCount(String orgId) { + if (extOrderStageConfigMapper.countStageConfig(orgId) > 10) { + throw new GenericException(Translator.get("order_stage_config_list")); + } + } + + + /** + * 删除 + * + * @param id + * @param orgId + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id, String orgId) { + OrderStageConfig stageConfig = deletePreCheck(id, orgId); + orderStageConfigMapper.deleteByPrimaryKey(id); + // 设置操作对象 + OperationLogContext.setResourceName(Translator.get("order_stage_setting").concat(":").concat(stageConfig.getName())); + } + + + private OrderStageConfig deletePreCheck(String id, String orgId) { + OrderStageConfig stageConfig = orderStageConfigMapper.selectByPrimaryKey(id); + if (stageConfig == null) { + throw new GenericException(Translator.get("order_stage_delete")); + } + return stageConfig; + } + + + /** + * 更新订单流阶段回退设置 + * + * @param request + * @param orgId + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE) + public void updateRollBack(StageRollBackRequest request, String orgId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(OrderStageConfig::getOrganizationId, orgId); + List stageConfigList = orderStageConfigMapper.selectListByLambda(wrapper); + extOrderStageConfigMapper.updateRollBack(request, orgId); + + Map originalVal = new HashMap<>(1); + originalVal.put("afootRollBack", Translator.get("log.enable.".concat(stageConfigList.getFirst().getAfootRollBack().toString()))); + originalVal.put("endRollBack", Translator.get("log.enable.".concat(stageConfigList.getFirst().getEndRollBack().toString()))); + Map modifiedVal = new HashMap<>(1); + modifiedVal.put("afootRollBack", Translator.get("log.enable.".concat(request.getAfootRollBack().toString()))); + modifiedVal.put("endRollBack", Translator.get("log.enable.".concat(request.getEndRollBack().toString()))); + OperationLogContext.setContext(LogContextInfo.builder() + .originalValue(originalVal) + .resourceName(Translator.get("order_stage_setting")) + .modifiedValue(modifiedVal) + .resourceId(orgId) + .build()); + } + + + /** + * 更新配置 + * + * @param request + * @param userId + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE) + public void update(StageUpdateRequest request, String userId) { + OrderStageConfig oldStageConfig = orderStageConfigMapper.selectByPrimaryKey(request.getId()); + if (oldStageConfig == null) { + throw new GenericException(Translator.get("order_stage_not_exist")); + } + extOrderStageConfigMapper.updateStageConfig(request, userId); + + Map originalVal = new HashMap<>(1); + originalVal.put("orderStage", oldStageConfig.getName()); + Map modifiedVal = new HashMap<>(1); + modifiedVal.put("orderStage", request.getName()); + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(request.getId()) + .resourceName(Translator.get("order_stage_setting")) + .originalValue(originalVal) + .modifiedValue(modifiedVal) + .build() + ); + } + + + /** + * 排序 + * + * @param ids + * @param orgId + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE) + public void sort(List ids, String orgId) { + List oldStageConfigList = extOrderStageConfigMapper.getStageConfigList(orgId); + List oldNames = oldStageConfigList.stream().map(StageConfigResponse::getName).toList(); + + for (int i = 0; i < ids.size(); i++) { + extOrderStageConfigMapper.updatePos(ids.get(i), (long) (i + 1)); + } + + List newStageConfigList = extOrderStageConfigMapper.getStageConfigList(orgId); + List newNames = newStageConfigList.stream().map(StageConfigResponse::getName).toList(); + + Map> originalVal = new HashMap<>(1); + originalVal.put("stageSort", oldNames); + Map> modifiedVal = new HashMap<>(1); + modifiedVal.put("stageSort", newNames); + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(orgId) + .resourceName(Translator.get("order_stage_setting")) + .originalValue(originalVal) + .modifiedValue(modifiedVal) + .build() + ); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/controller/ProductController.java b/backend/crm/src/main/java/cn/cordys/crm/product/controller/ProductController.java new file mode 100644 index 0000000..42c2eb9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/controller/ProductController.java @@ -0,0 +1,141 @@ +package cn.cordys.crm.product.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.product.domain.Product; +import cn.cordys.crm.product.dto.request.ProductEditRequest; +import cn.cordys.crm.product.dto.request.ProductPageRequest; +import cn.cordys.crm.product.dto.response.ProductGetResponse; +import cn.cordys.crm.product.dto.response.ProductListResponse; +import cn.cordys.crm.product.service.ProductService; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotEmpty; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * @author guoyuqi + */ +@Tag(name = "产品") +@RestController +@RequestMapping("/product") +public class ProductController { + + @Resource + private ModuleFormCacheService moduleFormCacheService; + + @Resource + private ProductService productService; + + + @GetMapping("/module/form") + @Operation(summary = "获取产品表单配置") + public ModuleFormConfigDTO getModuleFormConfig() { + return moduleFormCacheService.getBusinessFormConfig(FormKey.PRODUCT.getKey(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/page") + @RequiresPermissions(PermissionConstants.PRODUCT_MANAGEMENT_READ) + @Operation(summary = "产品列表") + public PagerWithOption> list(@Validated @RequestBody ProductPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.PRODUCT.getKey()); + return productService.list(request, OrganizationContext.getOrganizationId()); + } + + @GetMapping("/get/{id}") + @RequiresPermissions(PermissionConstants.PRODUCT_MANAGEMENT_READ) + @Operation(summary = "产品详情") + public ProductGetResponse get(@PathVariable String id) { + return productService.get(id); + } + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.PRODUCT_MANAGEMENT_ADD) + @Operation(summary = "添加产品") + public Product add(@Validated @RequestBody ProductEditRequest request) { + return productService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.PRODUCT_MANAGEMENT_UPDATE) + @Operation(summary = "更新产品") + public Product update(@Validated @RequestBody ProductEditRequest request) { + return productService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch/update") + @RequiresPermissions(PermissionConstants.PRODUCT_MANAGEMENT_UPDATE) + @Operation(summary = "批量更新产品") + public void batchUpdate(@Validated @RequestBody ResourceBatchEditRequest request) { + productService.batchUpdate(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.PRODUCT_MANAGEMENT_DELETE) + @Operation(summary = "删除产品") + public void delete(@PathVariable String id) { + productService.delete(id); + } + + + @PostMapping("/batch/delete") + @RequiresPermissions(PermissionConstants.PRODUCT_MANAGEMENT_DELETE) + @Operation(summary = "批量删除产品") + public void batchDelete(@RequestBody @NotEmpty List ids) { + productService.batchDelete(ids, SessionUtils.getUserId()); + } + + @PostMapping("/edit/pos") + @Operation(summary = "拖拽排序") + @RequiresPermissions(PermissionConstants.PRODUCT_MANAGEMENT_UPDATE) + public void editPos(@Validated @RequestBody PosRequest request) { + productService.editPos(request); + } + + @GetMapping("/template/download") + @RequiresPermissions(PermissionConstants.PRODUCT_MANAGEMENT_IMPORT) + @Operation(summary = "下载导入模板") + public void downloadImportTpl(HttpServletResponse response) { + productService.downloadImportTpl(response, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/import/pre-check") + @Operation(summary = "导入检查") + @RequiresPermissions(PermissionConstants.PRODUCT_MANAGEMENT_IMPORT) + public ImportResponse preCheck(@RequestPart(value = "file") MultipartFile file) { + return productService.importPreCheck(file, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/import") + @Operation(summary = "导入产品") + @RequiresPermissions(PermissionConstants.PRODUCT_MANAGEMENT_IMPORT) + public ImportResponse realImport(@RequestPart(value = "file") MultipartFile file) { + return productService.realImport(file, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @GetMapping("/list/option") + @Operation(summary = "获取当前组织下全部产品的id,和name集合") + public List listOption() { + return productService.listOption(OrganizationContext.getOrganizationId()); + } + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/controller/ProductPriceController.java b/backend/crm/src/main/java/cn/cordys/crm/product/controller/ProductPriceController.java new file mode 100644 index 0000000..8211e43 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/controller/ProductPriceController.java @@ -0,0 +1,159 @@ +package cn.cordys.crm.product.controller; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.ExportDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.product.domain.ProductPrice; +import cn.cordys.crm.product.dto.request.ProductPriceAddRequest; +import cn.cordys.crm.product.dto.request.ProductPriceEditRequest; +import cn.cordys.crm.product.dto.request.ProductPriceExportRequest; +import cn.cordys.crm.product.dto.request.ProductPricePageRequest; +import cn.cordys.crm.product.dto.response.ProductPriceGetResponse; +import cn.cordys.crm.product.dto.response.ProductPriceResponse; +import cn.cordys.crm.product.service.ProductPriceExportService; +import cn.cordys.crm.product.service.ProductPriceService; +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * @author song-cc-rock + */ +@RestController +@Tag(name = "价格表") +@RequestMapping("/price") +public class ProductPriceController { + + @Resource + private ProductPriceService priceService; + @Resource + private ProductPriceExportService priceExportService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + + @GetMapping("/module/form") + @Operation(summary = "获取表单配置") + public ModuleFormConfigDTO getModuleFormConfig() { + return moduleFormCacheService.getBusinessFormConfig(FormKey.PRICE.getKey(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/page") + @RequiresPermissions(PermissionConstants.PRICE_READ) + @Operation(summary = "价格列表") + public PagerWithOption> list(@Validated @RequestBody ProductPricePageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.PRICE.getKey()); + return priceService.list(request, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.PRICE_ADD) + @Operation(summary = "添加价格表") + public ProductPrice add(@Validated @RequestBody ProductPriceAddRequest request) { + return priceService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/copy/{id}") + @RequiresPermissions(PermissionConstants.PRICE_ADD) + @Operation(summary = "复制价格表") + public ProductPrice copy(@PathVariable String id) { + return priceService.copy(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.PRICE_UPDATE) + @Operation(summary = "修改价格表") + public ProductPrice update(@Validated @RequestBody ProductPriceEditRequest request) { + return priceService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/get/{id}") + @RequiresPermissions(PermissionConstants.PRICE_READ) + @Operation(summary = "价格表详情") + public ProductPriceGetResponse get(@PathVariable String id) { + return priceService.get(id); + } + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.PRICE_DELETE) + @Operation(summary = "删除价格表") + public void delete(@PathVariable String id) { + priceService.delete(id); + } + + @PostMapping("/batch/update") + @RequiresPermissions(PermissionConstants.PRICE_UPDATE) + @Operation(summary = "批量更新价格表") + public void batchUpdate(@Validated @RequestBody ResourceBatchEditRequest request) { + priceService.batchUpdate(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/edit/pos") + @Operation(summary = "拖拽排序") + @RequiresPermissions(PermissionConstants.PRICE_UPDATE) + public void editPos(@Validated @RequestBody PosRequest request) { + priceService.editPos(request); + } + + @GetMapping("/template/download") + @RequiresPermissions(PermissionConstants.PRICE_IMPORT) + @Operation(summary = "下载导入模板") + public void downloadImportTpl(HttpServletResponse response) { + priceService.downloadImportTpl(response, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/import/pre-check") + @Operation(summary = "导入检查") + @RequiresPermissions(PermissionConstants.PRICE_IMPORT) + public ImportResponse preCheck(@RequestPart(value = "file") @NotNull MultipartFile file) { + return priceService.importPreCheck(file, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/import") + @Operation(summary = "导入") + @RequiresPermissions(PermissionConstants.PRICE_IMPORT) + public ImportResponse realImport(@RequestPart(value = "file") MultipartFile file) { + return priceService.realImport(file, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/export") + @RequiresPermissions(PermissionConstants.PRICE_EXPORT) + @Operation(summary = "导出全部") + public String exportAll(@Validated @RequestBody ProductPriceExportRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.PRICE.getKey()); + ExportDTO exportParam = ExportDTO.builder().formKey(FormKey.PRICE.getKey()).fileName(request.getFileName()).headList(request.getHeadList()) + .orgId(OrganizationContext.getOrganizationId()).userId(SessionUtils.getUserId()).exportType(ExportConstants.ExportType.PRODUCT_PRICE.name()) + .locale(LocaleContextHolder.getLocale()).pageRequest(request).logModule(LogModule.PRODUCT_PRICE_MANAGEMENT).build(); + return priceExportService.exportAllWithMergeStrategy(exportParam); + } + + @PostMapping("/export-select") + @RequiresPermissions(PermissionConstants.PRICE_EXPORT) + @Operation(summary = "导出选中") + public String exportSelect(@Validated @RequestBody ExportSelectRequest request) { + ExportDTO exportParam = ExportDTO.builder().formKey(FormKey.PRICE.getKey()).fileName(request.getFileName()).headList(request.getHeadList()) + .orgId(OrganizationContext.getOrganizationId()).userId(SessionUtils.getUserId()).exportType(ExportConstants.ExportType.PRODUCT_PRICE.name()) + .locale(LocaleContextHolder.getLocale()).selectIds(request.getIds()).logModule(LogModule.PRODUCT_PRICE_MANAGEMENT).build(); + return priceExportService.exportSelectWithMergeStrategy(exportParam); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/domain/Product.java b/backend/crm/src/main/java/cn/cordys/crm/product/domain/Product.java new file mode 100644 index 0000000..4221fe3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/domain/Product.java @@ -0,0 +1,34 @@ +package cn.cordys.crm.product.domain; + +import cn.cordys.common.domain.BaseModel; +import cn.cordys.common.util.BigDecimalNoTrailingZeroSerializer; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@Table(name = "product") +public class Product extends BaseModel { + @Schema(description = "组织机构id") + private String organizationId; + + @Schema(description = "产品名称") + private String name; + + @Schema(description = "价格") + @DecimalMin(value = "0.00", inclusive = false, message = "{product.price.min}") + @DecimalMax(value = "10000000000.00", inclusive = false, message = "{product.price.max}") + @JsonSerialize(using = BigDecimalNoTrailingZeroSerializer.class) + private BigDecimal price; + + @Schema(description = "状态") + private String status; + + @Schema(description = "排序") + private Long pos; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/domain/ProductField.java b/backend/crm/src/main/java/cn/cordys/crm/product/domain/ProductField.java new file mode 100644 index 0000000..4a43c26 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/domain/ProductField.java @@ -0,0 +1,10 @@ +package cn.cordys.crm.product.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "product_field") +public class ProductField extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/domain/ProductFieldBlob.java b/backend/crm/src/main/java/cn/cordys/crm/product/domain/ProductFieldBlob.java new file mode 100644 index 0000000..58d21b2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/domain/ProductFieldBlob.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.product.domain; + +import cn.cordys.common.domain.BaseResourceField; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 产品自定义属性大文本 + * + * @author jianxing + * @date 2025-02-27 14:43:46 + */ +@Data +@Table(name = "product_field_blob") +public class ProductFieldBlob extends BaseResourceField { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/domain/ProductPrice.java b/backend/crm/src/main/java/cn/cordys/crm/product/domain/ProductPrice.java new file mode 100644 index 0000000..5508ca2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/domain/ProductPrice.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.product.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * 产品价格表 + * + * @author song-cc-rock + */ + +@Data +@Table(name = "product_price") +public class ProductPrice extends BaseModel { + + @Schema(description = "价格表名称") + private String name; + + @Schema(description = "状态") + private String status; + + @Schema(description = "排序") + private Long pos; + + @Schema(description = "组织ID") + private String organizationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/domain/ProductPriceField.java b/backend/crm/src/main/java/cn/cordys/crm/product/domain/ProductPriceField.java new file mode 100644 index 0000000..51f45f4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/domain/ProductPriceField.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.product.domain; + +import cn.cordys.common.domain.BaseResourceSubField; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * 产品价格表自定义属性 + * + * @author song-cc-rock + */ + +@Data +@Table(name = "product_price_field") +public class ProductPriceField extends BaseResourceSubField { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/domain/ProductPriceFieldBlob.java b/backend/crm/src/main/java/cn/cordys/crm/product/domain/ProductPriceFieldBlob.java new file mode 100644 index 0000000..8d716a9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/domain/ProductPriceFieldBlob.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.product.domain; + +import cn.cordys.common.domain.BaseResourceSubField; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * 产品价格表自定义属性 (部分大文本) + * + * @author song-cc-rock + */ + +@Data +@Table(name = "product_price_field_blob") +public class ProductPriceFieldBlob extends BaseResourceSubField { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductEditRequest.java b/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductEditRequest.java new file mode 100644 index 0000000..16adee9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductEditRequest.java @@ -0,0 +1,37 @@ +package cn.cordys.crm.product.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + + +/** + * @author guoyuqi + */ +@Data +public class ProductEditRequest { + + @Schema(description = "id") + @Size(max = 32) + private String id; + + @Schema(description = "产品名称") + private String name; + + @Schema(description = "价格") + @DecimalMin(value = "0.00", inclusive = false, message = "{product.price.min}") + @DecimalMax(value = "10000000000.00", inclusive = false, message = "{product.price.max}") + private BigDecimal price; + + @Schema(description = "状态") + private String status; + + @Schema(description = "自定义字段") + private List moduleFields; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductPageRequest.java new file mode 100644 index 0000000..b93bf0f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductPageRequest.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.product.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +/** + * @author guoyuqi + */ +@Data +public class ProductPageRequest extends BasePageRequest { + + @Schema(description = "状态:1-上架,2-下架") + private String status; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductPriceAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductPriceAddRequest.java new file mode 100644 index 0000000..e4df834 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductPriceAddRequest.java @@ -0,0 +1,34 @@ +package cn.cordys.crm.product.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * @author song-cc-rock + */ + +@Data +public class ProductPriceAddRequest { + + @NotBlank(message = "{product.price.name.required}") + @Size(max = 255) + @Schema(description = "价格表名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @NotBlank(message = "{product.price.status.required}") + @Size(max = 32) + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED) + private String status; + + @Schema(description = "自定义字段值") + private List moduleFields; + + @Schema(description = "子产品信息") + private List> products; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductPriceEditRequest.java b/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductPriceEditRequest.java new file mode 100644 index 0000000..768f85e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductPriceEditRequest.java @@ -0,0 +1,39 @@ +package cn.cordys.crm.product.dto.request; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * @author song-cc-rock + */ + +@Data +public class ProductPriceEditRequest { + + @NotBlank(message = "{product.price.id.required}") + @Size(max = 32) + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @NotBlank(message = "{product.price.name.required}") + @Size(max = 255) + @Schema(description = "价格表名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @NotBlank(message = "{product.price.status.required}") + @Size(max = 32) + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED) + private String status; + + @Schema(description = "自定义字段值") + private List moduleFields; + + @Schema(description = "子产品信息") + private List> products; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductPriceExportRequest.java b/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductPriceExportRequest.java new file mode 100644 index 0000000..2e64242 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductPriceExportRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.product.dto.request; + +import cn.cordys.common.dto.ExportHeadDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.List; + +/** + * @author song-cc-rock + */ +@Data +public class ProductPriceExportRequest extends ProductPricePageRequest{ + + @NotBlank + @Schema(description = "文件名", requiredMode = Schema.RequiredMode.REQUIRED) + private String fileName; + + @Schema(description = "表头信息", requiredMode = Schema.RequiredMode.REQUIRED) + private List headList; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductPricePageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductPricePageRequest.java new file mode 100644 index 0000000..85db87d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/dto/request/ProductPricePageRequest.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.product.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import lombok.Data; + +/** + * @author song-cc-rock + */ +@Data +public class ProductPricePageRequest extends BasePageRequest { + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/dto/response/ProductGetResponse.java b/backend/crm/src/main/java/cn/cordys/crm/product/dto/response/ProductGetResponse.java new file mode 100644 index 0000000..8e682b7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/dto/response/ProductGetResponse.java @@ -0,0 +1,61 @@ +package cn.cordys.crm.product.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Data +public class ProductGetResponse { + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "产品名称") + private String name; + + @Schema(description = "价格") + private BigDecimal price; + + @Schema(description = "状态") + private String status; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "自定义字段") + private List moduleFields; + + @Schema(description = "选项集合") + private Map> optionMap; + + /** + * 附件集合 + */ + @Schema(description = "附件集合") + private Map> attachmentMap; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/dto/response/ProductListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/product/dto/response/ProductListResponse.java new file mode 100644 index 0000000..11cd069 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/dto/response/ProductListResponse.java @@ -0,0 +1,48 @@ +package cn.cordys.crm.product.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + + +/** + * @author guoyuqi + */ +@Data +public class ProductListResponse { + @Schema(description = "ID") + private String id; + + @Schema(description = "产品名称") + private String name; + + @Schema(description = "价格") + private BigDecimal price; + + @Schema(description = "状态") + private String status; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "自定义字段集合") + private List moduleFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/dto/response/ProductPriceGetResponse.java b/backend/crm/src/main/java/cn/cordys/crm/product/dto/response/ProductPriceGetResponse.java new file mode 100644 index 0000000..f72399a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/dto/response/ProductPriceGetResponse.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.product.dto.response; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.system.domain.Attachment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * @author song-cc-rock + */ +@Data +public class ProductPriceGetResponse extends ProductPriceResponse{ + + @Schema(description = "选项集合") + private Map> optionMap; + + @Schema(description = "附件集合") + private Map> attachmentMap; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/dto/response/ProductPriceResponse.java b/backend/crm/src/main/java/cn/cordys/crm/product/dto/response/ProductPriceResponse.java new file mode 100644 index 0000000..4ee5018 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/dto/response/ProductPriceResponse.java @@ -0,0 +1,49 @@ +package cn.cordys.crm.product.dto.response; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + + +/** + * @author song-cc-rock + */ +@Data +public class ProductPriceResponse { + @Schema(description = "ID") + private String id; + + @Schema(description = "价格表名称") + private String name; + + @Schema(description = "状态") + private String status; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "自定义字段集合") + private List moduleFields; + + @Schema(description = "产品子列表") + private List> products = new ArrayList<>(); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/mapper/ExtProductMapper.java b/backend/crm/src/main/java/cn/cordys/crm/product/mapper/ExtProductMapper.java new file mode 100644 index 0000000..ce9a6d4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/mapper/ExtProductMapper.java @@ -0,0 +1,35 @@ +package cn.cordys.crm.product.mapper; + +import cn.cordys.common.dto.BatchUpdateDbParam; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.product.domain.Product; +import cn.cordys.crm.product.dto.request.ProductPageRequest; +import cn.cordys.crm.product.dto.response.ProductListResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * @author jianxing + * @date 2025-02-08 17:42:41 + */ +public interface ExtProductMapper { + + List list(@Param("request") ProductPageRequest request, @Param("orgId") String orgId); + + List listIdNameByIds(@Param("ids") List ids); + + List listByIds(@Param("ids") List ids); + + void batchUpdate(@Param("request") BatchUpdateDbParam request); + + List getOptions(@Param("orgId") String orgId); + + List getProductOptions(@Param("keyword") String keyword, @Param("orgId") String orgId); + + Long getPos(@Param("orgId") String orgId); + + Long getPrePos(@Param("orgId") String orgId, @Param("basePos") Long basePos, @Param("userId") String userId, @Param("resourceType") String resourceType); + + Long getLastPos(@Param("orgId") String orgId, @Param("basePos") Long basePos, @Param("userId") String userId, @Param("resourceType") String resourceType); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/mapper/ExtProductMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/product/mapper/ExtProductMapper.xml new file mode 100644 index 0000000..b37a378 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/mapper/ExtProductMapper.xml @@ -0,0 +1,311 @@ + + + + + + UPDATE product + + + name = #{request.fieldValue}, + + + price = #{request.fieldValue}, + + + status = #{request.fieldValue}, + + + pos = #{request.fieldValue}, + + update_time = #{request.updateTime}, + update_user = #{request.updateUser}, + + WHERE id IN + + #{id} + + + + + + + + + + + + + + + + + + + + + + + inner join product_field_blob ${tablePrefix}_${i} + on p.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + inner join product_field ${tablePrefix}_${i} + on p.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + + + + + + + + + + left join product_field sort_table + on p.id = sort_table.resource_id and sort_table.field_id = #{sortName} + left join sys_module_field module_table on sort_table.field_id = module_table.id + + + + + + + + order by + + + p.`name` ${sortType}, p.id ASC + + + p.`price` ${sortType}, p.id ASC + + + p.`status` ${sortType}, p.id ASC + + + p.`create_time` ${sortType}, p.id ASC + + + p.`update_time` ${sortType}, p.id ASC + + + p.`create_user` ${sortType}, p.id ASC + + + p.`update_user` ${sortType}, p.id ASC + + + CASE + WHEN module_table.type = 'INPUT_NUMBER' OR module_table.type = 'FORMULA' + THEN sort_table.field_value+0 + END ${sortType}, + sort_table.field_value ${sortType}, + p.id ${sortType} + + + + + order by p.pos + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/mapper/ExtProductPriceMapper.java b/backend/crm/src/main/java/cn/cordys/crm/product/mapper/ExtProductPriceMapper.java new file mode 100644 index 0000000..1c92a0c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/mapper/ExtProductPriceMapper.java @@ -0,0 +1,63 @@ +package cn.cordys.crm.product.mapper; + +import cn.cordys.common.dto.BatchUpdateDbParam; +import cn.cordys.crm.product.dto.request.ProductPricePageRequest; +import cn.cordys.crm.product.dto.response.ProductPriceResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * @author song-cc-rock + */ +public interface ExtProductPriceMapper { + + /** + * 查询价格列表 + * + * @param request 请求参数 + * @param currentOrg 当前组织 + * @return 价格列表 + */ + List list(@Param("request") ProductPricePageRequest request, @Param("currentOrg") String currentOrg); + + /** + * 获取位置值 + * @param orgId 组织ID + * @return 位置值 + */ + Long getPos(@Param("orgId") String orgId); + + /** + * 获取前置值 + * @param orgId 组织ID + * @param basePos 基础值 + * @param userId 用户ID + * @param resourceType 资源类型 + * @return 位置值 + */ + Long getPrePos(@Param("orgId") String orgId, @Param("basePos") Long basePos, @Param("userId") String userId, @Param("resourceType") String resourceType); + + /** + * 获取后置位置值 + * @param orgId 组织ID + * @param basePos 基础值 + * @param userId 用户ID + * @param resourceType 资源类型 + * @return 位置值 + */ + Long getLastPos(@Param("orgId") String orgId, @Param("basePos") Long basePos, @Param("userId") String userId, @Param("resourceType") String resourceType); + + /** + * 批量更新 + * @param request 请求参数 + */ + void batchUpdate(@Param("request") BatchUpdateDbParam request); + + /** + * 根据ID列表查询价格表信息 + * @param ids ID列表 + * @return 价格信息列表 + */ + List selectByIds(@Param("ids") List ids); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/mapper/ExtProductPriceMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/product/mapper/ExtProductPriceMapper.xml new file mode 100644 index 0000000..a049203 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/mapper/ExtProductPriceMapper.xml @@ -0,0 +1,299 @@ + + + + + + + + + + + + + + + + + + + + + + + + left join product_price_field_blob ${tablePrefix}_${i} + on pp.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + left join product_price_field ${tablePrefix}_${i} + on pp.id = ${tablePrefix}_${i}.resource_id and ${tablePrefix}_${i}.field_id = #{condition.name} + + + + + + + + + + + + left join product_price_field sort_table + on pp.id = sort_table.resource_id and sort_table.field_id = #{sortName} + left join sys_module_field module_table on sort_table.field_id = module_table.id + + + + + + + + order by + + + pp.`name` ${sortType}, pp.id ASC + + + pp.`status` ${sortType}, pp.id ASC + + + pp.`create_time` ${sortType}, pp.id ASC + + + pp.`update_time` ${sortType}, pp.id ASC + + + pp.`create_user` ${sortType}, pp.id ASC + + + pp.`update_user` ${sortType}, pp.id ASC + + + CASE + WHEN module_table.type = 'INPUT_NUMBER' OR module_table.type = 'FORMULA' + THEN sort_table.field_value+0 + END ${sortType}, + sort_table.field_value ${sortType}, + pp.id ${sortType} + + + + + order by pp.pos + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + update product_price + + + name = #{request.fieldValue}, + + + `status` = #{request.fieldValue}, + + + pos = #{request.fieldvalue}, + + update_time = #{request.updateTime}, + update_user = #{request.updateUser}, + + where id in + + #{id} + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/service/PriceLogService.java b/backend/crm/src/main/java/cn/cordys/crm/product/service/PriceLogService.java new file mode 100644 index 0000000..8fe4026 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/service/PriceLogService.java @@ -0,0 +1,61 @@ +package cn.cordys.crm.product.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.HasOption; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.BaseModuleLogService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@Transactional(rollbackFor = Exception.class) +public class PriceLogService extends BaseModuleLogService { + + @Resource + private ModuleFormCacheService moduleFormCacheService; + + @Override + public List handleLogField(List differences, String orgId) { + differences = super.handleModuleLogField(differences, orgId, FormKey.PRICE.getKey()); + + for (JsonDifferenceDTO differ : differences) { + if (Strings.CS.equals(differ.getColumn(), BusinessModuleField.PRICE_STATUS.getBusinessKey())) { + setPriceStatus(differ, orgId); + } + if (differ.getColumn().contains("-")) { + differ.setColumnName(differ.getColumn()); + } + } + + return differences; + } + + + /** + * 产品 + * + * @param differ businessModuleField + */ + private void setPriceStatus(JsonDifferenceDTO differ, String orgId) { + ModuleFormConfigDTO priceConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.PRICE.getKey(), orgId); + Optional statusField = priceConfig.getFields().stream().filter(f -> Strings.CS.equals(BusinessModuleField.PRICE_STATUS.getKey(), f.getInternalKey())).findFirst(); + if (statusField.isEmpty()) { + return; + } + List options = ((HasOption) statusField.get()).getOptions(); + differ.setNewValueName(options.stream() + .filter(optionProp -> Strings.CS.equals(optionProp.getValue(), differ.getNewValue().toString())).map(OptionProp::getLabel)); + differ.setOldValueName(options.stream() + .filter(optionProp -> Strings.CS.equals(optionProp.getValue(), differ.getOldValue().toString())).map(OptionProp::getLabel)); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductFieldService.java b/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductFieldService.java new file mode 100644 index 0000000..6d12ec5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductFieldService.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.product.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.crm.product.domain.ProductField; +import cn.cordys.crm.product.domain.ProductFieldBlob; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author jianxing + * @date 2025-02-08 16:24:22 + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class ProductFieldService extends BaseResourceFieldService { + @Resource + private BaseMapper productFieldMapper; + @Resource + private BaseMapper productFieldBlobMapper; + + @Override + protected String getFormKey() { + return FormKey.PRODUCT.getKey(); + } + + @Override + protected BaseMapper getResourceFieldMapper() { + return productFieldMapper; + } + + @Override + protected BaseMapper getResourceFieldBlobMapper() { + return productFieldBlobMapper; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductLogService.java b/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductLogService.java new file mode 100644 index 0000000..278ba61 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductLogService.java @@ -0,0 +1,162 @@ +package cn.cordys.crm.product.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.domain.ModuleField; +import cn.cordys.crm.system.domain.ModuleForm; +import cn.cordys.crm.system.service.BaseModuleLogService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.text.SimpleDateFormat; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +@Service +@Transactional(rollbackFor = Exception.class) +public class ProductLogService extends BaseModuleLogService { + + private static final String PRODUCT_PICTURE = "productPic"; + private static final String DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss"; + + @Resource + private BaseMapper moduleFieldBaseMapper; + @Resource + private BaseMapper moduleFormBaseMapper; + + @Override + public List handleLogField(List differences, String orgId) { + + super.handleModuleLogField(differences, orgId, FormKey.PRODUCT.getKey()); + + ModuleContext context = buildModuleContext(orgId); + SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_TIME_FORMAT); + + for (JsonDifferenceDTO differ : differences) { + String column = differ.getColumn(); + + if (Strings.CS.equals(column, BusinessModuleField.PRODUCT_STATUS.getBusinessKey())) { + setProductStatusName(differ); + continue; + } + + if (Strings.CS.equals(column, context.pictureFieldId())) { + setProductPicName(differ); + continue; + } + + if (Strings.CS.equals(column, context.timeFieldId())) { + formatDateTime(differ, dateFormat); + continue; + } + + if (context.attachFieldIds().contains(column)) { + setAttachName(differ); + } + } + return differences; + } + + /** + * 构建模块字段上下文 + */ + private ModuleContext buildModuleContext(String orgId) { + + Optional formOptional = moduleFormBaseMapper.selectListByLambda( + new LambdaQueryWrapper() + .eq(ModuleForm::getOrganizationId, orgId) + .eq(ModuleForm::getFormKey, FormKey.PRODUCT.getKey()) + ).stream().findFirst(); + + if (formOptional.isEmpty()) { + return ModuleContext.EMPTY; + } + + String formId = formOptional.get().getId(); + + List fields = moduleFieldBaseMapper.selectListByLambda( + new LambdaQueryWrapper() + .eq(ModuleField::getFormId, formId) + ); + + String pictureFieldId = fields.stream() + .filter(f -> Strings.CS.equals(f.getInternalKey(), PRODUCT_PICTURE)) + .map(ModuleField::getId) + .findFirst() + .orElse(null); + + String timeFieldId = fields.stream() + .filter(f -> Strings.CS.equals(f.getType(), FieldType.DATE_TIME.toString())) + .map(ModuleField::getId) + .findFirst() + .orElse(null); + + List attachFieldIds = fields.stream() + .filter(f -> Strings.CS.equals(f.getType(), FieldType.ATTACHMENT.toString())) + .map(ModuleField::getId) + .toList(); + + return new ModuleContext(pictureFieldId, timeFieldId, attachFieldIds); + } + + private void formatDateTime(JsonDifferenceDTO differ, SimpleDateFormat format) { + Optional.ofNullable(differ.getOldValue()) + .map(Object::toString) + .map(Long::parseLong) + .map(format::format) + .ifPresent(differ::setOldValueName); + + Optional.ofNullable(differ.getNewValue()) + .map(Object::toString) + .map(Long::parseLong) + .map(format::format) + .ifPresent(differ::setNewValueName); + } + + private void setAttachName(JsonDifferenceDTO differ) { + differ.setOldValueName(null); + differ.setNewValueName(Translator.get("common.attachment.changed")); + } + + /** + * 产品图片 + */ + private void setProductPicName(JsonDifferenceDTO differ) { + differ.setOldValueName(null); + differ.setNewValueName(Translator.get("common.picture.changed")); + } + + /** + * 产品上下架状态 + */ + private void setProductStatusName(JsonDifferenceDTO differ) { + differ.setOldValueName(resolveProductStatus(differ.getOldValue())); + differ.setNewValueName(resolveProductStatus(differ.getNewValue())); + } + + private String resolveProductStatus(Object value) { + return Strings.CI.equals(String.valueOf(value), "1") + ? Translator.get("product.shelves") + : Translator.get("product.unShelves"); + } + + /** + * 模块字段上下文(Java 17 record 风格,若低版本可改为普通类) + */ + private record ModuleContext( + String pictureFieldId, + String timeFieldId, + List attachFieldIds + ) { + static final ModuleContext EMPTY = + new ModuleContext(null, null, Collections.emptyList()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductPriceExportService.java b/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductPriceExportService.java new file mode 100644 index 0000000..990bd70 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductPriceExportService.java @@ -0,0 +1,145 @@ +package cn.cordys.crm.product.service; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.ExportDTO; +import cn.cordys.common.dto.ExportFieldParam; +import cn.cordys.common.dto.FieldExportMeta; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.service.BaseExportService; +import cn.cordys.crm.product.dto.request.ProductPricePageRequest; +import cn.cordys.crm.product.dto.response.ProductPriceResponse; +import cn.cordys.crm.product.mapper.ExtProductPriceMapper; +import cn.cordys.crm.product.utils.ProductPriceUtils; +import cn.cordys.crm.system.excel.domain.MergeResult; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.registry.ExportThreadRegistry; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +/** + * 价格表导出 + * + * @author song-cc-rock + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class ProductPriceExportService extends BaseExportService { + + @Resource + private ExtProductPriceMapper extProductPriceMapper; + @Resource + private ProductPriceService productPriceService; + @Resource + private ModuleFormService moduleFormService; + + @Override + protected MergeResult getExportMergeData(String taskId, ExportDTO exportParam) throws InterruptedException { + + List exportList = queryExportData(exportParam); + if (CollectionUtils.isEmpty(exportList)) { + return emptyMergeResult(); + } + + List dataList = productPriceService.buildList(exportList); + + ExportFieldParam exportFieldParam = exportParam.getExportFieldParam(); + Map> optionMap = buildOptionMap(dataList, exportFieldParam); + + return buildMergeResult(taskId, dataList, optionMap, exportParam); + } + + /** + * 查询导出数据 + */ + private List queryExportData(ExportDTO exportParam) { + if (CollectionUtils.isNotEmpty(exportParam.getSelectIds())) { + return extProductPriceMapper.selectByIds(exportParam.getSelectIds()); + } + ProductPricePageRequest request = (ProductPricePageRequest) exportParam.getPageRequest(); + return extProductPriceMapper.list(request, exportParam.getOrgId()); + } + + /** + * 构建选项映射 + */ + private Map> buildOptionMap(List dataList, + ExportFieldParam exportFieldParam) { + + List moduleFieldValues = + moduleFormService.getBaseModuleFieldValues(dataList, ProductPriceResponse::getModuleFields); + + return moduleFormService.getOptionMap( + exportFieldParam.getFormConfig(), + moduleFieldValues + ); + } + + /** + * 构建合并导出结果 + */ + private MergeResult buildMergeResult(String taskId, + List dataList, + Map> optionMap, + ExportDTO exportParam) throws InterruptedException { + + List> data = new ArrayList<>(); + List mergeRegions = new ArrayList<>(); + + int offset = 0; + ExportFieldParam exportFieldParam = exportParam.getExportFieldParam(); + var cacheMap = new HashMap<>(); + for (ProductPriceResponse response : dataList) { + + checkInterrupted(taskId); + + List> rows = + buildData(response, optionMap, exportFieldParam, exportParam.getExportMetas(), cacheMap); + + if (rows.size() > 1) { + mergeRegions.add(new int[]{offset, offset + rows.size() - 1}); + } + + offset += rows.size(); + data.addAll(rows); + } + + return MergeResult.builder() + .dataList(data) + .handleCount(dataList.size()) + .mergeRegions(mergeRegions) + .build(); + } + + private void checkInterrupted(String taskId) throws InterruptedException { + if (ExportThreadRegistry.isInterrupted(taskId)) { + throw new InterruptedException("导出中断"); + } + } + + private MergeResult emptyMergeResult() { + return MergeResult.builder() + .dataList(new ArrayList<>()) + .mergeRegions(new ArrayList<>()) + .build(); + } + + private List> buildData(ProductPriceResponse detail, + Map> optionMap, + ExportFieldParam exportFieldParam, + List metas, HashMap cacheMap) { + + LinkedHashMap systemFieldMap = + ProductPriceUtils.getSystemFieldMap(detail, optionMap); + + return buildDataWithSub( + detail.getModuleFields(), + exportFieldParam, + metas, + systemFieldMap, cacheMap + ); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductPriceFieldService.java b/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductPriceFieldService.java new file mode 100644 index 0000000..66733b5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductPriceFieldService.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.product.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.crm.product.domain.ProductPriceField; +import cn.cordys.crm.product.domain.ProductPriceFieldBlob; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author song-cc-rock + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class ProductPriceFieldService extends BaseResourceFieldService { + + @Resource + private BaseMapper productPriceFieldMapper; + @Resource + private BaseMapper productPriceFieldBlobMapper; + + @Override + protected String getFormKey() { + return FormKey.PRICE.getKey(); + } + + @Override + protected BaseMapper getResourceFieldMapper() { + return productPriceFieldMapper; + } + + @Override + protected BaseMapper getResourceFieldBlobMapper() { + return productPriceFieldBlobMapper; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductPriceService.java b/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductPriceService.java new file mode 100644 index 0000000..e47837c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductPriceService.java @@ -0,0 +1,736 @@ +package cn.cordys.crm.product.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.domain.BaseResourceSubField; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.ServiceUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.opportunity.domain.OpportunityQuotationField; +import cn.cordys.crm.opportunity.domain.OpportunityQuotationFieldBlob; +import cn.cordys.crm.product.domain.ProductPrice; +import cn.cordys.crm.product.domain.ProductPriceField; +import cn.cordys.crm.product.domain.ProductPriceFieldBlob; +import cn.cordys.crm.product.dto.request.ProductPriceAddRequest; +import cn.cordys.crm.product.dto.request.ProductPriceEditRequest; +import cn.cordys.crm.product.dto.request.ProductPricePageRequest; +import cn.cordys.crm.product.dto.response.ProductPriceGetResponse; +import cn.cordys.crm.product.dto.response.ProductPriceResponse; +import cn.cordys.crm.product.mapper.ExtProductPriceMapper; +import cn.cordys.crm.system.constants.SheetKey; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.excel.CustomImportAfterDoConsumer; +import cn.cordys.crm.system.excel.handler.CustomHeadColWidthStyleStrategy; +import cn.cordys.crm.system.excel.handler.CustomTemplateWriteHandler; +import cn.cordys.crm.system.excel.listener.CustomFieldCheckEventListener; +import cn.cordys.crm.system.excel.listener.CustomFieldImportEventListener; +import cn.cordys.crm.system.excel.listener.CustomFieldMergeCellEventListener; +import cn.cordys.crm.system.service.*; +import cn.cordys.excel.utils.EasyExcelExporter; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import cn.idev.excel.FastExcelFactory; +import cn.idev.excel.enums.CellExtraTypeEnum; +import cn.idev.excel.metadata.CellExtra; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; + +/** + * @author song-cc-rock + */ +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class ProductPriceService { + + @Resource + private BaseService baseService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private ModuleFieldExtService moduleFieldExtService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private BaseMapper productPriceMapper; + @Resource + private BaseMapper productPriceFieldMapper; + @Resource + private BaseMapper productPriceFieldBlobMapper; + @Resource + private ProductPriceFieldService productPriceFieldService; + @Resource + private ExtProductPriceMapper extProductPriceMapper; + @Resource + private LogService logService; + @Resource + private BaseMapper opportunityFieldMapper; + @Resource + private BaseMapper opportunityQuotationFieldBlobBaseMapper; + @Resource + private AttachmentService attachmentService; + + public static final int MAX_NAME_SPLIT_LENGTH = 243; + + /** + * 价格列表 + * + * @param request 请求参数 + * @param currentOrg 当前组织 + * + * @return 价格列表 + */ + public PagerWithOption> list(ProductPricePageRequest request, String currentOrg) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extProductPriceMapper.list(request, currentOrg); + List results = buildList(list); + // 处理自定义字段选项 + ModuleFormConfigDTO priceFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.PRICE.getKey(), currentOrg); + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(results, ProductPriceResponse::getModuleFields); + Map> optionMap = moduleFormService.getOptionMap(priceFormConfig, moduleFieldValues); + return PageUtils.setPageInfoWithOption(page, processList(results, priceFormConfig), optionMap); + } + + /** + * 新增价格表 + * + * @param request 请求参数 + * @param currentUser 当前用户 + * @param currentOrg 当前组织 + * + * @return 价格表 + */ + @OperationLog(module = LogModule.PRODUCT_PRICE_MANAGEMENT, type = LogType.ADD, resourceName = "{#request.name}", operator = "{#currentUser}") + public ProductPrice add(ProductPriceAddRequest request, String currentUser, String currentOrg) { + ProductPrice productPrice = BeanUtils.copyBean(new ProductPrice(), request); + productPrice.setId(IDGenerator.nextStr()); + productPrice.setOrganizationId(currentOrg); + productPrice.setPos(getNextOrder(currentOrg)); + productPrice.setCreateTime(System.currentTimeMillis()); + productPrice.setUpdateTime(System.currentTimeMillis()); + productPrice.setCreateUser(currentUser); + productPrice.setUpdateUser(currentUser); + // 设置子表格字段值 + request.getModuleFields().add(new BaseModuleFieldValue("products", request.getProducts())); + productPriceFieldService.saveModuleField(productPrice, currentOrg, currentUser, request.getModuleFields(), false); + productPriceMapper.insert(productPrice); + // 处理日志上下文 + ModuleFormConfigDTO priceFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.PRICE.getKey(), currentOrg); + baseService.handleAddLogWithSubTable(productPrice, request.getModuleFields(), Translator.get("products_info"), priceFormConfig); + return productPrice; + } + + /** + * 修改价格表 + * + * @param request 请求参数 + * @param currentUser 当前用户 + * @param currentOrg 当前组织 + * + * @return 价格表 + */ + @OperationLog(module = LogModule.PRODUCT_PRICE_MANAGEMENT, type = LogType.UPDATE, operator = "{#currentUser}") + public ProductPrice update(ProductPriceEditRequest request, String currentUser, String currentOrg) { + ProductPrice oldPrice = productPriceMapper.selectByPrimaryKey(request.getId()); + if (oldPrice == null) { + throw new GenericException(Translator.get("product.price.not.exist")); + } + List originFields = productPriceFieldService.getModuleFieldValuesByResourceId(request.getId()); + ProductPrice productPrice = BeanUtils.copyBean(new ProductPrice(), request); + productPrice.setUpdateTime(System.currentTimeMillis()); + productPrice.setUpdateUser(currentUser); + // 设置子表格字段值 + request.getModuleFields().add(new BaseModuleFieldValue("products", request.getProducts())); + updateFields(request.getModuleFields(), productPrice, currentOrg, currentUser); + productPriceMapper.update(productPrice); + // 处理日志上下文 + ModuleFormConfigDTO priceFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.PRICE.getKey(), currentOrg); + baseService.handleUpdateLogWithSubTable(oldPrice, productPrice, originFields, request.getModuleFields(), request.getId(), productPrice.getName(), Translator.get("products_info"), priceFormConfig); + return productPriceMapper.selectByPrimaryKey(request.getId()); + } + + /** + * ⚠️反射调用; 勿修改入参, 返回, 方法名! + * + * @param id 价格表ID + * + * @return 价格表详情 + */ + public ProductPriceGetResponse get(String id) { + ProductPrice price = productPriceMapper.selectByPrimaryKey(id); + if (price == null) { + return null; + } + ProductPriceGetResponse priceDetail = BeanUtils.copyBean(new ProductPriceGetResponse(), price); + // 处理自定义字段(包括详情附件) + List fieldValues = productPriceFieldService.getModuleFieldValuesByResourceId(id); + ModuleFormConfigDTO priceFormConf = moduleFormCacheService.getBusinessFormConfig(FormKey.PRICE.getKey(), price.getOrganizationId()); + Map> optionMap = moduleFormService.getOptionMap(priceFormConf, fieldValues); + priceDetail.setOptionMap(optionMap); + moduleFormService.processBusinessFieldValues(priceDetail, fieldValues, priceFormConf); + priceDetail.setAttachmentMap(moduleFormService.getAttachmentMap(priceFormConf, priceDetail.getModuleFields())); + return baseService.setCreateAndUpdateUserName(priceDetail); + } + + /** + * 获取价格表详情-简化版 (⚠️反射调用; 勿修改入参, 返回, 方法名!) + * @param id 价格表ID + * @return 价格表详情 + */ + public ProductPriceGetResponse getSimple(String id) { + ProductPrice price = productPriceMapper.selectByPrimaryKey(id); + if (price == null) { + return null; + } + ProductPriceGetResponse response = BeanUtils.copyBean(new ProductPriceGetResponse(), price); + // 处理自定义字段(包括详情附件) + ModuleFormConfigDTO priceFormConf = moduleFormCacheService.getBusinessFormConfig(FormKey.PRICE.getKey(), price.getOrganizationId()); + List fvs = productPriceFieldService.getModuleFieldValuesByResourceId(id); + moduleFormService.processBusinessFieldValues(response, fvs, priceFormConf); + return response; + } + + /** + * 批量获取价格表详情 (用于数据源批量查询优化) + * @param ids 价格表ID集合 + * @return 价格表详情列表 + */ + public List batchGetSimpleByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return Collections.emptyList(); + } + List prices = productPriceMapper.selectByIds(ids); + if (CollectionUtils.isEmpty(prices)) { + return Collections.emptyList(); + } + ModuleFormConfigDTO priceFormConf = moduleFormCacheService.getBusinessFormConfig(FormKey.PRICE.getKey(), prices.getFirst().getOrganizationId()); + Map> fieldValueMap = productPriceFieldService.getResourceFieldMap(ids, true); + + return prices.stream().map(price -> { + ProductPriceGetResponse response = BeanUtils.copyBean(new ProductPriceGetResponse(), price); + List fvs = fieldValueMap.get(price.getId()); + if (CollectionUtils.isNotEmpty(fvs)) { + moduleFormService.processBusinessFieldValues(response, fvs, priceFormConf); + } + return response; + }).toList(); + } + + /** + * 删除价格表 + * + * @param id 价格表ID + */ + @OperationLog(module = LogModule.PRODUCT_PRICE_MANAGEMENT, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + // 1. 查询价格表,不存在则抛错 + ProductPrice price = Optional.ofNullable( + productPriceMapper.selectByPrimaryKey(id) + ).orElseThrow(() -> new GenericException( + Translator.get("product.price.not.exist") + )); + + // 2. 检查是否被引用(报价单等) + List opportunityFields = opportunityFieldMapper.selectListByLambda( + new LambdaQueryWrapper() + .eq(OpportunityQuotationField::getFieldValue, id) + ); + + List opportunityBlobFields = opportunityQuotationFieldBlobBaseMapper.selectListByLambda( + new LambdaQueryWrapper() + .eq(OpportunityQuotationFieldBlob::getFieldValue, id) + ); + + + if (!opportunityFields.isEmpty() || !opportunityBlobFields.isEmpty()) { + throw new GenericException( + Translator.get("product.price.in.use.cannot.delete") + ); + } + + // 3. 删除主表和自定义字段表数据 + productPriceMapper.deleteByPrimaryKey(id); + productPriceFieldService.deleteByResourceId(id); + + // 4. 记录日志上下文(用于审计) + OperationLogContext.setResourceName(price.getName()); + } + + /** + * 复制价格表 + * + * @param id 价格表ID + * @param currentUser 当前用户 + * @param currentOrg 当前组织 + * + * @return 复制后价格表 + */ + @OperationLog(module = LogModule.PRODUCT_PRICE_MANAGEMENT, type = LogType.ADD, operator = "{#currentUser}") + public ProductPrice copy(String id, String currentUser, String currentOrg) { + // 1. 查询价格表,不存在则抛错 + ProductPrice price = Optional.ofNullable(productPriceMapper.selectByPrimaryKey(id)) + .orElseThrow(() -> new GenericException(Translator.get("product.price.not.exist"))); + // 2. 复制价格表基础信息 + price.setId(IDGenerator.nextStr()); + price.setPos(getNextOrder(currentOrg)); + if (price.getName() != null && price.getName().length() > MAX_NAME_SPLIT_LENGTH) { + price.setName(price.getName().substring(0, 243)); + } + price.setName(price.getName() + "_copy_" + RandomStringUtils.random(6, 0, 0, true, true, null, new Random())); + price.setCreateUser(currentUser); + price.setCreateTime(System.currentTimeMillis()); + price.setUpdateUser(currentUser); + price.setUpdateTime(System.currentTimeMillis()); + price.setOrganizationId(currentOrg); + productPriceMapper.insert(price); + // 3. 复制价格表自定义字段信息 + copyPriceFields(id, price.getId(), currentOrg, currentUser); + // 4. 处理日志上下文 + ModuleFormConfigDTO priceFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.PRICE.getKey(), currentOrg); + List fvs = productPriceFieldService.getModuleFieldValuesByResourceId(price.getId()); + baseService.handleAddLogWithSubTable(price, fvs, Translator.get("products_info"), priceFormConfig); + OperationLogContext.getContext().setResourceId(price.getId()); + OperationLogContext.getContext().setResourceName(price.getName()); + return price; + } + + /** + * 批量更新价格表 + * + * @param request 请求参数 + * @param currentUser 当前用户 + * @param currentOrg 当前组织 + */ + public void batchUpdate(ResourceBatchEditRequest request, String currentUser, String currentOrg) { + BaseField field = productPriceFieldService.getAndCheckField(request.getFieldId(), currentOrg); + List prices = productPriceMapper.selectByIds(request.getIds()); + productPriceFieldService.batchUpdate(request, field, prices, ProductPrice.class, + LogModule.PRODUCT_PRICE_MANAGEMENT, extProductPriceMapper::batchUpdate, currentUser, currentOrg); + } + + /** + * 下载导入的模板 + * + * @param response 响应 + */ + public void downloadImportTpl(HttpServletResponse response, String currentOrg) { + new EasyExcelExporter().exportMultiSheetTplWithSharedHandler(response, + moduleFormService.getCustomImportHeadsNoRef(FormKey.PRICE.getKey(), currentOrg), + Translator.get("product.price.import_tpl.name"), + Translator.get(SheetKey.DATA), Translator.get(SheetKey.COMMENT), + new CustomTemplateWriteHandler(moduleFormService.getAllCustomImportFields(FormKey.PRICE.getKey(), currentOrg)), + new CustomHeadColWidthStyleStrategy() + ); + } + + /** + * 导入检查 + * + * @param file 导入文件 + * @param currentOrg 当前组织 + * + * @return 导入检查信息 + */ + public ImportResponse importPreCheck(MultipartFile file, String currentOrg) { + if (file == null) { + throw new GenericException(Translator.get("file_cannot_be_null")); + } + return checkImportExcel(file, currentOrg); + } + + /** + * 检查导入的文件 + * + * @param file 文件 + * @param currentOrg 当前组织 + * + * @return 检查信息 + */ + private ImportResponse checkImportExcel(MultipartFile file, String currentOrg) { + try { + List fields = moduleFormService.getAllCustomImportFields( + FormKey.PRICE.getKey(), + currentOrg + ); + + boolean supportSubHead = moduleFormService.supportSubHead(fields); + int headRowNumber = supportSubHead ? 2 : 1; + + // 1 先读取合并单元格信息 + CustomFieldMergeCellEventListener mergeCellEventListener = + new CustomFieldMergeCellEventListener(); + + FastExcelFactory.read(file.getInputStream(), mergeCellEventListener) + .extraRead(CellExtraTypeEnum.MERGE) + .headRowNumber(headRowNumber) + .ignoreEmptyRow(true) + .sheet() + .doRead(); + + // 2 校验数据 + CustomFieldCheckEventListener eventListener = + new CustomFieldCheckEventListener( + fields, + "product_price", + "product_price_field", + currentOrg, + mergeCellEventListener.getMergeCellMap(), + mergeCellEventListener.getMergeRowDataMap() + ); + + FastExcelFactory.read(file.getInputStream(), eventListener) + .headRowNumber(headRowNumber) + .ignoreEmptyRow(true) + .sheet() + .doRead(); + + return ImportResponse.builder() + .errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccess()) + .failCount(eventListener.getErrList().size()) + .build(); + + } catch (Exception e) { + log.error("price import pre-check error: {}", e.getMessage(), e); + throw new GenericException(e.getMessage()); + } + } + + /** + * 价格表导入 + * + * @param file 导入文件 + * @param currentOrg 当前组织 + * @param currentUser 当前用户 + * + * @return 导入返回信息 + */ + public ImportResponse realImport(MultipartFile file, String currentOrg, String currentUser) { + try { + List fields = moduleFormService.getAllFields( + FormKey.PRICE.getKey(), + currentOrg + ); + + boolean supportSubHead = moduleFormService.supportSubHead(fields); + int headRowNumber = supportSubHead ? 2 : 1; + + // 1 读取合并单元格信息 + CustomFieldMergeCellEventListener mergeCellEventListener = + new CustomFieldMergeCellEventListener(); + + FastExcelFactory.read(file.getInputStream(), mergeCellEventListener) + .extraRead(CellExtraTypeEnum.MERGE) + .headRowNumber(headRowNumber) + .ignoreEmptyRow(true) + .sheet() + .doRead(); + + // 2 实际导入 + CustomFieldImportEventListener eventListener = + getPriceEventListener( + currentOrg, + currentUser, + fields, + mergeCellEventListener.getMergeCellMap(), + mergeCellEventListener.getMergeRowDataMap() + ); + + FastExcelFactory.read(file.getInputStream(), eventListener) + .extraRead(CellExtraTypeEnum.MERGE) + .headRowNumber(headRowNumber) + .ignoreEmptyRow(true) + .sheet() + .doRead(); + + return ImportResponse.builder() + .errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccessCount()) + .failCount(eventListener.getErrList().size()) + .build(); + + } catch (Exception e) { + log.error("价格表导入失败, 原因: {}", e.getMessage(), e); + throw new GenericException(e.getMessage()); + } + } + + /** + * 价格表导入监听器 + * + * @param currentOrg 当前组织 + * @param currentUser 当前用户 + * @param fields 自定义字段集合 + * + * @return 导入监听器 + */ + private CustomFieldImportEventListener getPriceEventListener( + String currentOrg, + String currentUser, + List fields, + Map> mergeCellMap, + Map> mergeRowDataMap) { + + AtomicLong initPos = new AtomicLong(getNextOrder(currentOrg)); + + CustomImportAfterDoConsumer afterDo = + (prices, priceFields, priceFieldBlobs) -> { + + List logs = new ArrayList<>(); + + prices.forEach(price -> { + price.setPos(initPos.getAndAdd(ServiceUtils.POS_STEP)); + logs.add(new LogDTO( + currentOrg, + price.getId(), + currentUser, + LogType.ADD, + LogModule.PRODUCT_PRICE_MANAGEMENT, + price.getName() + )); + }); + + productPriceMapper.batchInsert(prices); + + productPriceFieldMapper.batchInsert( + priceFields.stream() + .map(field -> BeanUtils.copyBean(new ProductPriceField(), field)) + .toList() + ); + + productPriceFieldBlobMapper.batchInsert( + priceFieldBlobs.stream() + .map(field -> BeanUtils.copyBean(new ProductPriceFieldBlob(), field)) + .toList() + ); + + // record logs + logService.batchAdd(logs); + }; + + return new CustomFieldImportEventListener<>( + fields, + ProductPrice.class, + currentOrg, + currentUser, + "product_price_field", + afterDo, + 2000, + mergeCellMap, + mergeRowDataMap + ); + } + + /** + * 构建列表数据 + * + * @param listData 列表数据 + * + * @return 列表数据 + */ + public List buildList(List listData) { + // 查询列表数据的自定义字段 + Map> dataFieldMap = productPriceFieldService.getResourceFieldMap( + listData.stream().map(ProductPriceResponse::getId).toList(), true); + // 列表项设置自定义字段&&用户名 + listData.forEach(item -> item.setModuleFields(dataFieldMap.get(item.getId()))); + return baseService.setCreateAndUpdateUserName(listData); + } + + /** + * 处理列表数据 + * + * @param listData 列表数据 + * + * @return 列表数据 + */ + public List processList(List listData, ModuleFormConfigDTO priceFormConf) { + // 查询列表数据的自定义字段 + Map> dataFieldMap = productPriceFieldService.getResourceFieldMap( + listData.stream().map(ProductPriceResponse::getId).toList(), true); + // 列表项设置自定义字段&&用户名 + listData.forEach(item -> { + if (!dataFieldMap.containsKey(item.getId())) { + return; + } + moduleFormService.processBusinessFieldValues(item, dataFieldMap.get(item.getId()), priceFormConf); + }); + return baseService.setCreateAndUpdateUserName(listData); + } + + /** + * 更新自定义字段 + * + * @param fields 自定义字段集合 + * @param price 价格表 + * @param currentOrg 当前组织 + * @param currentUser 当前用户 + */ + private void updateFields(List fields, ProductPrice price, String currentOrg, String currentUser) { + if (fields == null) { + return; + } + productPriceFieldService.deleteByResourceId(price.getId()); + productPriceFieldService.saveModuleField(price, currentOrg, currentUser, fields, true); + } + + /** + * 拖拽排序 + * + * @param request 请求参数 + */ + public void editPos(PosRequest request) { + ServiceUtils.updatePosFieldByAsc(request, + ProductPrice.class, + null, + null, + productPriceMapper::selectByPrimaryKey, + extProductPriceMapper::getPrePos, + extProductPriceMapper::getLastPos, + productPriceMapper::update); + } + + /** + * 获取下一个排序值 + * + * @param orgId 组织ID + * + * @return 下一个排序值 + */ + public Long getNextOrder(String orgId) { + Long pos = extProductPriceMapper.getPos(orgId); + return (pos == null ? 0 : pos) + ServiceUtils.POS_STEP; + } + + /** + * 获取价格表名称 + * + * @param id id + * + * @return 名称 + */ + public String getProductPriceName(String id) { + ProductPrice productPrice = productPriceMapper.selectByPrimaryKey(id); + return Optional.ofNullable(productPrice).map(ProductPrice::getName).orElse(null); + } + + /** + * 通过ID集合获取价格表名称串 + * + * @param ids ID集合 + * + * @return 名称字符串 + */ + public String getProductPriceNameByIds(List ids) { + List productPrices = productPriceMapper.selectByIds(ids); + if (CollectionUtils.isNotEmpty(productPrices)) { + List names = productPrices.stream().map(ProductPrice::getName).toList(); + return String.join(",", names); + } + return StringUtils.EMPTY; + } + + /** + * 通过名称获取价格表集合 + * + * @param names 名称集合 + * + * @return 价格表集合 + */ + public List getProductPriceListByNames(List names) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.in(ProductPrice::getName, names); + return productPriceMapper.selectListByLambda(lambdaQueryWrapper); + } + + /** + * 复制价格表自定义字段信息 + * + * @param sourceId 源价格表ID + * @param targetId 目标价格表ID + */ + private void copyPriceFields(String sourceId, String targetId, String currentOrg, String currentUser) { + + // 1 普通字段 + LambdaQueryWrapper fieldQuery = new LambdaQueryWrapper<>(); + fieldQuery.eq(ProductPriceField::getResourceId, sourceId); + List sourceFields = productPriceFieldMapper.selectListByLambda(fieldQuery); + + if (CollectionUtils.isNotEmpty(sourceFields)) { + List targetFields = sourceFields.stream() + .peek(field -> { + field.setId(IDGenerator.nextStr()); + field.setResourceId(targetId); + }) + .toList(); + + productPriceFieldMapper.batchInsert(targetFields); + } + + // 2 Blob 字段(含附件) + LambdaQueryWrapper blobQuery = new LambdaQueryWrapper<>(); + blobQuery.eq(ProductPriceFieldBlob::getResourceId, sourceId); + List sourceBlobs = productPriceFieldBlobMapper.selectListByLambda(blobQuery); + + Map attachmentIdMap = new HashMap<>(8); + List attachmentFieldIds = moduleFieldExtService.getFieldIdsOfForm(FormKey.PRICE.getKey(), currentOrg); + + if (CollectionUtils.isNotEmpty(sourceBlobs)) { + List targetBlobs = sourceBlobs.stream() + .peek(blob -> { + blob.setId(IDGenerator.nextStr()); + blob.setResourceId(targetId); + + if (attachmentFieldIds.contains(blob.getFieldId()) && blob.getFieldValue() != null) { + + List attachmentIds = JSON.parseArray( + blob.getFieldValue().toString(), + String.class + ); + + List copyAttachmentIds = new ArrayList<>(); + attachmentIds.forEach(id -> { + attachmentIdMap.putIfAbsent(id, IDGenerator.nextStr()); + copyAttachmentIds.add(attachmentIdMap.get(id)); + }); + + blob.setFieldValue(JSON.toJSONString(copyAttachmentIds)); + } + }) + .toList(); + + productPriceFieldBlobMapper.batchInsert(targetBlobs); + } + + // 3 复制附件实体 + attachmentService.batchCopyOfIdMap(attachmentIdMap, targetId, currentUser); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductService.java b/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductService.java new file mode 100644 index 0000000..30915e7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/service/ProductService.java @@ -0,0 +1,439 @@ +package cn.cordys.crm.product.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.domain.BaseResourceSubField; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.ServiceUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.product.domain.Product; +import cn.cordys.crm.product.domain.ProductField; +import cn.cordys.crm.product.domain.ProductFieldBlob; +import cn.cordys.crm.product.dto.request.ProductEditRequest; +import cn.cordys.crm.product.dto.request.ProductPageRequest; +import cn.cordys.crm.product.dto.response.ProductGetResponse; +import cn.cordys.crm.product.dto.response.ProductListResponse; +import cn.cordys.crm.product.mapper.ExtProductMapper; +import cn.cordys.crm.system.constants.SheetKey; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.ImportResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.excel.CustomImportAfterDoConsumer; +import cn.cordys.crm.system.excel.handler.CustomHeadColWidthStyleStrategy; +import cn.cordys.crm.system.excel.handler.CustomTemplateWriteHandler; +import cn.cordys.crm.system.excel.listener.CustomFieldCheckEventListener; +import cn.cordys.crm.system.excel.listener.CustomFieldImportEventListener; +import cn.cordys.crm.system.service.LogService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.excel.utils.EasyExcelExporter; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import cn.idev.excel.FastExcelFactory; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +/** + * @author guoyuqi + */ +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class ProductService { + + @Resource + private BaseMapper productBaseMapper; + @Resource + private ExtProductMapper extProductMapper; + @Resource + private BaseService baseService; + @Resource + private ProductFieldService productFieldService; + @Resource + private LogService logService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private BaseMapper productFieldMapper; + @Resource + private BaseMapper productFieldBlobMapper; + + public PagerWithOption> list(ProductPageRequest request, String orgId) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extProductMapper.list(request, orgId); + List buildList = buildListData(list); + // 处理自定义字段选项数据 + ModuleFormConfigDTO productFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.PRODUCT.getKey(), orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, ProductListResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(productFormConfig, moduleFieldValues); + return PageUtils.setPageInfoWithOption(page, buildList, optionMap); + } + + private List buildListData(List list) { + List productIds = list.stream().map(ProductListResponse::getId) + .collect(Collectors.toList()); + + Map> productFiledMap = productFieldService.getResourceFieldMap(productIds, true); + + list.forEach(productListResponse -> { + // 获取自定义字段 + List productFields = productFiledMap.get(productListResponse.getId()); + productListResponse.setModuleFields(productFields); + }); + + return baseService.setCreateAndUpdateUserName(list); + } + + /** + * ⚠️反射调用; 勿修改入参, 返回, 方法名! + * + * @param id 产品ID + * + * @return 产品详情 + */ + public ProductGetResponse get(String id) { + Product product = productBaseMapper.selectByPrimaryKey(id); + if (product == null) { + return null; + } + ProductGetResponse productGetResponse = BeanUtils.copyBean(new ProductGetResponse(), product); + + // 获取模块字段 + List productFields = productFieldService.getModuleFieldValuesByResourceId(id); + + productGetResponse.setModuleFields(productFields); + ModuleFormConfigDTO productFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.PRODUCT.getKey(), product.getOrganizationId()); + Map> optionMap = moduleFormService.getOptionMap(productFormConfig, productFields); + productGetResponse.setOptionMap(optionMap); + // 附件信息 + productGetResponse.setAttachmentMap(moduleFormService.getAttachmentMap(productFormConfig, productFields)); + return baseService.setCreateAndUpdateUserName(productGetResponse); + } + + /** + * 获取产品详情(简化版), ⚠️反射调用; 勿修改入参, 返回, 方法名! + * @param id 产品ID + * @return 产品详情 + */ + public ProductGetResponse getSimple(String id) { + Product product = productBaseMapper.selectByPrimaryKey(id); + if (product == null) { + return null; + } + ProductGetResponse response = BeanUtils.copyBean(new ProductGetResponse(), product); + // 获取模块字段 + List fvs = productFieldService.getModuleFieldValuesByResourceId(id); + response.setModuleFields(fvs); + return response; + } + + /** + * 批量获取产品详情 (用于数据源批量查询优化) + * @param ids 产品ID集合 + * @return 产品详情列表 + */ + public List batchGetSimpleByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return Collections.emptyList(); + } + List products = productBaseMapper.selectByIds(ids); + if (CollectionUtils.isEmpty(products)) { + return Collections.emptyList(); + } + Map> fieldValueMap = productFieldService.getResourceFieldMap(ids, true); + + return products.stream().map(product -> { + ProductGetResponse response = BeanUtils.copyBean(new ProductGetResponse(), product); + response.setModuleFields(fieldValueMap.get(product.getId())); + return response; + }).toList(); + } + + @OperationLog(module = LogModule.PRODUCT_MANAGEMENT, type = LogType.ADD, operator = "{#userId}") + public Product add(ProductEditRequest request, String userId, String orgId) { + Product product = BeanUtils.copyBean(new Product(), request); + product.setName(request.getName()); + product.setPrice(request.getPrice()); + product.setStatus(request.getStatus()); + product.setCreateTime(System.currentTimeMillis()); + product.setUpdateTime(System.currentTimeMillis()); + product.setPos(getNextOrder(orgId)); + product.setUpdateUser(userId); + product.setCreateUser(userId); + product.setOrganizationId(orgId); + product.setId(IDGenerator.nextStr()); + + //保存自定义字段 + productFieldService.saveModuleField(product, orgId, userId, request.getModuleFields(), false); + + productBaseMapper.insert(product); + + // 添加日志上下文 + baseService.handleAddLogWithResourceName(product, request.getModuleFields()); + return product; + } + + @OperationLog(module = LogModule.PRODUCT_MANAGEMENT, type = LogType.UPDATE, operator = "{#userId}") + public Product update(ProductEditRequest request, String userId, String orgId) { + if (StringUtils.isBlank(request.getId())) { + throw new GenericException(Translator.get("product.id.empty")); + } + Product oldProduct = productBaseMapper.selectByPrimaryKey(request.getId()); + Product product = BeanUtils.copyBean(new Product(), request); + product.setUpdateTime(System.currentTimeMillis()); + product.setUpdateUser(userId); + product.setOrganizationId(orgId); + + // 获取模块字段 + List originCustomerFields = productFieldService.getModuleFieldValuesByResourceId(request.getId()); + + // 更新模块字段 + updateModuleField(product, request.getModuleFields(), orgId, userId); + productBaseMapper.update(product); + + //添加日志 + baseService.handleUpdateLog(oldProduct, product, originCustomerFields, request.getModuleFields(), request.getId(), product.getName()); + + return productBaseMapper.selectByPrimaryKey(product.getId()); + } + + private void updateModuleField(Product product, List moduleFields, String orgId, String userId) { + if (moduleFields == null) { + // 如果为 null,则不更新 + return; + } + // 先删除 + productFieldService.deleteByResourceId(product.getId()); + // 再保存 + productFieldService.saveModuleField(product, orgId, userId, moduleFields, true); + } + + private void batchUpdateModuleField(List productIds, List moduleFields) { + if (moduleFields == null) { + // 如果为 null,则不更新 + return; + } + // 先删除 + productFieldService.deleteByResourceIds(productIds); + // 再保存 + productFieldService.saveModuleFieldByResourceIds(productIds, moduleFields); + } + + @OperationLog(module = LogModule.PRODUCT_MANAGEMENT, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + Product product = productBaseMapper.selectByPrimaryKey(id); + // 删除产品 + productBaseMapper.deleteByPrimaryKey(id); + // 删除产品模块字段 + productFieldService.deleteByResourceId(id); + // 添加日志上下文 + OperationLogContext.setResourceName(product.getName()); + } + + public void batchUpdate(ResourceBatchEditRequest request, String userId, String organizationId) { + BaseField field = productFieldService.getAndCheckField(request.getFieldId(), organizationId); + List products = productBaseMapper.selectByIds(request.getIds()); + productFieldService.batchUpdate(request, field, products, Product.class, LogModule.PRODUCT_MANAGEMENT, extProductMapper::batchUpdate, userId, organizationId); + } + + /** + * 批量删除产品 + * + * @param ids 产品id集合 + * @param userId 操作人id + */ + public void batchDelete(List ids, String userId) { + + List products = productBaseMapper.selectByIds(ids); + + if (products == null || products.isEmpty()) { + return; + } + productBaseMapper.deleteByIds(ids); + productFieldService.deleteByResourceIds(ids); + + List logs = products.stream() + .map(p -> { + LogDTO log = new LogDTO( + p.getOrganizationId(), + p.getId(), + userId, + LogType.DELETE, + LogModule.PRODUCT_MANAGEMENT, + p.getName() + ); + log.setOriginalValue(p.getName()); + return log; + }) + .toList(); + + logService.batchAdd(logs); + } + + public void checkProductList(List products) { + if (products != null && products.size() > 20) { + throw new GenericException(Translator.get("product.length")); + } + } + + public Long getNextOrder(String orgId) { + Long pos = extProductMapper.getPos(orgId); + return (pos == null ? 0 : pos) + ServiceUtils.POS_STEP; + } + + public void editPos(PosRequest request) { + + ServiceUtils.updatePosFieldByAsc(request, + Product.class, + null, + null, + productBaseMapper::selectByPrimaryKey, + extProductMapper::getPrePos, + extProductMapper::getLastPos, + productBaseMapper::update); + } + + public List getProductListByNames(List names) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.in(Product::getName, names); + return productBaseMapper.selectListByLambda(lambdaQueryWrapper); + } + + public String getProductName(String id) { + Product product = productBaseMapper.selectByPrimaryKey(id); + return Optional.ofNullable(product).map(Product::getName).orElse(null); + } + + public String getProductNameByIds(List ids) { + List productList = productBaseMapper.selectByIds(ids); + if (CollectionUtils.isNotEmpty(productList)) { + List names = productList.stream().map(Product::getName).toList(); + return String.join(",", names); + } + return StringUtils.EMPTY; + } + + /** + * 下载导入的模板 + * + * @param response 响应 + */ + public void downloadImportTpl(HttpServletResponse response, String currentOrg) { + new EasyExcelExporter() + .exportMultiSheetTplWithSharedHandler(response, moduleFormService.getCustomImportHeadsNoRef(FormKey.PRODUCT.getKey(), currentOrg), + Translator.get("product.import_tpl.name"), Translator.get(SheetKey.DATA), Translator.get(SheetKey.COMMENT), + new CustomTemplateWriteHandler(moduleFormService.getAllCustomImportFields(FormKey.PRODUCT.getKey(), currentOrg)), new CustomHeadColWidthStyleStrategy()); + } + + /** + * 导入检查 + * + * @param file 导入文件 + * @param currentOrg 当前组织 + * + * @return 导入检查信息 + */ + public ImportResponse importPreCheck(MultipartFile file, String currentOrg) { + if (file == null) { + throw new GenericException(Translator.get("file_cannot_be_null")); + } + return checkImportExcel(file, currentOrg); + } + + /** + * 产品导入 + * + * @param file 导入文件 + * @param currentOrg 当前组织 + * @param currentUser 当前用户 + * + * @return 导入返回信息 + */ + public ImportResponse realImport(MultipartFile file, String currentOrg, String currentUser) { + try { + AtomicLong initPos = new AtomicLong(getNextOrder(currentOrg)); + List fields = moduleFormService.getAllFields(FormKey.PRODUCT.getKey(), currentOrg); + CustomImportAfterDoConsumer afterDo = (products, productFields, productFieldBlobs) -> { + List logs = new ArrayList<>(); + products.forEach(product -> { + product.setPos(initPos.getAndAdd(ServiceUtils.POS_STEP)); + logs.add(new LogDTO(currentOrg, product.getId(), currentUser, LogType.ADD, LogModule.PRODUCT_MANAGEMENT, product.getName())); + }); + productBaseMapper.batchInsert(products); + productFieldMapper.batchInsert(productFields.stream().map(field -> BeanUtils.copyBean(new ProductField(), field)).toList()); + productFieldBlobMapper.batchInsert(productFieldBlobs.stream().map(field -> BeanUtils.copyBean(new ProductFieldBlob(), field)).toList()); + // record logs + logService.batchAdd(logs); + }; + CustomFieldImportEventListener eventListener = new CustomFieldImportEventListener<>(fields, Product.class, currentOrg, currentUser, + "product_field", afterDo, 2000, null, null); + FastExcelFactory.read(file.getInputStream(), eventListener).headRowNumber(1).ignoreEmptyRow(true).sheet().doRead(); + return ImportResponse.builder().errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccessCount()).failCount(eventListener.getErrList().size()).build(); + } catch (Exception e) { + log.error("product import error: {}", e.getMessage()); + throw new GenericException(e.getMessage()); + } + } + + /** + * 检查导入的文件 + * + * @param file 文件 + * @param currentOrg 当前组织 + * + * @return 检查信息 + */ + private ImportResponse checkImportExcel(MultipartFile file, String currentOrg) { + try { + List fields = moduleFormService.getAllCustomImportFields(FormKey.PRODUCT.getKey(), currentOrg); + CustomFieldCheckEventListener eventListener = new CustomFieldCheckEventListener(fields, "product", "product_field", currentOrg); + FastExcelFactory.read(file.getInputStream(), eventListener).headRowNumber(1).ignoreEmptyRow(true).sheet().doRead(); + return ImportResponse.builder().errorMessages(eventListener.getErrList()) + .successCount(eventListener.getSuccess()).failCount(eventListener.getErrList().size()).build(); + } catch (Exception e) { + log.error("product import pre-check error: {}", e.getMessage()); + throw new GenericException(e.getMessage()); + } + } + + /** + * 获取产品选项列表 + * + * @param organizationId 组织ID + * + * @return 产品选项列表 + */ + public List listOption(String organizationId) { + return extProductMapper.getOptions(organizationId); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/product/utils/ProductPriceUtils.java b/backend/crm/src/main/java/cn/cordys/crm/product/utils/ProductPriceUtils.java new file mode 100644 index 0000000..7c0fee5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/product/utils/ProductPriceUtils.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.product.utils; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.util.TimeUtils; +import cn.cordys.crm.product.dto.response.ProductPriceResponse; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * @author song-cc-rock + */ +public class ProductPriceUtils { + + public static LinkedHashMap getSystemFieldMap(ProductPriceResponse data, Map> optionMap) { + LinkedHashMap systemFieldMap = new LinkedHashMap<>(); + systemFieldMap.put("name", data.getName()); + systemFieldMap.put("status", getStatusName(data.getStatus(), optionMap)); + systemFieldMap.put("createUser", data.getCreateUserName()); + systemFieldMap.put("createTime", TimeUtils.getDateTimeStr(data.getCreateTime())); + systemFieldMap.put("updateUser", data.getUpdateUserName()); + systemFieldMap.put("updateTime", TimeUtils.getDateTimeStr(data.getUpdateTime())); + return systemFieldMap; + } + + public static String getStatusName(String status, Map> optionMap) { + if (optionMap.containsKey(BusinessModuleField.PRICE_STATUS.getBusinessKey())) { + for (OptionDTO option : optionMap.get(BusinessModuleField.PRICE_STATUS.getBusinessKey())) { + if (option.getId().equals(status)) { + return option.getName(); + } + } + } + return status; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/constants/DefaultSearchFieldEnum.java b/backend/crm/src/main/java/cn/cordys/crm/search/constants/DefaultSearchFieldEnum.java new file mode 100644 index 0000000..71df8a9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/constants/DefaultSearchFieldEnum.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.search.constants; + +public class DefaultSearchFieldEnum { + + // 线索&线索池 + public static final String CLUE_NAME = "clueName"; + public static final String CLUE_CONTACT_PHONE = "clueContactPhone"; + + // 客户&公海 + public static final String CUSTOMER_NAME = "customerName"; + + // 联系人 + public static final String CONTACT_CUSTOMER = "contactCustomer"; + public static final String CONTACT_PHONE = "contactPhone"; + + + // 商机 + public static final String OPPORTUNITY_NAME = "opportunityName"; + public static final String OPPORTUNITY_CUSTOMER = "opportunityCustomer"; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/constants/SearchModuleEnum.java b/backend/crm/src/main/java/cn/cordys/crm/search/constants/SearchModuleEnum.java new file mode 100644 index 0000000..8ef82c1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/constants/SearchModuleEnum.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.search.constants; + +import java.util.List; + +public class SearchModuleEnum { + + public static final String SEARCH_ADVANCED_CLUE = "searchAdvancedClue"; // 线索 + public static final String SEARCH_ADVANCED_CUSTOMER = "searchAdvancedCustomer"; // 客户 + public static final String SEARCH_ADVANCED_CONTACT = "searchAdvancedContact"; // 联系人 + public static final String SEARCH_ADVANCED_PUBLIC = "searchAdvancedPublic"; // 公海 + public static final String SEARCH_ADVANCED_CLUE_POOL = "searchAdvancedCluePool"; // 线索池 + public static final String SEARCH_ADVANCED_OPPORTUNITY = "searchAdvancedOpportunity"; // 商机 + + + public static final List VALUES = List.of( + SEARCH_ADVANCED_CLUE, + SEARCH_ADVANCED_CLUE_POOL, + SEARCH_ADVANCED_CUSTOMER, + SEARCH_ADVANCED_PUBLIC, + SEARCH_ADVANCED_OPPORTUNITY, + SEARCH_ADVANCED_CONTACT + ); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/constants/SearchPhoneEnum.java b/backend/crm/src/main/java/cn/cordys/crm/search/constants/SearchPhoneEnum.java new file mode 100644 index 0000000..ab89d3a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/constants/SearchPhoneEnum.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.search.constants; + +import java.util.List; + +public class SearchPhoneEnum { + + public static final String SEARCH_CN = "(+86)"; // 大陆 + public static final String SEARCH_TW = "(+886)"; // 台湾 + public static final String SEARCH_HK = "(+852)"; // 香港 + public static final String SEARCH_MO = "(+853)"; // 澳门 + + + public static final List VALUES = List.of( + SEARCH_CN, + SEARCH_TW, + SEARCH_HK, + SEARCH_MO + ); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/controller/AdvancedSearchController.java b/backend/crm/src/main/java/cn/cordys/crm/search/controller/AdvancedSearchController.java new file mode 100644 index 0000000..1de3b7d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/controller/AdvancedSearchController.java @@ -0,0 +1,105 @@ +package cn.cordys.crm.search.controller; + +import cn.cordys.aspectj.constants.GlobalSearchModule; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.clue.dto.request.CluePageRequest; +import cn.cordys.crm.customer.dto.request.CustomerContactPageRequest; +import cn.cordys.crm.customer.dto.request.CustomerPageRequest; +import cn.cordys.crm.opportunity.dto.request.OpportunityPageRequest; +import cn.cordys.crm.search.response.advanced.*; +import cn.cordys.crm.search.service.BaseSearchService; +import cn.cordys.crm.search.service.advanced.AdvancedCustomerSearchService; +import cn.cordys.crm.search.service.advanced.AdvancedSearchServiceFactory; +import cn.cordys.crm.system.dto.request.RepeatCustomerDetailPageRequest; +import cn.cordys.security.SessionUtils; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/advanced/search") +@Tag(name = "全局高级搜索") +public class AdvancedSearchController { + + @Resource + private AdvancedCustomerSearchService globalSearchCustomerService; + + @PostMapping("/account") + @Operation(summary = "全局搜索客户相关数据") + public PagerWithOption> advancedSearchCustomer(@Validated @RequestBody CustomerPageRequest request) { + BaseSearchService searchService = AdvancedSearchServiceFactory.getSearchService(GlobalSearchModule.CUSTOMER); + return searchService.startSearch(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + + @PostMapping("/contact") + @Operation(summary = "全局搜索联系人相关数据") + public PagerWithOption> advancedSearchCustomerContact(@Validated @RequestBody CustomerContactPageRequest request) { + BaseSearchService searchService = AdvancedSearchServiceFactory.getSearchService(GlobalSearchModule.CUSTOMER_CONTACT); + return searchService.startSearch(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + + @PostMapping("/lead") + @Operation(summary = "全局搜索线索相关数据") + public PagerWithOption> advancedSearchClue(@Validated @RequestBody CluePageRequest request) { + BaseSearchService searchService = AdvancedSearchServiceFactory.getSearchService(GlobalSearchModule.CLUE); + return searchService.startSearch(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + + @PostMapping("/lead/detail") + @Operation(summary = "全局搜索线索详情") + @RequiresPermissions(value = {PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CLUE_MANAGEMENT_POOL_READ}, logical = Logical.OR) + public Pager> getRepeatClueDetail(@Validated @RequestBody RepeatCustomerDetailPageRequest request) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, globalSearchCustomerService.getRepeatClueDetail(request, OrganizationContext.getOrganizationId())); + } + + @PostMapping("/opportunity/detail") + @Operation(summary = "全局搜索商机详情") + @RequiresPermissions(value = {PermissionConstants.OPPORTUNITY_MANAGEMENT_READ}) + public Pager> getRepeatOpportunityDetail(@Validated @RequestBody RepeatCustomerDetailPageRequest request) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, globalSearchCustomerService.getRepeatOpportunityDetail(request)); + } + + + @PostMapping("/opportunity") + @Operation(summary = "全局搜索-商机") + public PagerWithOption> advancedSearchOpportunity(@Validated @RequestBody OpportunityPageRequest request) { + BaseSearchService searchService = AdvancedSearchServiceFactory.getSearchService(GlobalSearchModule.OPPORTUNITY); + return searchService.startSearch(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + + @PostMapping("/account-pool") + @Operation(summary = "全局搜索-公海") + public PagerWithOption> advancedSearchCustomerPool(@Validated @RequestBody BasePageRequest request) { + BaseSearchService searchService = AdvancedSearchServiceFactory.getSearchService(GlobalSearchModule.CUSTOMER_POOL); + return searchService.startSearch(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/lead-pool") + @Operation(summary = "全局搜索-线索池") + public PagerWithOption> advancedSearchCluePool(@Validated @RequestBody BasePageRequest request) { + BaseSearchService searchService = AdvancedSearchServiceFactory.getSearchService(GlobalSearchModule.CLUE_POOL); + return searchService.startSearch(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/controller/GlobalSearchController.java b/backend/crm/src/main/java/cn/cordys/crm/search/controller/GlobalSearchController.java new file mode 100644 index 0000000..0caca8f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/controller/GlobalSearchController.java @@ -0,0 +1,76 @@ +package cn.cordys.crm.search.controller; + +import cn.cordys.aspectj.constants.GlobalSearchModule; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.OptionCountDTO; +import cn.cordys.common.pager.Pager; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.search.response.global.*; +import cn.cordys.crm.search.service.BaseSearchService; +import cn.cordys.crm.search.service.global.GlobalSearchCountService; +import cn.cordys.crm.search.service.global.GlobalSearchServiceFactory; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/global/search") +@Tag(name = "全局搜索") +public class GlobalSearchController { + + @Resource + private GlobalSearchCountService globalSearchCountService; + + @PostMapping("/opportunity") + @Operation(summary = "全局搜索-商机") + public Pager> globalSearchOpportunity(@Validated @RequestBody BasePageRequest request) { + BaseSearchService searchService = GlobalSearchServiceFactory.getSearchService(GlobalSearchModule.OPPORTUNITY); + return searchService.startSearchNoOption(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/customer_pool") + @Operation(summary = "全局搜索-公海") + public Pager> globalSearchCustomerPool(@Validated @RequestBody BasePageRequest request) { + BaseSearchService searchService = GlobalSearchServiceFactory.getSearchService(GlobalSearchModule.CUSTOMER_POOL); + return searchService.startSearchNoOption(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/lead") + @Operation(summary = "全局搜索-线索") + public Pager> globalSearchClue(@Validated @RequestBody BasePageRequest request) { + BaseSearchService searchService = GlobalSearchServiceFactory.getSearchService(GlobalSearchModule.CLUE); + return searchService.startSearchNoOption(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/clue_pool") + @Operation(summary = "全局搜索-线索池") + public Pager> globalSearchCluePool(@Validated @RequestBody BasePageRequest request) { + BaseSearchService searchService = GlobalSearchServiceFactory.getSearchService(GlobalSearchModule.CLUE_POOL); + return searchService.startSearchNoOption(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/account") + @Operation(summary = "全局搜索-客户") + public Pager> globalSearchCustomer(@Validated @RequestBody BasePageRequest request) { + BaseSearchService searchService = GlobalSearchServiceFactory.getSearchService(GlobalSearchModule.CUSTOMER); + return searchService.startSearchNoOption(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/contact") + @Operation(summary = "全局搜索-联系人") + public Pager> globalSearchContact(@Validated @RequestBody BasePageRequest request) { + BaseSearchService searchService = GlobalSearchServiceFactory.getSearchService(GlobalSearchModule.CUSTOMER_CONTACT); + return searchService.startSearchNoOption(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/module/count") + @Operation(summary = "全局搜索-模块数量统计") + public List globalSearchCount(@Validated @RequestParam String keyword) { + return globalSearchCountService.startSearchModuleCount(keyword, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/controller/SearchFieldMaskConfigController.java b/backend/crm/src/main/java/cn/cordys/crm/search/controller/SearchFieldMaskConfigController.java new file mode 100644 index 0000000..71c4211 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/controller/SearchFieldMaskConfigController.java @@ -0,0 +1,35 @@ +package cn.cordys.crm.search.controller; + + +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.search.request.FieldMaskConfigDTO; +import cn.cordys.crm.search.service.SearchFieldMaskConfigService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/mask/config") +@Tag(name = "脱敏字段配置") +public class SearchFieldMaskConfigController { + + @Resource + private SearchFieldMaskConfigService searchFieldMaskConfigService; + + + @PostMapping("/save") + @Operation(summary = "保存配置") + public void save(@Validated @RequestBody FieldMaskConfigDTO request) { + searchFieldMaskConfigService.save(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/get") + @Operation(summary = "获取脱敏字段配置") + public FieldMaskConfigDTO get() { + return searchFieldMaskConfigService.get(OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/controller/UserSearchConfigController.java b/backend/crm/src/main/java/cn/cordys/crm/search/controller/UserSearchConfigController.java new file mode 100644 index 0000000..961a1bb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/controller/UserSearchConfigController.java @@ -0,0 +1,45 @@ +package cn.cordys.crm.search.controller; + + +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.search.request.UserSearchConfigAddRequest; +import cn.cordys.crm.search.response.SearchFieldResponse; +import cn.cordys.crm.search.service.UserSearchConfigService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + + +@RestController +@RequestMapping("/search/config") +@Tag(name = "用户全局搜索字段配置") +public class UserSearchConfigController { + + @Resource + private UserSearchConfigService userSearchConfigService; + + + @PostMapping("/save") + @Operation(summary = "保存配置") + public void save(@Validated @RequestBody UserSearchConfigAddRequest request) { + userSearchConfigService.save(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/reset") + @Operation(summary = "重置") + public void reset() { + userSearchConfigService.reset(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/get") + @Operation(summary = "获取搜索字段配置") + public SearchFieldResponse get() { + return userSearchConfigService.get(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/domain/SearchFieldMaskConfig.java b/backend/crm/src/main/java/cn/cordys/crm/search/domain/SearchFieldMaskConfig.java new file mode 100644 index 0000000..06dc6df --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/domain/SearchFieldMaskConfig.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.search.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * 全局脱敏搜索配置 + */ +@Data +@Table(name = "sys_search_field_mask_config") +public class SearchFieldMaskConfig extends BaseModel { + @Schema(description = "搜索字段id") + private String fieldId; + + @Schema(description = "类型") + private String type; + + @Schema(description = "业务字段key") + private String businessKey; + + @Schema(description = "数据源对应类型") + private String dataSourceType; + + @Schema(description = "搜索模块(customer/lead/等)") + private String moduleType; + + @Schema(description = "组织id") + private String organizationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/domain/UserSearchConfig.java b/backend/crm/src/main/java/cn/cordys/crm/search/domain/UserSearchConfig.java new file mode 100644 index 0000000..ffc971e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/domain/UserSearchConfig.java @@ -0,0 +1,42 @@ +package cn.cordys.crm.search.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 用户全局搜索范围 + */ +@Data +@Table(name = "sys_user_search_config") +public class UserSearchConfig extends BaseModel { + + @Schema(description = "搜索字段id") + private String fieldId; + + @Schema(description = "类型") + private String type; + + @Schema(description = "业务字段key") + private String businessKey; + + @Schema(description = "数据源对应类型") + private String dataSourceType; + + @Schema(description = "用户id") + private String userId; + + @Schema(description = "搜索模块(customer/lead/等)") + private String moduleType; + + @Schema(description = "模块顺序设置['customer','clue']") + private String sortSetting; + + @Schema(description = "是否展示有搜索结果的列表") + private Boolean resultDisplay; + + @Schema(description = "组织id") + private String organizationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/mapper/ExtSearchFieldMaskConfigMapper.java b/backend/crm/src/main/java/cn/cordys/crm/search/mapper/ExtSearchFieldMaskConfigMapper.java new file mode 100644 index 0000000..92794a0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/mapper/ExtSearchFieldMaskConfigMapper.java @@ -0,0 +1,6 @@ +package cn.cordys.crm.search.mapper; + +public interface ExtSearchFieldMaskConfigMapper { + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/mapper/ExtSearchFieldMaskConfigMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/search/mapper/ExtSearchFieldMaskConfigMapper.xml new file mode 100644 index 0000000..85fe770 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/mapper/ExtSearchFieldMaskConfigMapper.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/mapper/ExtUserSearchConfigMapper.java b/backend/crm/src/main/java/cn/cordys/crm/search/mapper/ExtUserSearchConfigMapper.java new file mode 100644 index 0000000..7d8b9b9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/mapper/ExtUserSearchConfigMapper.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.search.mapper; + +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtUserSearchConfigMapper { + + //根据userIds删除用户搜索配置 + void deleteByUserIds(@Param("userIds") List userIds, @Param("orgId") String orgId); + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/mapper/ExtUserSearchConfigMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/search/mapper/ExtUserSearchConfigMapper.xml new file mode 100644 index 0000000..0cb3544 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/mapper/ExtUserSearchConfigMapper.xml @@ -0,0 +1,13 @@ + + + + + + + delete from sys_user_search_config where user_id in + + #{userId} + + and organization_id = #{orgId} + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/request/FieldMaskConfigDTO.java b/backend/crm/src/main/java/cn/cordys/crm/search/request/FieldMaskConfigDTO.java new file mode 100644 index 0000000..286ae58 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/request/FieldMaskConfigDTO.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.search.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class FieldMaskConfigDTO { + + @Schema(description = "搜索字段集合") + private Map> searchFields; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/request/UserSearchConfigAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/search/request/UserSearchConfigAddRequest.java new file mode 100644 index 0000000..7fecd71 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/request/UserSearchConfigAddRequest.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.search.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class UserSearchConfigAddRequest { + + @Schema(description = "搜索字段集合") + private Map> searchFields; + + @Schema(description = "是否展示有搜索结果的列表") + private Boolean resultDisplay = false; + + @Schema(description = "排序列表") + private List sortSetting; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/response/SearchFieldResponse.java b/backend/crm/src/main/java/cn/cordys/crm/search/response/SearchFieldResponse.java new file mode 100644 index 0000000..641ffbb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/response/SearchFieldResponse.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.search.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class SearchFieldResponse { + + @Schema(description = "搜索字段集合") + private Map> searchFields; + + @Schema(description = "是否展示有搜索结果的列表") + private Boolean resultDisplay; + + @Schema(description = "排序列表") + private List sortSetting; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedCluePoolResponse.java b/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedCluePoolResponse.java new file mode 100644 index 0000000..ad2ff65 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedCluePoolResponse.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.search.response.advanced; + +import cn.cordys.crm.clue.dto.response.ClueListResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class AdvancedCluePoolResponse extends ClueListResponse { + + @Schema(description = "线索池名称") + private String poolName; + + @Schema(description = "是否有当前数据的权限") + private boolean hasPermission; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedClueResponse.java b/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedClueResponse.java new file mode 100644 index 0000000..d8054d0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedClueResponse.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.search.response.advanced; + +import cn.cordys.crm.clue.dto.response.ClueListResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + + +/** + * @author guoyuqi + * @date 2025-02-08 16:24:22 + */ +@Data +public class AdvancedClueResponse extends ClueListResponse { + + @Schema(description = "是否有当前数据的权限") + private boolean hasPermission; + + @Schema(description = "意向产品名称列表") + private List productNameList; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedCustomerContactResponse.java b/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedCustomerContactResponse.java new file mode 100644 index 0000000..bf888b1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedCustomerContactResponse.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.search.response.advanced; + +import cn.cordys.crm.customer.dto.response.CustomerContactListResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class AdvancedCustomerContactResponse extends CustomerContactListResponse { + + @Schema(description = "是否有当前数据的权限") + private boolean hasPermission; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedCustomerPoolResponse.java b/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedCustomerPoolResponse.java new file mode 100644 index 0000000..0679171 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedCustomerPoolResponse.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.search.response.advanced; + +import cn.cordys.crm.customer.dto.response.CustomerListResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class AdvancedCustomerPoolResponse extends CustomerListResponse { + + @Schema(description = "公海名称") + private String poolName; + + + @Schema(description = "是否有当前数据的权限") + private boolean hasPermission; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedCustomerResponse.java b/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedCustomerResponse.java new file mode 100644 index 0000000..b042dff --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedCustomerResponse.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.search.response.advanced; + +import cn.cordys.crm.customer.dto.response.CustomerListResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class AdvancedCustomerResponse extends CustomerListResponse { + + @Schema(description = "线索重复数据数量") + private Integer clueCount; + + @Schema(description = "商机重复数据数量") + private Integer opportunityCount; + + @Schema(description = "线索模块是否开启") + private boolean clueModuleEnable; + + @Schema(description = "商机模块是否开启") + private boolean opportunityModuleEnable; + + @Schema(description = "是否有当前数据的权限") + private boolean hasPermission; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedOpportunityResponse.java b/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedOpportunityResponse.java new file mode 100644 index 0000000..58dcc4f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/AdvancedOpportunityResponse.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.search.response.advanced; + +import cn.cordys.crm.opportunity.dto.response.OpportunityListResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class AdvancedOpportunityResponse extends OpportunityListResponse { + + @Schema(description = "是否有当前数据的权限") + private boolean hasPermission; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/OpportunityRepeatResponse.java b/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/OpportunityRepeatResponse.java new file mode 100644 index 0000000..f376675 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/response/advanced/OpportunityRepeatResponse.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.search.response.advanced; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class OpportunityRepeatResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "商机名称") + private String name; + + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "客户名称") + private String customerName; + + @Schema(description = "商机阶段") + private String stage; + + @Schema(description = "意向产品Ids") + private List products; + + @Schema(description = "意向产品名称列表") + private List productNames; + + @Schema(description = "责任人") + private String owner; + + @Schema(description = "责任人名称") + private String ownerName; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalCluePoolResponse.java b/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalCluePoolResponse.java new file mode 100644 index 0000000..7c9aa9f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalCluePoolResponse.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.search.response.global; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class GlobalCluePoolResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "线索名称") + private String name; + + @Schema(description = "线索池id") + private String poolId; + + @Schema(description = "线索池名称") + private String poolName; + + @Schema(description = "电话") + private String phone; + + @Schema(description = "意向产品") + private List products; + + @Schema(description = "是否有当前数据的权限") + private boolean hasPermission; + + @Schema(description = "自定义字段集合") + private List moduleFields; + + @Schema(description = "最新跟进日期") + private Long followTime; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalClueResponse.java b/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalClueResponse.java new file mode 100644 index 0000000..d72e072 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalClueResponse.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.search.response.global; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class GlobalClueResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "线索名称") + private String name; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "意向产品") + private List products; + + @Schema(description = "电话") + private String phone; + + @Schema(description = "负责人部门名称") + private String departmentName; + + @Schema(description = "是否有当前数据的权限") + private boolean hasPermission; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "自定义字段集合") + private List moduleFields; + + @Schema(description = "最新跟进日期") + private Long followTime; + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalCustomerContactResponse.java b/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalCustomerContactResponse.java new file mode 100644 index 0000000..61f22c2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalCustomerContactResponse.java @@ -0,0 +1,50 @@ +package cn.cordys.crm.search.response.global; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class GlobalCustomerContactResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "联系人名称") + private String name; + + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "客户名称") + private String customerName; + + @Schema(description = "公海ID") + private String poolId; + + @Schema(description = "是否在公海池") + private Boolean inSharedPool; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "负责人部门名称") + private String departmentName; + + @Schema(description = "手机") + private String phone; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "是否有当前数据的权限") + private boolean hasPermission; + + @Schema(description = "自定义字段集合") + private List moduleFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalCustomerPoolResponse.java b/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalCustomerPoolResponse.java new file mode 100644 index 0000000..98a4c75 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalCustomerPoolResponse.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.search.response.global; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class GlobalCustomerPoolResponse { + @Schema(description = "ID") + private String id; + + @Schema(description = "客户名称") + private String name; + + @Schema(description = "公海id") + private String poolId; + + @Schema(description = "公海名称") + private String poolName; + + @Schema(description = "是否有当前数据的权限") + private boolean hasPermission; + + @Schema(description = "自定义字段集合") + private List moduleFields; + + @Schema(description = "最新跟进日期") + private Long followTime; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalCustomerResponse.java b/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalCustomerResponse.java new file mode 100644 index 0000000..42a7fe6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalCustomerResponse.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.search.response.global; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class GlobalCustomerResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "客户名称") + private String name; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "负责人部门名称") + private String departmentName; + + @Schema(description = "是否有当前数据的权限") + private boolean hasPermission; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "自定义字段集合") + private List moduleFields; + + @Schema(description = "最新跟进日期") + private Long followTime; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalOpportunityResponse.java b/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalOpportunityResponse.java new file mode 100644 index 0000000..b681e8f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/response/global/GlobalOpportunityResponse.java @@ -0,0 +1,65 @@ +package cn.cordys.crm.search.response.global; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class GlobalOpportunityResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "商机名称") + private String name; + + @Schema(description = "客户id") + private String customerId; + + @Schema(description = "客户名称") + private String customerName; + + @Schema(description = "关联的客户是否在公海") + private Boolean inCustomerPool; + + @Schema(description = "客户公海id") + private String poolId; + + @Schema(description = "联系人Id") + private String contactId; + + @Schema(description = "联系人名称") + private String contactName; + + @Schema(description = "负责人") + private String owner; + + @Schema(description = "负责人名称") + private String ownerName; + + @Schema(description = "意向产品") + private List products; + + @Schema(description = "商机阶段") + private String stage; + + @Schema(description = "商机阶段") + private String stageName; + + @Schema(description = "负责人部门名称") + private String departmentName; + + @Schema(description = "是否有当前数据的权限") + private boolean hasPermission; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "自定义字段集合") + private List moduleFields; + + @Schema(description = "最新跟进日期") + private Long followTime; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/BaseSearchService.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/BaseSearchService.java new file mode 100644 index 0000000..89dd1e8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/BaseSearchService.java @@ -0,0 +1,463 @@ +package cn.cordys.crm.search.service; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.condition.CombineSearch; +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.util.JSON; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.clue.domain.CluePool; +import cn.cordys.crm.customer.domain.CustomerPool; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.crm.product.mapper.ExtProductMapper; +import cn.cordys.crm.search.domain.SearchFieldMaskConfig; +import cn.cordys.crm.search.domain.UserSearchConfig; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.domain.Module; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.mapper.ExtModuleFieldMapper; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.crm.system.service.UserExtendService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +public abstract class BaseSearchService { + + @Resource + private BaseMapper moduleMapper; + + @Resource + private BaseMapper userSearchConfigBaseMapper; + + @Resource + private BaseMapper searchFieldMaskConfigBaseMapper; + + @Resource + private ExtProductMapper extProductMapper; + + @Resource + private ExtCustomerMapper extCustomerMapper; + + @Resource + private BaseMapper customerPoolMapper; + + @Resource + private BaseMapper cluePoolMapper; + + @Resource + private ExtModuleFieldMapper extModuleFieldMapper; + + @Resource + private ModuleFormService moduleFormService; + + @Resource + private UserExtendService userExtendService; + + public PagerWithOption> startSearch(T request, String orgId, String userId) { + return new PagerWithOption<>(); + } + + public Pager> startSearchNoOption(T request, String orgId, String userId) { + return new Pager<>(); + } + + /** + * 获取当前组织下所有已开启的模块key + * + * @return ListEnabledModuleKeys + */ + public List getEnabledModules() { + return moduleMapper.selectListByLambda( + new LambdaQueryWrapper() + .eq(Module::getOrganizationId, OrganizationContext.getOrganizationId()) + .eq(Module::getEnable, true) + ).stream() + .map(Module::getModuleKey) + .toList(); + } + + /** + * 获取过滤条件 + * + * @param name 字段名称 + * @param value 字段值 + * @param operator 操作符 + * @param type 字段类型 + * + * @return FilterCondition + */ + public FilterCondition getFilterCondition(String name, Object value, String operator, String type) { + FilterCondition nameCondition = new FilterCondition(); + nameCondition.setName(name); + nameCondition.setValue(value); + nameCondition.setOperator(operator); + nameCondition.setMultipleValue(Strings.CI.equals(type, FieldType.DATA_SOURCE_MULTIPLE.toString())); + nameCondition.setType(type); + return nameCondition; + } + + + /** + * 获取产品ID和名称的映射关系 + * + * @param orgId 组织ID + * + * @return 产品ID和名称的映射关系 + */ + public Map getProductNameMap(String orgId) { + List productOption = extProductMapper.getOptions(orgId); + return productOption.stream().collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + } + + /** + * 通过关键字和组织ID获取客户ID列表 + * + * @param keyword 关键字 + * @param orgId 组织ID + * + * @return 客户ID列表 + */ + public List getCustomerIds(String keyword, String orgId) { + List customerOptions = extCustomerMapper.getCustomerOptions(keyword, orgId); + if (CollectionUtils.isEmpty(customerOptions)) { + return new ArrayList<>(); + } + return customerOptions.stream() + .map(OptionDTO::getId) + .toList(); + } + + /** + * 获取用户有权限的公海池 + * + * @param orgId 组织ID + * @param userId 用户ID + * + * @return 公海池ID和名称的映射关系 + */ + public Map getUserCustomerPool(String orgId, String userId) { + Map poolMap = new HashMap<>(); + LambdaQueryWrapper poolWrapper = new LambdaQueryWrapper<>(); + poolWrapper.eq(CustomerPool::getEnable, true).eq(CustomerPool::getOrganizationId, orgId); + poolWrapper.orderByDesc(CustomerPool::getUpdateTime); + List pools = customerPoolMapper.selectListByLambda(poolWrapper); + pools.forEach(pool -> { + List scopeIds = userExtendService.getScopeOwnerIds(JSON.parseArray(pool.getScopeId(), String.class), orgId); + List ownerIds = userExtendService.getScopeOwnerIds(JSON.parseArray(pool.getOwnerId(), String.class), orgId); + if (scopeIds.contains(userId) || ownerIds.contains(userId) || Strings.CS.equals(userId, InternalUser.ADMIN.getValue())) { + poolMap.put(pool.getId(), pool.getName()); + } + }); + return poolMap; + } + + + /** + * 获取用户有权限的线索池 + * + * @param orgId 组织ID + * @param userId 用户ID + * + * @return 线索池ID和名称的映射关系 + */ + public Map getUserCluePool(String orgId, String userId) { + Map poolMap = new HashMap<>(); + LambdaQueryWrapper poolWrapper = new LambdaQueryWrapper<>(); + poolWrapper.eq(CluePool::getEnable, true).eq(CluePool::getOrganizationId, orgId); + poolWrapper.orderByDesc(CluePool::getUpdateTime); + List pools = cluePoolMapper.selectListByLambda(poolWrapper); + pools.forEach(pool -> { + List scopeIds = userExtendService.getScopeOwnerIds(JSON.parseArray(pool.getScopeId(), String.class), orgId); + List ownerIds = userExtendService.getScopeOwnerIds(JSON.parseArray(pool.getOwnerId(), String.class), orgId); + if (scopeIds.contains(userId) || ownerIds.contains(userId) || Strings.CS.equals(userId, InternalUser.ADMIN.getValue())) { + poolMap.put(pool.getId(), pool.getName()); + } + }); + return poolMap; + } + + /** + * 构建其他类型的过滤条件 + * + * @param orgId 组织ID + * @param userSearchConfig 用户搜索配置 + * @param keyword 关键字 + * @param conditions 过滤条件列表 + */ + public void buildOtherFilterCondition(String orgId, UserSearchConfig userSearchConfig, String keyword, List conditions) { + String name; + if (StringUtils.isNotBlank(userSearchConfig.getBusinessKey())) { + name = userSearchConfig.getBusinessKey(); + } else { + name = userSearchConfig.getFieldId(); + } + //如果是businessKey不为空,说明是固定列,则name 给businessKey + // 如果不是数据源类型的字段,直接添加到查询条件中 + if (StringUtils.isBlank(userSearchConfig.getDataSourceType()) && !Strings.CI.equals(userSearchConfig.getType(), FieldType.PHONE.toString())) { + FilterCondition filterCondition = getFilterCondition(name, keyword, FilterCondition.CombineConditionOperator.CONTAINS.toString(), FieldType.INPUT.toString()); + conditions.add(filterCondition); + return; + } + // 如果是PHONE类型的字段,使用精确查询 + if (Strings.CI.equals(userSearchConfig.getType(), FieldType.PHONE.toString())) { + keyword = StringUtils.deleteWhitespace(keyword); + FilterCondition filterCondition = getFilterCondition(name, keyword, FilterCondition.CombineConditionOperator.EQUALS.toString(), FieldType.PHONE.toString()); + conditions.add(filterCondition); + return; + } + // 如果是数据源类型的字段,使用IN查询 + if (Strings.CI.equals(userSearchConfig.getType(), FieldType.DATA_SOURCE.toString()) || Strings.CI.equals(userSearchConfig.getType(), FieldType.DATA_SOURCE_MULTIPLE.toString())) { + List ids = new ArrayList<>(); + + Map sourceMap = moduleFormService.initTypeSourceMap(); + String tableName = sourceMap.get(userSearchConfig.getDataSourceType()); + if (StringUtils.isBlank(tableName)) { + extModuleFieldMapper.getCustomFormOptionsByName(keyword, orgId).forEach(option -> ids.add(option.getId())); + } else { + extModuleFieldMapper.getSourceOptionsByName(tableName, keyword, orgId).forEach(option -> ids.add(option.getId())); + } + if (CollectionUtils.isEmpty(ids)) { + return; // 如果没有匹配的数据,直接返回 + } + FilterCondition filterCondition = getFilterCondition(name, ids, FilterCondition.CombineConditionOperator.IN.toString(), FieldType.DATA_SOURCE.toString()); + conditions.add(filterCondition); + } + } + + /** + * 获取搜索字段脱敏配置 + * + * @param orgId 组织ID + * + * @return 搜索字段脱敏配置列表 + */ + public List getSearchFieldMaskConfigs(String orgId, String moduleType) { + LambdaQueryWrapper sysWrapper = new LambdaQueryWrapper<>(); + sysWrapper.eq(SearchFieldMaskConfig::getModuleType, moduleType).eq(SearchFieldMaskConfig::getOrganizationId, orgId); + return searchFieldMaskConfigBaseMapper.selectListByLambda(sysWrapper); + } + + /** + * 获取用户搜索配置 + * + * @param userId 用户ID + * + * @return 用户搜索配置列表 + */ + public List getUserSearchConfigs(String userId, String orgId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UserSearchConfig::getUserId, userId) + .eq(UserSearchConfig::getOrganizationId, orgId); + return userSearchConfigBaseMapper.selectListByLambda(wrapper); + } + + /** + * 获取自定义字段值 + * + * @param fieldIdSet 用户配置的字段ID集合 + * @param id 数据ID + * @param moduleFiledMap 数据ID和自定义字段值的映射关系 + * @param moduleFormConfigDTO 模块表单配置 + * @param searchFieldMaskConfigMap 脱敏配置映射关系 + * @param hasPermission 是否有权限查看 + * + * @return 自定义字段值列表 + */ + public List getBaseModuleFieldValues(Set fieldIdSet, String id, Map> moduleFiledMap, ModuleFormConfigDTO moduleFormConfigDTO, Map searchFieldMaskConfigMap, boolean hasPermission) { + List returnBaseModuleFieldValues = new ArrayList<>(); + //此条数据所带的自定义字段及其值 + List baseModuleFieldValues = moduleFiledMap.get(id); + if (CollectionUtils.isEmpty(baseModuleFieldValues)) { + return new ArrayList<>(); + } + Map fieldValueMap = baseModuleFieldValues.stream().collect(Collectors.toMap(BaseModuleFieldValue::getFieldId, BaseModuleFieldValue::getFieldValue)); + //获取自定义字段选项,用于补充数据源的值 + Map> optionMap = moduleFormService.getOptionMap(moduleFormConfigDTO, baseModuleFieldValues); + List fields = moduleFormConfigDTO.getFields(); + Map fieldTypeMap = fields.stream().collect(Collectors.toMap(BaseField::getId, BaseField::getType)); + for (BaseModuleFieldValue baseModuleFieldValue : baseModuleFieldValues) { + String fieldId = baseModuleFieldValue.getFieldId(); + if (fieldIdSet.contains(fieldId)) { + SearchFieldMaskConfig searchFieldMaskConfig = searchFieldMaskConfigMap.get(fieldId); + BaseModuleFieldValue returnBaseModuleFieldValue = new BaseModuleFieldValue(); + returnBaseModuleFieldValue.setFieldId(fieldId); + Object fieldValue = fieldValueMap.get(fieldId); + if (fieldValue instanceof String) { + //数据源类型的字段,进行值的转换 + if (Strings.CI.equals(fieldTypeMap.get(fieldId), FieldType.DATA_SOURCE.toString())) { + List options = optionMap.get(fieldId); + if (CollectionUtils.isEmpty(options)) { + continue; + } + for (OptionDTO optionDTO : options) { + if (Strings.CI.equals(optionDTO.getId(), (String) fieldValue)) { + fieldValue = optionDTO.getName(); + break; + } + } + } + //无权限根据类型进行脱敏 + if (!hasPermission) { + if (searchFieldMaskConfig != null) { + fieldValue = setStringFieldValue(searchFieldMaskConfig, fieldValue); + } + } + returnBaseModuleFieldValue.setFieldValue(fieldValue); + } else if (fieldValue instanceof List) { + List fieldValueList = new ArrayList<>(); + for (Object element : (List) fieldValue) { + String s = element == null ? null : element.toString(); + List optionDTOs = optionMap.get(fieldId); + if (CollectionUtils.isEmpty(optionDTOs)) { + continue; + } + for (OptionDTO optionDTO : optionDTOs) { + if (Strings.CI.equals(optionDTO.getId(), s)) { + String name = optionDTO.getName(); + if (!hasPermission) { + getInputFieldValue(name, name.length()); + } + fieldValueList.add(name); + } + } + } + returnBaseModuleFieldValue.setFieldValue(fieldValueList); + } + returnBaseModuleFieldValues.add(returnBaseModuleFieldValue); + } + } + return returnBaseModuleFieldValues; + } + + /** + * 设置字符串类型字段的脱敏值 + * + * @param searchFieldMaskConfig 脱敏配置 + * @param fieldValue 字段值 + * + * @return 脱敏后的字段值 + */ + private Object setStringFieldValue(SearchFieldMaskConfig searchFieldMaskConfig, Object fieldValue) { + int length = ((String) fieldValue).length(); + if (Strings.CI.equals(searchFieldMaskConfig.getType(), FieldType.PHONE.toString()) || Strings.CI.equals(searchFieldMaskConfig.getType(), FieldType.SERIAL_NUMBER.toString())) { + //fieldValue后6位以*替代 + fieldValue = getPhoneFieldValue(fieldValue, length); + } else if (Strings.CI.equals(searchFieldMaskConfig.getType(), FieldType.INPUT.toString()) || Strings.CI.equals(searchFieldMaskConfig.getType(), FieldType.DATA_SOURCE.toString())) { + fieldValue = getInputFieldValue(fieldValue, length); + } + return fieldValue; + } + + public Object getPhoneFieldValue(Object fieldValue, int length) { + if (length > 6) { + fieldValue = ((String) fieldValue).substring(0, length - 6) + "******"; + } else { + fieldValue = "******"; + } + return fieldValue; + } + + public Object getInputFieldValue(Object fieldValue, int length) { + //fieldValue保留第一位字符,后面全部用*代替 + if (length > 1) { + fieldValue = ((String) fieldValue).charAt(0) + "*".repeat(length - 1); + } else { + fieldValue = "*"; + } + return fieldValue; + } + + /** + * 通过产品ID列表获取产品名称列表 + * + * @param products 产品ID列表 + * @param productNameMap 产品ID和名称的映射关系 + * + * @return 产品名称列表 + */ + public List getProductNames(List products, Map productNameMap) { + if (CollectionUtils.isEmpty(products)) { + return new ArrayList<>(); + } + List productNames = new ArrayList<>(); + for (String product : products) { + String productName = productNameMap.get(product); + if (StringUtils.isNotBlank(productName)) { + productNames.add(productName); + } + } + return productNames; + } + + /** + * 构建组合查询条件 + * + * @param conditions 过滤条件列表 + * @param request 分页请求 + */ + public void buildCombineSearch(List conditions, BasePageRequest request) { + CombineSearch combineSearch = new CombineSearch(); + combineSearch.setSearchMode(CombineSearch.SearchMode.OR.toString()); + combineSearch.setConditions(conditions); + request.setCombineSearch(combineSearch); + request.setKeyword(null); + } + + /** + * 构建内置字段 + * + * @param internalKeyMap 内置字段映射关系 + * @param entity 实体对象 + * @param clazz 实体类 + * @param 实体类型 + * + * @return 内置字段列表 + */ + public List buildInternalField(Map internalKeyMap, Map searchFieldMaskConfigMap, boolean hasPermission, A entity, + Class clazz) { + List returnOpportunityFields = new ArrayList<>(); + if (entity == null) { + return returnOpportunityFields; + } + List capitalizedList = internalKeyMap.values().stream() + .map(s -> s.substring(0, 1).toUpperCase() + s.substring(1)) + .toList(); + for (String s : capitalizedList) { + try { + String methodName = "get" + s; + String value = (String) clazz.getMethod(methodName).invoke(entity); + BaseModuleFieldValue baseModuleFieldValue = new BaseModuleFieldValue(); + baseModuleFieldValue.setFieldValue(value); + internalKeyMap.forEach((k, v) -> { + if (Strings.CI.equals(v, s)) { + baseModuleFieldValue.setFieldId(k); + } + }); + SearchFieldMaskConfig searchFieldMaskConfig = searchFieldMaskConfigMap.get(baseModuleFieldValue.getFieldId()); + if (!hasPermission && searchFieldMaskConfig != null) { + Object fieldValue = setStringFieldValue(searchFieldMaskConfig, value); + baseModuleFieldValue.setFieldValue(fieldValue); + } + returnOpportunityFields.add(baseModuleFieldValue); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + return returnOpportunityFields; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/SearchFieldMaskConfigService.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/SearchFieldMaskConfigService.java new file mode 100644 index 0000000..a1234ae --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/SearchFieldMaskConfigService.java @@ -0,0 +1,162 @@ +package cn.cordys.crm.search.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.search.constants.SearchModuleEnum; +import cn.cordys.crm.search.domain.SearchFieldMaskConfig; +import cn.cordys.crm.search.request.FieldMaskConfigDTO; +import cn.cordys.crm.system.dto.field.DatasourceField; +import cn.cordys.crm.system.dto.field.DatasourceMultipleField; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class SearchFieldMaskConfigService { + + @Resource + private BaseMapper searchFieldMaskConfigMapper; + @Resource + private ModuleFormCacheService moduleFormCacheService; + + /** + * 保存脱敏字段配置 + * + * @param request + * @param userId + * @param orgId + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE) + public void save(FieldMaskConfigDTO request, String userId, String orgId) { + FieldMaskConfigDTO fieldMaskConfigDTO = get(orgId); + deleteFieldMaskConfig(orgId); + request.getSearchFields().forEach((key, value) -> { + switch (key) { + case SearchModuleEnum.SEARCH_ADVANCED_CLUE: + saveMaskFields(value, userId, orgId, FormKey.CLUE.getKey(), key); + break; + case SearchModuleEnum.SEARCH_ADVANCED_CUSTOMER: + saveMaskFields(value, userId, orgId, FormKey.CUSTOMER.getKey(), key); + break; + case SearchModuleEnum.SEARCH_ADVANCED_CONTACT: + saveMaskFields(value, userId, orgId, FormKey.CONTACT.getKey(), key); + break; + case SearchModuleEnum.SEARCH_ADVANCED_PUBLIC: + saveMaskFields(value, userId, orgId, FormKey.CUSTOMER.getKey(), key); + break; + case SearchModuleEnum.SEARCH_ADVANCED_CLUE_POOL: + saveMaskFields(value, userId, orgId, FormKey.CLUE.getKey(), key); + break; + case SearchModuleEnum.SEARCH_ADVANCED_OPPORTUNITY: + saveMaskFields(value, userId, orgId, FormKey.OPPORTUNITY.getKey(), key); + break; + default: + break; + } + }); + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(orgId) + .resourceName(Translator.get("module.desensitization_set")) + .originalValue(fieldMaskConfigDTO.getSearchFields()) + .modifiedValue(request.getSearchFields()) + .build() + ); + } + + + /** + * 保存脱敏字段 + * + * @param fieldIds + * @param userId + * @param orgId + * @param formKey + * @param moduleType + */ + private void saveMaskFields(List fieldIds, String userId, String orgId, String formKey, String moduleType) { + if (CollectionUtils.isNotEmpty(fieldIds)) { + ModuleFormConfigDTO businessFormConfig = moduleFormCacheService.getBusinessFormConfig(formKey, orgId); + List fields = businessFormConfig.getFields(); + List searchConfigs = new ArrayList<>(); + + for (String fieldId : fieldIds) { + BaseField baseField = fields.stream().filter(field -> Strings.CI.equals(field.getId(), fieldId)).findFirst().orElse(null); + if (baseField == null) { + continue; + } + SearchFieldMaskConfig fieldMaskConfig = new SearchFieldMaskConfig(); + fieldMaskConfig.setId(IDGenerator.nextStr()); + fieldMaskConfig.setFieldId(baseField.getId()); + fieldMaskConfig.setType(baseField.getType()); + fieldMaskConfig.setBusinessKey(baseField.getBusinessKey()); + if (baseField.getType().equals("DATA_SOURCE")) { + fieldMaskConfig.setDataSourceType(((DatasourceField) baseField).getDataSourceType()); + } + if (baseField.getType().equals("DATA_SOURCE_MULTIPLE")) { + fieldMaskConfig.setDataSourceType(((DatasourceMultipleField) baseField).getDataSourceType()); + } + fieldMaskConfig.setModuleType(moduleType); + fieldMaskConfig.setOrganizationId(orgId); + fieldMaskConfig.setCreateUser(userId); + fieldMaskConfig.setUpdateUser(userId); + fieldMaskConfig.setCreateTime(System.currentTimeMillis()); + fieldMaskConfig.setUpdateTime(System.currentTimeMillis()); + searchConfigs.add(fieldMaskConfig); + } + + searchFieldMaskConfigMapper.batchInsert(searchConfigs); + } + + } + + + private void deleteFieldMaskConfig(String orgId) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(SearchFieldMaskConfig::getOrganizationId, orgId); + searchFieldMaskConfigMapper.deleteByLambda(queryWrapper); + } + + + /** + * 获取脱敏字段配置 + * + * @param orgId + * + * @return + */ + public FieldMaskConfigDTO get(String orgId) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(SearchFieldMaskConfig::getOrganizationId, orgId); + List searchFieldMaskConfigs = searchFieldMaskConfigMapper.selectListByLambda(queryWrapper); + + FieldMaskConfigDTO response = new FieldMaskConfigDTO(); + Map> searchFields = searchFieldMaskConfigs.stream() + .collect(Collectors.groupingBy( + SearchFieldMaskConfig::getModuleType, + Collectors.mapping(SearchFieldMaskConfig::getFieldId, + Collectors.toList()) + )); + response.setSearchFields(searchFields); + return response; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/UserSearchConfigService.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/UserSearchConfigService.java new file mode 100644 index 0000000..95313c8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/UserSearchConfigService.java @@ -0,0 +1,317 @@ +package cn.cordys.crm.search.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.search.constants.DefaultSearchFieldEnum; +import cn.cordys.crm.search.constants.SearchModuleEnum; +import cn.cordys.crm.search.domain.UserSearchConfig; +import cn.cordys.crm.search.mapper.ExtUserSearchConfigMapper; +import cn.cordys.crm.search.request.UserSearchConfigAddRequest; +import cn.cordys.crm.search.response.SearchFieldResponse; +import cn.cordys.crm.system.dto.field.DatasourceField; +import cn.cordys.crm.system.dto.field.DatasourceMultipleField; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class UserSearchConfigService { + + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private BaseMapper userSearchConfigMapper; + @Resource + private ExtUserSearchConfigMapper extUserSearchConfigMapper; + + + /** + * 保存配置 + * + * @param request + * @param userId + * @param orgId + */ + public void save(UserSearchConfigAddRequest request, String userId, String orgId) { + boolean allEmpty = request.getSearchFields().values().stream() + .allMatch(list -> list == null || list.isEmpty()); + if (allEmpty) { + throw new GenericException(Translator.get("search.config.empty")); + } + deleteUserSearchConfig(userId, orgId); + request.getSearchFields().forEach((key, value) -> { + if (value.size() > 5) { + throw new GenericException(Translator.get("search.config.limit")); + } + switch (key) { + case SearchModuleEnum.SEARCH_ADVANCED_CLUE, SearchModuleEnum.SEARCH_ADVANCED_CLUE_POOL: + saveSearchFields(value, userId, orgId, FormKey.CLUE.getKey(), key, request); + break; + case SearchModuleEnum.SEARCH_ADVANCED_CUSTOMER, SearchModuleEnum.SEARCH_ADVANCED_PUBLIC: + saveSearchFields(value, userId, orgId, FormKey.CUSTOMER.getKey(), key, request); + break; + case SearchModuleEnum.SEARCH_ADVANCED_CONTACT: + saveSearchFields(value, userId, orgId, FormKey.CONTACT.getKey(), key, request); + break; + case SearchModuleEnum.SEARCH_ADVANCED_OPPORTUNITY: + saveSearchFields(value, userId, orgId, FormKey.OPPORTUNITY.getKey(), key, request); + break; + default: + break; + } + }); + } + + + /** + * 清空用户配置 + * + * @param userId + * @param orgId + */ + private void deleteUserSearchConfig(String userId, String orgId) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(UserSearchConfig::getUserId, userId).eq(UserSearchConfig::getOrganizationId, orgId); + userSearchConfigMapper.deleteByLambda(queryWrapper); + } + + + private void saveSearchFields(List fieldIds, String userId, String orgId, String formKey, String moduleType, UserSearchConfigAddRequest request) { + if (CollectionUtils.isNotEmpty(fieldIds)) { + ModuleFormConfigDTO businessFormConfig = moduleFormCacheService.getBusinessFormConfig(formKey, orgId); + List fields = businessFormConfig.getFields(); + List searchConfigs = new ArrayList<>(); + for (String fieldId : fieldIds) { + BaseField baseField = fields.stream().filter(field -> Strings.CI.equals(field.getId(), fieldId)).findFirst().orElse(null); + if (baseField == null) { + continue; + } + UserSearchConfig userSearchConfig = new UserSearchConfig(); + userSearchConfig.setId(IDGenerator.nextStr()); + userSearchConfig.setFieldId(baseField.getId()); + userSearchConfig.setType(baseField.getType()); + userSearchConfig.setBusinessKey(baseField.getBusinessKey()); + if (baseField.getType().equals("DATA_SOURCE")) { + userSearchConfig.setDataSourceType(((DatasourceField) baseField).getDataSourceType()); + } + if (baseField.getType().equals("DATA_SOURCE_MULTIPLE")) { + userSearchConfig.setDataSourceType(((DatasourceMultipleField) baseField).getDataSourceType()); + } + userSearchConfig.setUserId(userId); + userSearchConfig.setModuleType(moduleType); + userSearchConfig.setSortSetting(JSON.toJSONString(request.getSortSetting())); + userSearchConfig.setResultDisplay(request.getResultDisplay()); + userSearchConfig.setOrganizationId(orgId); + userSearchConfig.setCreateUser(userId); + userSearchConfig.setUpdateUser(userId); + userSearchConfig.setCreateTime(System.currentTimeMillis()); + userSearchConfig.setUpdateTime(System.currentTimeMillis()); + searchConfigs.add(userSearchConfig); + } + + userSearchConfigMapper.batchInsert(searchConfigs); + } + } + + + /** + * 重置配置 + * + * @param userId + * @param orgId + */ + public void reset(String userId, String orgId) { + deleteUserSearchConfig(userId, orgId); + List defaultSearchConfigs = new ArrayList<>(); + //客户&公海 + buildCustomerFields(FormKey.CUSTOMER.name(), userId, orgId, defaultSearchConfigs); + //线索&线索池 + buildClueFields(FormKey.CLUE.name(), userId, orgId, defaultSearchConfigs); + //商机 + buildOpportunityFields(FormKey.OPPORTUNITY.name(), userId, orgId, defaultSearchConfigs); + //联系人 + buildContactFields(FormKey.CONTACT.name(), userId, orgId, defaultSearchConfigs); + + userSearchConfigMapper.batchInsert(defaultSearchConfigs); + + } + + /** + * 联系人默认字段 + * + * @param formKey + * @param userId + * @param orgId + * @param defaultSearchConfigs + */ + private void buildContactFields(String formKey, String userId, String orgId, List defaultSearchConfigs) { + ModuleFormConfigDTO businessFormConfig = moduleFormCacheService.getBusinessFormConfig(formKey, orgId); + businessFormConfig.getFields().forEach(field -> { + if (Strings.CI.equals(field.getInternalKey(), DefaultSearchFieldEnum.CONTACT_CUSTOMER)) { + buildDto(field, userId, orgId, SearchModuleEnum.SEARCH_ADVANCED_CONTACT, defaultSearchConfigs); + } + if (Strings.CI.equals(field.getInternalKey(), DefaultSearchFieldEnum.CONTACT_PHONE)) { + buildDto(field, userId, orgId, SearchModuleEnum.SEARCH_ADVANCED_CONTACT, defaultSearchConfigs); + } + }); + } + + + /** + * 线索/线索池默认字段 + * + * @param formKey + * @param userId + * @param orgId + * @param defaultSearchConfigs + */ + private void buildClueFields(String formKey, String userId, String orgId, List defaultSearchConfigs) { + ModuleFormConfigDTO businessFormConfig = moduleFormCacheService.getBusinessFormConfig(formKey, orgId); + businessFormConfig.getFields().forEach(field -> { + if (Strings.CI.equals(field.getInternalKey(), DefaultSearchFieldEnum.CLUE_NAME)) { + buildDto(field, userId, orgId, SearchModuleEnum.SEARCH_ADVANCED_CLUE, defaultSearchConfigs); + buildDto(field, userId, orgId, SearchModuleEnum.SEARCH_ADVANCED_CLUE_POOL, defaultSearchConfigs); + } + if (Strings.CI.equals(field.getInternalKey(), DefaultSearchFieldEnum.CLUE_CONTACT_PHONE)) { + buildDto(field, userId, orgId, SearchModuleEnum.SEARCH_ADVANCED_CLUE, defaultSearchConfigs); + buildDto(field, userId, orgId, SearchModuleEnum.SEARCH_ADVANCED_CLUE_POOL, defaultSearchConfigs); + } + }); + } + + + /** + * 客户/公海默认字段 + * + * @param formKey + * @param userId + * @param orgId + * @param defaultSearchConfigs + */ + private void buildCustomerFields(String formKey, String userId, String orgId, List defaultSearchConfigs) { + ModuleFormConfigDTO businessFormConfig = moduleFormCacheService.getBusinessFormConfig(formKey, orgId); + businessFormConfig.getFields().forEach(field -> { + if (Strings.CI.equals(field.getInternalKey(), DefaultSearchFieldEnum.CUSTOMER_NAME)) { + buildDto(field, userId, orgId, SearchModuleEnum.SEARCH_ADVANCED_CUSTOMER, defaultSearchConfigs); + buildDto(field, userId, orgId, SearchModuleEnum.SEARCH_ADVANCED_PUBLIC, defaultSearchConfigs); + + } + }); + } + + + /** + * 商机默认字段 + * + * @param formKey + * @param userId + * @param orgId + * @param defaultSearchConfigs + */ + private void buildOpportunityFields(String formKey, String userId, String orgId, List defaultSearchConfigs) { + ModuleFormConfigDTO businessFormConfig = moduleFormCacheService.getBusinessFormConfig(formKey, orgId); + businessFormConfig.getFields().forEach(field -> { + if (Strings.CI.equals(field.getInternalKey(), DefaultSearchFieldEnum.OPPORTUNITY_NAME)) { + buildDto(field, userId, orgId, SearchModuleEnum.SEARCH_ADVANCED_OPPORTUNITY, defaultSearchConfigs); + } + if (Strings.CI.equals(field.getInternalKey(), DefaultSearchFieldEnum.OPPORTUNITY_CUSTOMER)) { + buildDto(field, userId, orgId, SearchModuleEnum.SEARCH_ADVANCED_OPPORTUNITY, defaultSearchConfigs); + } + }); + } + + + private void buildDto(BaseField field, String userId, String orgId, String moduleType, List defaultSearchConfigs) { + UserSearchConfig userSearchConfig = new UserSearchConfig(); + userSearchConfig.setId(IDGenerator.nextStr()); + userSearchConfig.setFieldId(field.getId()); + userSearchConfig.setType(field.getType()); + userSearchConfig.setBusinessKey(field.getBusinessKey()); + if (field.getType().equals("DATA_SOURCE")) { + userSearchConfig.setDataSourceType(((DatasourceField) field).getDataSourceType()); + } else if (field.getType().equals("DATA_SOURCE_MULTIPLE")) { + userSearchConfig.setDataSourceType(((DatasourceMultipleField) field).getDataSourceType()); + } else { + userSearchConfig.setDataSourceType(null); + } + userSearchConfig.setUserId(userId); + userSearchConfig.setModuleType(moduleType); + userSearchConfig.setSortSetting(JSON.toJSONString(SearchModuleEnum.VALUES)); + userSearchConfig.setResultDisplay(false); + userSearchConfig.setOrganizationId(orgId); + userSearchConfig.setCreateUser(userId); + userSearchConfig.setUpdateUser(userId); + userSearchConfig.setCreateTime(System.currentTimeMillis()); + userSearchConfig.setUpdateTime(System.currentTimeMillis()); + defaultSearchConfigs.add(userSearchConfig); + } + + + /** + * 获取搜索字段配置 + * + * @param userId + * @param orgId + * + * @return + */ + public SearchFieldResponse get(String userId, String orgId) { + if (!configCount(userId, orgId)) { + reset(userId, orgId); + } + List searchConfigs = getConfigList(userId, orgId); + + SearchFieldResponse response = new SearchFieldResponse(); + Map> searchFields = searchConfigs.stream() + .collect(Collectors.groupingBy( + UserSearchConfig::getModuleType, + Collectors.mapping(UserSearchConfig::getFieldId, + Collectors.toList()) + )); + + response.setSearchFields(searchFields); + response.setResultDisplay(searchConfigs.getFirst().getResultDisplay()); + response.setSortSetting(JSON.parseArray(searchConfigs.getFirst().getSortSetting())); + + return response; + + } + + private boolean configCount(String userId, String orgId) { + UserSearchConfig example = new UserSearchConfig(); + example.setUserId(userId); + example.setOrganizationId(orgId); + return userSearchConfigMapper.countByExample(example) > 0; + } + + + /** + * 获取用户搜索配置字段数据 + * + * @param userId + * @param orgId + * + * @return + */ + private List getConfigList(String userId, String orgId) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(UserSearchConfig::getUserId, userId).eq(UserSearchConfig::getOrganizationId, orgId); + return userSearchConfigMapper.selectListByLambda(queryWrapper); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedCluePoolSearchService.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedCluePoolSearchService.java new file mode 100644 index 0000000..bb53026 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedCluePoolSearchService.java @@ -0,0 +1,236 @@ +package cn.cordys.crm.search.service.advanced; + +import cn.cordys.common.constants.*; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.UserDeptDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.clue.domain.CluePool; +import cn.cordys.crm.clue.domain.CluePoolRecycleRule; +import cn.cordys.crm.clue.dto.response.ClueListResponse; +import cn.cordys.crm.clue.mapper.ExtClueMapper; +import cn.cordys.crm.clue.service.ClueFieldService; +import cn.cordys.crm.clue.service.CluePoolService; +import cn.cordys.crm.product.mapper.ExtProductMapper; +import cn.cordys.crm.search.response.advanced.AdvancedCluePoolResponse; +import cn.cordys.crm.search.service.BaseSearchService; +import cn.cordys.crm.system.constants.DictModule; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.constants.SystemResultCode; +import cn.cordys.crm.system.domain.Dict; +import cn.cordys.crm.system.dto.DictConfigDTO; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.DictService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +public class AdvancedCluePoolSearchService extends BaseSearchService { + + @Resource + private ExtClueMapper extClueMapper; + @Resource + private ClueFieldService clueFieldService; + @Resource + private BaseService baseService; + @Resource + private CluePoolService cluePoolService; + @Resource + private BaseMapper recycleRuleMapper; + @Resource + private DictService dictService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private ExtProductMapper extProductMapper; + @Resource + private DataScopeService dataScopeService; + + @Override + public PagerWithOption> startSearch(BasePageRequest request, String orgId, String userId) { + // 查询当前组织下已启用的模块列表 + List enabledModules = getEnabledModules(); + + // 检查:如果线索模块未启用,抛出异常 + if (!enabledModules.contains(ModuleKey.CLUE.getKey())) { + throw new GenericException(SystemResultCode.MODULE_ENABLE); + } + ConditionFilterUtils.parseCondition(request, FormKey.CLUE.getKey()); + // 查询重复线索池线索列表 + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extClueMapper.cluePoolList(request, orgId); + if (CollectionUtils.isEmpty(list)) { + return PageUtils.setPageInfoWithOption(page, List.of(), Map.of()); + } + List buildList = buildListData(list, orgId, userId); + Map> optionMap = buildOptionMap(orgId, list, buildList); + return PageUtils.setPageInfoWithOption(page, buildList, optionMap); + } + + public List buildListData(List list, String orgId, String userId) { + if (CollectionUtils.isEmpty(list)) { + return list; + } + List clueIds = list.stream().map(ClueListResponse::getId) + .collect(Collectors.toList()); + + Map> caseCustomFiledMap = clueFieldService.getResourceFieldMap(clueIds, true); + + List ownerIds = list.stream() + .map(ClueListResponse::getOwner) + .distinct() + .toList(); + + List followerIds = list.stream() + .map(ClueListResponse::getFollower) + .distinct() + .toList(); + List createUserIds = list.stream() + .map(ClueListResponse::getCreateUser) + .distinct() + .toList(); + List updateUserIds = list.stream() + .map(ClueListResponse::getUpdateUser) + .distinct() + .toList(); + List userIds = Stream.of(ownerIds, followerIds, createUserIds, updateUserIds) + .flatMap(Collection::stream) + .distinct() + .toList(); + Map userNameMap = baseService.getUserNameMap(userIds); + + // 获取负责人线索池信息 + Map ownersDefaultPoolMap = cluePoolService.getOwnersDefaultPoolMap(ownerIds, orgId); + List poolIds = ownersDefaultPoolMap.values().stream().map(CluePool::getId).distinct().toList(); + Map recycleRuleMap; + if (CollectionUtils.isEmpty(poolIds)) { + recycleRuleMap = Map.of(); + } else { + LambdaQueryWrapper recycleRuleWrapper = new LambdaQueryWrapper<>(); + recycleRuleWrapper.in(CluePoolRecycleRule::getPoolId, poolIds); + List recycleRules = recycleRuleMapper.selectListByLambda(recycleRuleWrapper); + recycleRuleMap = recycleRules.stream().collect(Collectors.toMap(CluePoolRecycleRule::getPoolId, rule -> rule)); + } + + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + + // 线索池原因 + DictConfigDTO dictConf = dictService.getDictConf(DictModule.CLUE_POOL_RS.name(), orgId); + List dictList = dictConf.getDictList(); + Map dictMap = dictList.stream().collect(Collectors.toMap(Dict::getId, Dict::getName)); + + //获取用户线索池 + Map userPoolMap = getUserCluePool(orgId, userId); + ModuleFormConfigDTO moduleFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CLUE.getKey(), orgId); + List phoneTypeFieldIds = moduleFormConfig.getFields().stream().filter(field -> Strings.CI.equals(field.getType(), FieldType.PHONE.name())).map(BaseField::getId).toList(); + + list.forEach(clueListResponse -> { + boolean hasPermission = getHasPermission(userId, orgId, clueListResponse, userPoolMap); + // 设置回收公海 + CluePool reservePool = ownersDefaultPoolMap.get(clueListResponse.getOwner()); + // 获取自定义字段 + if (hasPermission) { + List clueFields = caseCustomFiledMap.get(clueListResponse.getId()); + clueListResponse.setModuleFields(clueFields); + clueListResponse.setRecyclePoolName(reservePool != null ? reservePool.getName() : null); + clueListResponse.setReasonName(dictMap.get(clueListResponse.getReasonId())); + + } else { + clueListResponse.setModuleFields(new ArrayList<>()); + clueListResponse.setRecyclePoolName(null); + clueListResponse.setReasonName(null); + clueListResponse.setPhone(null); + clueListResponse.setContact(null); + } + // 计算剩余归属天数 + clueListResponse.setReservedDays(cluePoolService.calcReservedDay(reservePool, + reservePool != null ? recycleRuleMap.get(reservePool.getId()) : null, + clueListResponse.getCollectionTime(), clueListResponse.getCreateTime())); + + UserDeptDTO userDeptDTO = userDeptMap.get(clueListResponse.getOwner()); + if (userDeptDTO != null) { + clueListResponse.setDepartmentId(userDeptDTO.getDeptId()); + clueListResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + clueListResponse.setFollowerName(userNameMap.get(clueListResponse.getFollower())); + clueListResponse.setCreateUserName(userNameMap.get(clueListResponse.getCreateUser())); + clueListResponse.setUpdateUserName(userNameMap.get(clueListResponse.getUpdateUser())); + clueListResponse.setOwnerName(userNameMap.get(clueListResponse.getOwner())); + + + clueListResponse.setHasPermission(hasPermission); + clueListResponse.setPoolName(userPoolMap.get(clueListResponse.getPoolId())); + + if (StringUtils.isNotBlank(clueListResponse.getPhone())) { + clueListResponse.setPhone((String) getPhoneFieldValue(clueListResponse.getPhone(), clueListResponse.getPhone().length())); + } + if (CollectionUtils.isNotEmpty(clueListResponse.getModuleFields())) { + clueListResponse.getModuleFields().stream().forEach(moduleField -> { + if (phoneTypeFieldIds.contains(moduleField.getFieldId()) && StringUtils.isNotBlank(moduleField.getFieldValue().toString())) { + moduleField.setFieldValue((getPhoneFieldValue(moduleField.getFieldValue(), moduleField.getFieldValue().toString().length()))); + } + }); + } + }); + + return list; + } + + private boolean getHasPermission(String userId, String orgId, AdvancedCluePoolResponse clueListResponse, Map userPoolMap) { + if (Strings.CI.equals(userId, InternalUser.ADMIN.getValue())) { + return true; + } + if (MapUtils.isEmpty(userPoolMap)) { + return false; + } + boolean hasPool = userPoolMap.containsKey(clueListResponse.getPoolId()); + boolean hasPermission = dataScopeService.hasDataPermission(userId, orgId, new ArrayList<>(), PermissionConstants.CLUE_MANAGEMENT_POOL_READ); + + return hasPool && hasPermission; + } + + public Map> buildOptionMap(String orgId, List list, List buildList) { + // 处理自定义字段选项数据 + ModuleFormConfigDTO customerFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CLUE.getKey(), orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, ClueListResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, moduleFieldValues); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(buildList, + ClueListResponse::getOwner, ClueListResponse::getOwnerName); + optionMap.put(BusinessModuleField.CLUE_OWNER.getBusinessKey(), ownerFieldOption); + + // 意向产品选项 + List productOption = extProductMapper.getOptions(orgId); + optionMap.put(BusinessModuleField.OPPORTUNITY_PRODUCTS.getBusinessKey(), productOption); + return optionMap; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedClueSearchService.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedClueSearchService.java new file mode 100644 index 0000000..f05d820 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedClueSearchService.java @@ -0,0 +1,211 @@ +package cn.cordys.crm.search.service.advanced; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.ModuleKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.UserDeptDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.clue.domain.CluePool; +import cn.cordys.crm.clue.domain.CluePoolRecycleRule; +import cn.cordys.crm.clue.dto.request.CluePageRequest; +import cn.cordys.crm.clue.dto.response.ClueListResponse; +import cn.cordys.crm.clue.mapper.ExtClueMapper; +import cn.cordys.crm.clue.service.ClueFieldService; +import cn.cordys.crm.clue.service.CluePoolService; +import cn.cordys.crm.product.mapper.ExtProductMapper; +import cn.cordys.crm.search.response.advanced.AdvancedClueResponse; +import cn.cordys.crm.search.service.BaseSearchService; +import cn.cordys.crm.system.constants.DictModule; +import cn.cordys.crm.system.constants.SystemResultCode; +import cn.cordys.crm.system.domain.Dict; +import cn.cordys.crm.system.dto.DictConfigDTO; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.DictService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +public class AdvancedClueSearchService extends BaseSearchService { + + @Resource + private ExtClueMapper extClueMapper; + @Resource + private ExtProductMapper extProductMapper; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private DictService dictService; + @Resource + private DataScopeService dataScopeService; + @Resource + private BaseService baseService; + @Resource + private ClueFieldService clueFieldService; + @Resource + private CluePoolService cluePoolService; + @Resource + private BaseMapper recycleRuleMapper; + + + @Override + public PagerWithOption> startSearch(CluePageRequest request, String orgId, String userId) { + + // 查询当前组织下已启用的模块列表 + List enabledModules = getEnabledModules(); + // 检查:如果客户模块未启用,抛出异常 + if (!enabledModules.contains(ModuleKey.CLUE.getKey())) { + throw new GenericException(SystemResultCode.MODULE_ENABLE); + } + request.setPoolId(null); + ConditionFilterUtils.parseCondition(request, FormKey.CLUE.getKey()); + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + // 4. 查询并返回相似线索列表 + List list = extClueMapper.list(request, orgId, userId, null, false); + if (CollectionUtils.isEmpty(list)) { + return PageUtils.setPageInfoWithOption(page, List.of(), Map.of()); + } + List buildList = buildClueList(list, orgId, userId); + + Map> optionMap = buildClueOptionMap(orgId, list, buildList); + + return PageUtils.setPageInfoWithOption(page, buildList, optionMap); + } + + public Map> buildClueOptionMap(String orgId, List list, List buildList) { + // 处理自定义字段选项数据 + ModuleFormConfigDTO customerFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CLUE.getKey(), orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, ClueListResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, moduleFieldValues); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(buildList, + AdvancedClueResponse::getOwner, AdvancedClueResponse::getOwnerName); + optionMap.put(BusinessModuleField.CLUE_OWNER.getBusinessKey(), ownerFieldOption); + + // 意向产品选项 + List productOption = extProductMapper.getOptions(orgId); + optionMap.put(BusinessModuleField.OPPORTUNITY_PRODUCTS.getBusinessKey(), productOption); + return optionMap; + } + + + public List buildClueList(List list, String orgId, String userId) { + if (CollectionUtils.isEmpty(list)) { + return new ArrayList<>(); + } + List clueIds = list.stream().map(ClueListResponse::getId) + .collect(Collectors.toList()); + + Map> caseCustomFiledMap = clueFieldService.getResourceFieldMap(clueIds, true); + + List ownerIds = list.stream() + .map(ClueListResponse::getOwner) + .distinct() + .toList(); + + List followerIds = list.stream() + .map(ClueListResponse::getFollower) + .distinct() + .toList(); + List createUserIds = list.stream() + .map(ClueListResponse::getCreateUser) + .distinct() + .toList(); + List updateUserIds = list.stream() + .map(ClueListResponse::getUpdateUser) + .distinct() + .toList(); + List userIds = Stream.of(ownerIds, followerIds, createUserIds, updateUserIds) + .flatMap(Collection::stream) + .distinct() + .toList(); + Map userNameMap = baseService.getUserNameMap(userIds); + + // 获取负责人线索池信息 + Map ownersDefaultPoolMap = cluePoolService.getOwnersDefaultPoolMap(ownerIds, orgId); + List poolIds = ownersDefaultPoolMap.values().stream().map(CluePool::getId).distinct().toList(); + Map recycleRuleMap; + if (CollectionUtils.isEmpty(poolIds)) { + recycleRuleMap = Map.of(); + } else { + LambdaQueryWrapper recycleRuleWrapper = new LambdaQueryWrapper<>(); + recycleRuleWrapper.in(CluePoolRecycleRule::getPoolId, poolIds); + List recycleRules = recycleRuleMapper.selectListByLambda(recycleRuleWrapper); + recycleRuleMap = recycleRules.stream().collect(Collectors.toMap(CluePoolRecycleRule::getPoolId, rule -> rule)); + } + + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + + // 线索池原因 + DictConfigDTO dictConf = dictService.getDictConf(DictModule.CLUE_POOL_RS.name(), orgId); + List dictList = dictConf.getDictList(); + Map dictMap = dictList.stream().collect(Collectors.toMap(Dict::getId, Dict::getName)); + + List returnList = new ArrayList<>(); + + list.forEach(clueListResponse -> { + // 获取自定义字段 + List clueFields = caseCustomFiledMap.get(clueListResponse.getId()); + clueListResponse.setModuleFields(clueFields); + + // 设置回收公海 + CluePool reservePool = ownersDefaultPoolMap.get(clueListResponse.getOwner()); + clueListResponse.setRecyclePoolName(reservePool != null ? reservePool.getName() : null); + // 计算剩余归属天数 + clueListResponse.setReservedDays(cluePoolService.calcReservedDay(reservePool, + reservePool != null ? recycleRuleMap.get(reservePool.getId()) : null, + clueListResponse.getCollectionTime(), clueListResponse.getCreateTime())); + + UserDeptDTO userDeptDTO = userDeptMap.get(clueListResponse.getOwner()); + if (userDeptDTO != null) { + clueListResponse.setDepartmentId(userDeptDTO.getDeptId()); + clueListResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + clueListResponse.setFollowerName(userNameMap.get(clueListResponse.getFollower())); + clueListResponse.setCreateUserName(userNameMap.get(clueListResponse.getCreateUser())); + clueListResponse.setUpdateUserName(userNameMap.get(clueListResponse.getUpdateUser())); + clueListResponse.setOwnerName(userNameMap.get(clueListResponse.getOwner())); + clueListResponse.setReasonName(dictMap.get(clueListResponse.getReasonId())); + AdvancedClueResponse advancedClueResponse = new AdvancedClueResponse(); + BeanUtils.copyBean(advancedClueResponse, clueListResponse); + boolean hasPermission = dataScopeService.hasDataPermission(userId, orgId, clueListResponse.getOwner(), PermissionConstants.CLUE_MANAGEMENT_READ); + advancedClueResponse.setHasPermission(hasPermission); + if (!hasPermission) { + advancedClueResponse.setPhone(null); + advancedClueResponse.setContact(null); + advancedClueResponse.setModuleFields(new ArrayList<>()); + advancedClueResponse.setReasonName(null); + advancedClueResponse.setRecyclePoolName(null); + } + returnList.add(advancedClueResponse); + }); + return returnList; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedCustomerContactSearchService.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedCustomerContactSearchService.java new file mode 100644 index 0000000..9e57f60 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedCustomerContactSearchService.java @@ -0,0 +1,156 @@ +package cn.cordys.crm.search.service.advanced; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.ModuleKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.UserDeptDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.customer.dto.request.CustomerContactPageRequest; +import cn.cordys.crm.customer.dto.response.CustomerContactListResponse; +import cn.cordys.crm.customer.mapper.ExtCustomerContactMapper; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.crm.customer.service.CustomerContactFieldService; +import cn.cordys.crm.search.response.advanced.AdvancedCustomerContactResponse; +import cn.cordys.crm.search.service.BaseSearchService; +import cn.cordys.crm.system.constants.SystemResultCode; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class AdvancedCustomerContactSearchService extends BaseSearchService { + + @Resource + private ExtCustomerContactMapper extCustomerContactMapper; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private DataScopeService dataScopeService; + @Resource + private BaseService baseService; + @Resource + private CustomerContactFieldService customerContactFieldService; + @Resource + private ExtCustomerMapper extCustomerMapper; + + @Override + public PagerWithOption> startSearch(CustomerContactPageRequest request, String orgId, String userId) { + // 查询当前组织下已启用的模块列表 + List enabledModules = getEnabledModules(); + // 检查:如果客户模块未启用,抛出异常 + if (!enabledModules.contains(ModuleKey.CUSTOMER.getKey())) { + throw new GenericException(SystemResultCode.MODULE_ENABLE); + } + + ConditionFilterUtils.parseCondition(request, FormKey.CONTACT.getKey()); + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extCustomerContactMapper.list(request, userId, orgId, null); + List buildListData = buildCustomerContactData(list, orgId, userId); + Map> optionMap = buildCustomerContactOptionMap(orgId, buildListData); + // 查询重复联系人列表 + return PageUtils.setPageInfoWithOption(page, buildListData, optionMap); + } + + + private Map> buildCustomerContactOptionMap(String orgId, List list) { + ModuleFormConfigDTO customerFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CONTACT.getKey(), orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, AdvancedCustomerContactResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, moduleFieldValues); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(list, + AdvancedCustomerContactResponse::getOwner, AdvancedCustomerContactResponse::getOwnerName); + optionMap.put(BusinessModuleField.CUSTOMER_CONTACT_OWNER.getBusinessKey(), ownerFieldOption); + + // 补充客户选项 + List customerFieldOption = moduleFormService.getBusinessFieldOption(list, + AdvancedCustomerContactResponse::getCustomerId, AdvancedCustomerContactResponse::getCustomerName); + optionMap.put(BusinessModuleField.CUSTOMER_CONTACT_CUSTOMER.getBusinessKey(), customerFieldOption); + return optionMap; + } + + private List buildCustomerContactData(List list, String orgId, String userId) { + if (CollectionUtils.isEmpty(list)) { + return List.of(); + } + + List customerContactIds = list.stream().map(CustomerContactListResponse::getId) + .distinct() + .collect(Collectors.toList()); + + List customerIds = list.stream().map(CustomerContactListResponse::getCustomerId) + .distinct() + .collect(Collectors.toList()); + + Map> caseCustomFiledMap = customerContactFieldService.getResourceFieldMap(customerContactIds, true); + + Map customNameMap = extCustomerMapper.selectOptionByIds(customerIds) + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + + List ownerIds = list.stream() + .map(CustomerContactListResponse::getOwner) + .distinct() + .toList(); + + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + + List returnList = new ArrayList<>(); + list.forEach(customerListResponse -> { + // 获取自定义字段 + List customerFields = caseCustomFiledMap.get(customerListResponse.getId()); + customerListResponse.setModuleFields(customerFields); + + UserDeptDTO userDeptDTO = userDeptMap.get(customerListResponse.getOwner()); + if (userDeptDTO != null) { + customerListResponse.setDepartmentId(userDeptDTO.getDeptId()); + customerListResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + + customerListResponse.setCustomerName(customNameMap.get(customerListResponse.getCustomerId())); + AdvancedCustomerContactResponse advancedCustomerContactResponse = new AdvancedCustomerContactResponse(); + BeanUtils.copyBean(advancedCustomerContactResponse, customerListResponse); + boolean hasPermission = dataScopeService.hasDataPermission(userId, orgId, advancedCustomerContactResponse.getOwner(), PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ); + advancedCustomerContactResponse.setHasPermission(hasPermission); + if (!hasPermission) { + advancedCustomerContactResponse.setModuleFields(new ArrayList<>()); + if (StringUtils.isNotBlank(customerListResponse.getCustomerName())) { + advancedCustomerContactResponse.setCustomerName((String) getInputFieldValue(customerListResponse.getCustomerName(), customerListResponse.getCustomerName().length())); + } + if (StringUtils.isNotBlank(customerListResponse.getName())) { + advancedCustomerContactResponse.setName((String) getInputFieldValue(customerListResponse.getName(), customerListResponse.getName().length())); + } + if (StringUtils.isNotBlank(customerListResponse.getPhone())) { + advancedCustomerContactResponse.setPhone((String) getPhoneFieldValue(customerListResponse.getPhone(), customerListResponse.getPhone().length())); + } + } + returnList.add(advancedCustomerContactResponse); + }); + + return baseService.setCreateUpdateOwnerUserName(returnList); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedCustomerPoolSearchService.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedCustomerPoolSearchService.java new file mode 100644 index 0000000..495ea9c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedCustomerPoolSearchService.java @@ -0,0 +1,234 @@ +package cn.cordys.crm.search.service.advanced; + +import cn.cordys.common.constants.*; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.UserDeptDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.customer.domain.CustomerPool; +import cn.cordys.crm.customer.domain.CustomerPoolRecycleRule; +import cn.cordys.crm.customer.dto.response.CustomerListResponse; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.crm.customer.service.CustomerFieldService; +import cn.cordys.crm.customer.service.CustomerPoolService; +import cn.cordys.crm.search.response.advanced.AdvancedCustomerPoolResponse; +import cn.cordys.crm.search.service.BaseSearchService; +import cn.cordys.crm.system.constants.DictModule; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.constants.SystemResultCode; +import cn.cordys.crm.system.domain.Dict; +import cn.cordys.crm.system.dto.DictConfigDTO; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.DictService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +public class AdvancedCustomerPoolSearchService extends BaseSearchService { + + @Resource + private ExtCustomerMapper extCustomerMapper; + + @Resource + private DataScopeService dataScopeService; + @Resource + private CustomerFieldService customerFieldService; + @Resource + private BaseService baseService; + @Resource + private CustomerPoolService customerPoolService; + @Resource + private BaseMapper customerPoolRecycleRuleMapper; + @Resource + private DictService dictService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ModuleFormService moduleFormService; + + + @Override + public PagerWithOption> startSearch(BasePageRequest request, String orgId, String userId) { + + // 查询当前组织下已启用的模块列表 + List enabledModules = getEnabledModules(); + + // 检查:如果客户模块未启用,抛出异常 + if (!enabledModules.contains(ModuleKey.CUSTOMER.getKey())) { + throw new GenericException(SystemResultCode.MODULE_ENABLE); + } + + ConditionFilterUtils.parseCondition(request, FormKey.CUSTOMER.getKey()); + // 查询重复公海客户列表 + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extCustomerMapper.customerPoolList(request, orgId); + if (CollectionUtils.isEmpty(list)) { + return PageUtils.setPageInfoWithOption(page, List.of(), Map.of()); + } + List buildList = buildListData(list, orgId, userId); + Map> optionMap = buildOptionMap(orgId, list, buildList); + return PageUtils.setPageInfoWithOption(page, buildList, optionMap); + } + + public List buildListData(List list, String orgId, String userId) { + if (CollectionUtils.isEmpty(list)) { + return list; + } + List customerIds = list.stream().map(CustomerListResponse::getId) + .collect(Collectors.toList()); + + Map> caseCustomFiledMap = customerFieldService.getResourceFieldMap(customerIds, true); + + List ownerIds = list.stream() + .map(CustomerListResponse::getOwner) + .distinct() + .toList(); + + List followerIds = list.stream() + .map(CustomerListResponse::getFollower) + .distinct() + .toList(); + List createUserIds = list.stream() + .map(CustomerListResponse::getCreateUser) + .distinct() + .toList(); + List updateUserIds = list.stream() + .map(CustomerListResponse::getUpdateUser) + .distinct() + .toList(); + List userIds = Stream.of(ownerIds, followerIds, createUserIds, updateUserIds) + .flatMap(Collection::stream) + .distinct() + .toList(); + Map userNameMap = baseService.getUserNameMap(userIds); + + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + + // 获取负责人默认公海信息 + Map ownersDefaultPoolMap = customerPoolService.getOwnersDefaultPoolMap(ownerIds, orgId); + List poolIds = ownersDefaultPoolMap.values().stream().map(CustomerPool::getId).distinct().toList(); + Map recycleRuleMap; + if (CollectionUtils.isEmpty(poolIds)) { + recycleRuleMap = Map.of(); + } else { + LambdaQueryWrapper recycleRuleWrapper = new LambdaQueryWrapper<>(); + recycleRuleWrapper.in(CustomerPoolRecycleRule::getPoolId, poolIds); + List recycleRules = customerPoolRecycleRuleMapper.selectListByLambda(recycleRuleWrapper); + recycleRuleMap = recycleRules.stream().collect(Collectors.toMap(CustomerPoolRecycleRule::getPoolId, rule -> rule)); + } + + // 公海原因 + DictConfigDTO dictConf = dictService.getDictConf(DictModule.CUSTOMER_POOL_RS.name(), orgId); + List dictList = dictConf.getDictList(); + Map dictMap = dictList.stream().collect(Collectors.toMap(Dict::getId, Dict::getName)); + + //获取用户公海 + Map userPoolMap = getUserCustomerPool(orgId, userId); + ModuleFormConfigDTO moduleFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CUSTOMER.getKey(), orgId); + List phoneTypeFieldIds = moduleFormConfig.getFields().stream().filter(field -> Strings.CI.equals(field.getType(), FieldType.PHONE.name())).map(BaseField::getId).toList(); + + list.forEach(customerListResponse -> { + boolean hasPermission = getHasPermission(userId, orgId, customerListResponse, userPoolMap); + // 获取自定义字段 + List customerFields = caseCustomFiledMap.get(customerListResponse.getId()); + customerListResponse.setModuleFields(customerFields); + // 设置回收公海 + CustomerPool reservePool = ownersDefaultPoolMap.get(customerListResponse.getOwner()); + customerListResponse.setRecyclePoolName(reservePool != null ? reservePool.getName() : null); + // 计算剩余归属天数 + customerListResponse.setReservedDays(customerPoolService.calcReservedDay(reservePool, + reservePool != null ? recycleRuleMap.get(reservePool.getId()) : null, + customerListResponse.getCollectionTime(), customerListResponse.getCreateTime())); + + UserDeptDTO userDeptDTO = userDeptMap.get(customerListResponse.getOwner()); + if (userDeptDTO != null) { + customerListResponse.setDepartmentId(userDeptDTO.getDeptId()); + customerListResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + + String followerName = baseService.getAndCheckOptionName(userNameMap.get(customerListResponse.getFollower())); + customerListResponse.setFollowerName(followerName); + String createUserName = baseService.getAndCheckOptionName(userNameMap.get(customerListResponse.getCreateUser())); + customerListResponse.setCreateUserName(createUserName); + String updateUserName = baseService.getAndCheckOptionName(userNameMap.get(customerListResponse.getUpdateUser())); + customerListResponse.setUpdateUserName(updateUserName); + customerListResponse.setOwnerName(userNameMap.get(customerListResponse.getOwner())); + String reasonName = baseService.getAndCheckOptionName(dictMap.get(customerListResponse.getReasonId())); + customerListResponse.setReasonName(reasonName); + + customerListResponse.setHasPermission(hasPermission); + if (!hasPermission) { + customerListResponse.setModuleFields(new ArrayList<>()); + customerListResponse.setReasonName(null); + customerListResponse.setRecyclePoolName(null); + } + customerListResponse.setPoolName(userPoolMap.get(customerListResponse.getPoolId())); + + if (CollectionUtils.isNotEmpty(customerListResponse.getModuleFields())) { + customerListResponse.getModuleFields().stream().forEach(moduleField -> { + if (phoneTypeFieldIds.contains(moduleField.getFieldId()) && StringUtils.isNotBlank(moduleField.getFieldValue().toString())) { + moduleField.setFieldValue((getPhoneFieldValue(moduleField.getFieldValue(), moduleField.getFieldValue().toString().length()))); + } + }); + } + }); + + return list; + } + + public Map> buildOptionMap(String orgId, List list, List buildList) { + // 处理自定义字段选项数据 + ModuleFormConfigDTO customerFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CUSTOMER.getKey(), orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, CustomerListResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, moduleFieldValues); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(buildList, + CustomerListResponse::getOwner, CustomerListResponse::getOwnerName); + optionMap.put(BusinessModuleField.CUSTOMER_OWNER.getBusinessKey(), ownerFieldOption); + + return optionMap; + } + + private boolean getHasPermission(String userId, String orgId, AdvancedCustomerPoolResponse customerPoolResponse, Map userPoolMap) { + + if (Strings.CI.equals(userId, InternalUser.ADMIN.getValue())) { + return true; + } + + if (MapUtils.isEmpty(userPoolMap)) { + return false; + } + + boolean hasPool = userPoolMap.containsKey(customerPoolResponse.getPoolId()); + boolean hasPermission = dataScopeService.hasDataPermission(userId, orgId, new ArrayList<>(), PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ); + + return hasPool && hasPermission; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedCustomerSearchService.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedCustomerSearchService.java new file mode 100644 index 0000000..6b0d336 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedCustomerSearchService.java @@ -0,0 +1,378 @@ +package cn.cordys.crm.search.service.advanced; + +import cn.cordys.common.constants.*; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.UserDeptDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.clue.mapper.ExtClueMapper; +import cn.cordys.crm.customer.domain.CustomerPool; +import cn.cordys.crm.customer.domain.CustomerPoolRecycleRule; +import cn.cordys.crm.customer.dto.request.CustomerPageRequest; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.crm.customer.service.CustomerFieldService; +import cn.cordys.crm.customer.service.CustomerPoolService; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityMapper; +import cn.cordys.crm.product.domain.Product; +import cn.cordys.crm.product.mapper.ExtProductMapper; +import cn.cordys.crm.search.response.advanced.AdvancedClueResponse; +import cn.cordys.crm.search.response.advanced.AdvancedCustomerResponse; +import cn.cordys.crm.search.response.advanced.OpportunityRepeatResponse; +import cn.cordys.crm.search.service.BaseSearchService; +import cn.cordys.crm.system.constants.DictModule; +import cn.cordys.crm.system.constants.SystemResultCode; +import cn.cordys.crm.system.domain.Dict; +import cn.cordys.crm.system.dto.DictConfigDTO; +import cn.cordys.crm.system.dto.request.RepeatCustomerDetailPageRequest; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.mapper.ExtUserRoleMapper; +import cn.cordys.crm.system.service.DictService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +public class AdvancedCustomerSearchService extends BaseSearchService { + + @Resource + private ExtUserRoleMapper extUserRoleMapper; + @Resource + private CustomerFieldService customerFieldService; + @Resource + private CustomerPoolService customerPoolService; + @Resource + private BaseMapper customerPoolRecycleRuleMapper; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private ExtCustomerMapper extCustomerMapper; + @Resource + private DictService dictService; + @Resource + private DataScopeService dataScopeService; + @Resource + private BaseService baseService; + @Resource + private ExtOpportunityMapper extOpportunityMapper; + @Resource + private ExtClueMapper extClueMapper; + @Resource + private ExtProductMapper extProductMapper; + + @Override + public PagerWithOption> startSearch(CustomerPageRequest request, String orgId, String userId) { + + // 查询当前组织下已启用的模块列表 + List enabledModules = getEnabledModules(); + // 检查:如果客户模块未启用,抛出异常 + if (!enabledModules.contains(ModuleKey.CUSTOMER.getKey())) { + throw new GenericException(SystemResultCode.MODULE_ENABLE); + } + + boolean isAdmin = Strings.CI.equals(userId, InternalUser.ADMIN.getValue()); + + ConditionFilterUtils.parseCondition(request); + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + // 查询重复客户列表 + List customers = extCustomerMapper.checkRepeatCustomer(request, orgId, userId); + if (CollectionUtils.isEmpty(customers)) { + return PageUtils.setPageInfoWithOption( + page, List.of(), Map.of()); + } + List buildList = buildCustomerList(orgId, userId, isAdmin, customers, enabledModules); + Map> optionMap = buildCustomerOptionMap(orgId, customers, buildList); + return PageUtils.setPageInfoWithOption(page, buildList, optionMap); + } + + private List buildCustomerList(String organizationId, String userId, boolean isAdmin, List customers, List enabledModules) { + // 查询用户权限 + List permissions = extUserRoleMapper.selectPermissionsByUserId(userId); + // 获取商机和线索的重复数量映射 + Map opportunityCounts = getOpportunityCounts(permissions, isAdmin, customers, enabledModules); + Map clueCounts = getClueCounts(permissions, isAdmin, customers, enabledModules); + + // 检查相关模块是否启用 + boolean isClueModuleEnabled = enabledModules.contains(ModuleKey.CLUE.getKey()); + boolean isOpportunityModuleEnabled = enabledModules.contains(ModuleKey.BUSINESS.getKey()); + + List customerIds = customers.stream().map(AdvancedCustomerResponse::getId) + .collect(Collectors.toList()); + + Map> caseCustomFiledMap = customerFieldService.getResourceFieldMap(customerIds, true); + + List ownerIds = customers.stream() + .map(AdvancedCustomerResponse::getOwner) + .distinct() + .toList(); + + List followerIds = customers.stream() + .map(AdvancedCustomerResponse::getFollower) + .distinct() + .toList(); + List createUserIds = customers.stream() + .map(AdvancedCustomerResponse::getCreateUser) + .distinct() + .toList(); + List updateUserIds = customers.stream() + .map(AdvancedCustomerResponse::getUpdateUser) + .distinct() + .toList(); + List userIds = Stream.of(ownerIds, followerIds, createUserIds, updateUserIds) + .flatMap(Collection::stream) + .distinct() + .toList(); + Map userNameMap = baseService.getUserNameMap(userIds); + + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, organizationId); + + // 获取负责人默认公海信息 + Map ownersDefaultPoolMap = customerPoolService.getOwnersDefaultPoolMap(ownerIds, organizationId); + List poolIds = ownersDefaultPoolMap.values().stream().map(CustomerPool::getId).distinct().toList(); + Map recycleRuleMap; + if (CollectionUtils.isEmpty(poolIds)) { + recycleRuleMap = Map.of(); + } else { + LambdaQueryWrapper recycleRuleWrapper = new LambdaQueryWrapper<>(); + recycleRuleWrapper.in(CustomerPoolRecycleRule::getPoolId, poolIds); + List recycleRules = customerPoolRecycleRuleMapper.selectListByLambda(recycleRuleWrapper); + recycleRuleMap = recycleRules.stream().collect(Collectors.toMap(CustomerPoolRecycleRule::getPoolId, rule -> rule)); + } + + // 公海原因 + DictConfigDTO dictConf = dictService.getDictConf(DictModule.CUSTOMER_POOL_RS.name(), organizationId); + List dictList = dictConf.getDictList(); + Map dictMap = dictList.stream().collect(Collectors.toMap(Dict::getId, Dict::getName)); + + // 处理每个客户返回结果 + return customers.stream() + .peek(customer -> { + boolean hasPermission = dataScopeService.hasDataPermission(userId, organizationId, customer.getOwner(), PermissionConstants.CUSTOMER_MANAGEMENT_READ); + // 设置商机数量 + CustomerPool reservePool = ownersDefaultPoolMap.get(customer.getOwner()); + if (!hasPermission) { + customer.setOpportunityCount(null); + // 设置线索数量 + customer.setClueCount(null); + customer.setModuleFields(new ArrayList<>()); + customer.setRecyclePoolName(null); + customer.setReasonName(null); + } else { + customer.setOpportunityCount(parseCount(opportunityCounts.get(customer.getId()))); + customer.setClueCount(parseCount(clueCounts.get(customer.getName()))); + // 获取自定义字段 + List customerFields = caseCustomFiledMap.get(customer.getId()); + customer.setModuleFields(customerFields); + // 设置回收公海 + customer.setRecyclePoolName(reservePool != null ? reservePool.getName() : null); + } + // 计算剩余归属天数 + customer.setReservedDays(customerPoolService.calcReservedDay(reservePool, + reservePool != null ? recycleRuleMap.get(reservePool.getId()) : null, + customer.getCollectionTime(), customer.getCreateTime())); + // 设置模块启用状态 + customer.setOpportunityModuleEnable(isOpportunityModuleEnabled); + customer.setClueModuleEnable(isClueModuleEnabled); + customer.setHasPermission(hasPermission); + UserDeptDTO userDeptDTO = userDeptMap.get(customer.getOwner()); + if (userDeptDTO != null) { + customer.setDepartmentId(userDeptDTO.getDeptId()); + customer.setDepartmentName(userDeptDTO.getDeptName()); + } + + String followerName = baseService.getAndCheckOptionName(userNameMap.get(customer.getFollower())); + customer.setFollowerName(followerName); + String createUserName = baseService.getAndCheckOptionName(userNameMap.get(customer.getCreateUser())); + customer.setCreateUserName(createUserName); + String updateUserName = baseService.getAndCheckOptionName(userNameMap.get(customer.getUpdateUser())); + customer.setUpdateUserName(updateUserName); + customer.setOwnerName(userNameMap.get(customer.getOwner())); + String reasonName = baseService.getAndCheckOptionName(dictMap.get(customer.getReasonId())); + customer.setReasonName(reasonName); + }) + .toList(); + } + + public Map> buildCustomerOptionMap(String orgId, List list, List buildList) { + // 处理自定义字段选项数据 + ModuleFormConfigDTO customerFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CUSTOMER.getKey(), orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, AdvancedCustomerResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, moduleFieldValues); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(buildList, + AdvancedCustomerResponse::getOwner, AdvancedCustomerResponse::getOwnerName); + optionMap.put(BusinessModuleField.CUSTOMER_OWNER.getBusinessKey(), ownerFieldOption); + + return optionMap; + } + + /** + * 获取客户关联的商机数量 + * + * @param permissions 用户权限列表 + * @param isAdmin 是否是管理员 + * @param customers 客户列表 + * @param enabledModules 已启用模块列表 + * + * @return 商机数量映射(客户ID - > 数量) + */ + private Map getOpportunityCounts(List permissions, + boolean isAdmin, + List customers, + List enabledModules) { + // 没有商机读取权限且不是管理员返回空map + if (!permissions.contains(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) && !isAdmin) { + return Collections.emptyMap(); + } + // 商机模块未启用返回空map + if (!enabledModules.contains(ModuleKey.BUSINESS.getKey())) { + return Collections.emptyMap(); + } + + // 获取客户ID列表并查询商机数量 + List customerIds = customers.stream() + .map(AdvancedCustomerResponse::getId) + .toList(); + + return extOpportunityMapper.getRepeatCountMap(customerIds).stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + } + + /** + * 获取客户关联的线索数量 + * + * @param permissions 用户权限列表 + * @param isAdmin 是否是管理员 + * @param customers 客户列表 + * @param enabledModules 已启用模块列表 + * + * @return 线索数量映射(客户名称 - > 数量) + */ + private Map getClueCounts(List permissions, + boolean isAdmin, + List customers, + List enabledModules) { + // 没有线索读取权限且不是管理员返回空map + if (!permissions.contains(PermissionConstants.CLUE_MANAGEMENT_READ) && !permissions.contains(PermissionConstants.CLUE_MANAGEMENT_POOL_READ) && !isAdmin) { + return Collections.emptyMap(); + } + // 线索模块未启用返回空map + if (!enabledModules.contains(ModuleKey.CLUE.getKey())) { + return Collections.emptyMap(); + } + + // 获取客户名称列表并查询线索数量 + List customerNames = customers.stream() + .map(AdvancedCustomerResponse::getName) + .toList(); + + return extClueMapper.getRepeatCountMap(customerNames).stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + } + + /** + * 安全解析数量字符串 + * + * @param count 数量字符串 + * + * @return 解析后的整型数量(空字符串返回0) + */ + private int parseCount(String count) { + return StringUtils.isBlank(count) ? 0 : Integer.parseInt(count); + } + + private void getProductNames(List productOption, List list) { + // 设置产品名称 + Map productMap = productOption.stream().collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + list.forEach(clue -> { + // 设置产品名称 + List productNames = new ArrayList<>(); + List productIds = clue.getProducts(); + productIds.forEach(product -> { + if (productMap.containsKey(product)) { + productNames.add(productMap.get(product)); + } + }); + clue.setProductNameList(productNames); + }); + } + + public List getRepeatClueDetail(RepeatCustomerDetailPageRequest request, + String organizationId) { + List repeatClueList = extClueMapper.getRepeatClueList(request.getName(), organizationId); + if (CollectionUtils.isEmpty(repeatClueList)) { + return repeatClueList; + } + List productOption = extProductMapper.getOptions(organizationId); + // 设置产品名称 + getProductNames(productOption, repeatClueList); + + return repeatClueList; + } + + /** + * 获取重复商机详情列表 + * + * @param request 包含商机ID的查询请求 + * + * @return 重复商机响应列表(包含产品名称信息) + */ + public List getRepeatOpportunityDetail( + RepeatCustomerDetailPageRequest request) { + + // 1. 获取基础重复商机列表 + List responses = extOpportunityMapper.getRepeatList(request.getId()); + + if (CollectionUtils.isEmpty(responses)) { + return responses; + } + + // 2. 获取所有不重复的产品ID列表 + List productIds = responses.stream() + .flatMap(response -> response.getProducts().stream()) + .distinct() + .toList(); + + // 3. 批量获取产品名称映射(优化数据库查询) + Map productNameMap = productIds.isEmpty() + ? Collections.emptyMap() + : extProductMapper.listIdNameByIds(productIds).stream() + .collect(Collectors.toMap( + Product::getId, + Product::getName, + (existing, replacement) -> existing)); // 处理可能的重复键 + + // 4. 填充每个商机的产品名称 + responses.forEach(response -> { + List names = response.getProducts().stream() + .map(productNameMap::get) + .filter(Objects::nonNull) // 过滤掉null值 + .toList(); + response.setProductNames(names); + }); + + return responses; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedOpportunitySearchService.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedOpportunitySearchService.java new file mode 100644 index 0000000..0bf0e61 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedOpportunitySearchService.java @@ -0,0 +1,215 @@ +package cn.cordys.crm.search.service.advanced; + + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.ModuleKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.UserDeptDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.opportunity.constants.OpportunityStageType; +import cn.cordys.crm.opportunity.domain.OpportunityRule; +import cn.cordys.crm.opportunity.dto.request.OpportunityPageRequest; +import cn.cordys.crm.opportunity.dto.response.OpportunityListResponse; +import cn.cordys.crm.opportunity.dto.response.StageConfigResponse; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityMapper; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityStageConfigMapper; +import cn.cordys.crm.opportunity.service.OpportunityFieldService; +import cn.cordys.crm.opportunity.service.OpportunityRuleService; +import cn.cordys.crm.product.mapper.ExtProductMapper; +import cn.cordys.crm.search.response.advanced.AdvancedOpportunityResponse; +import cn.cordys.crm.search.service.BaseSearchService; +import cn.cordys.crm.system.constants.DictModule; +import cn.cordys.crm.system.constants.SystemResultCode; +import cn.cordys.crm.system.domain.Dict; +import cn.cordys.crm.system.dto.DictConfigDTO; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.DictService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +public class AdvancedOpportunitySearchService extends BaseSearchService { + + @Resource + private ExtOpportunityMapper extOpportunityMapper; + @Resource + private DataScopeService dataScopeService; + @Resource + private OpportunityFieldService opportunityFieldService; + @Resource + private BaseService baseService; + @Autowired + private OpportunityRuleService opportunityRuleService; + @Resource + private DictService dictService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private ExtProductMapper extProductMapper; + @Resource + private ExtOpportunityStageConfigMapper extOpportunityStageConfigMapper; + + /** + * 全局搜索商机 + * + * @param request + * @param orgId + * @param userId + * + * @return + */ + @Override + public PagerWithOption> startSearch(OpportunityPageRequest request, String orgId, String userId) { + // 查询当前组织下已启用的模块列表 + List enabledModules = getEnabledModules(); + // 检查:如果有商机读取权限但商机模块未启用,抛出异常 + if (!enabledModules.contains(ModuleKey.BUSINESS.getKey())) { + throw new GenericException(SystemResultCode.MODULE_ENABLE); + } + + ConditionFilterUtils.parseCondition(request, FormKey.OPPORTUNITY.getKey()); + // 查询重复商机列表 + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + List list = extOpportunityMapper.advancedSearchList(request, orgId); + if (CollectionUtils.isEmpty(list)) { + return PageUtils.setPageInfoWithOption(page, List.of(), Map.of()); + } + List buildList = buildListData(list, orgId, userId); + Map> optionMap = buildOptionMap(orgId, list, buildList); + return PageUtils.setPageInfoWithOption(page, buildList, optionMap); + } + + public List buildListData(List list, String orgId, String userId) { + if (CollectionUtils.isEmpty(list)) { + return list; + } + List opportunityIds = list.stream().map(OpportunityListResponse::getId) + .collect(Collectors.toList()); + Map> opportunityFiledMap = opportunityFieldService.getResourceFieldMap(opportunityIds, true); + + List ownerIds = list.stream() + .map(OpportunityListResponse::getOwner) + .distinct() + .toList(); + + List followerIds = list.stream() + .map(OpportunityListResponse::getFollower) + .distinct() + .toList(); + List createUserIds = list.stream() + .map(OpportunityListResponse::getCreateUser) + .distinct() + .toList(); + List updateUserIds = list.stream() + .map(OpportunityListResponse::getUpdateUser) + .distinct() + .toList(); + List userIds = Stream.of(ownerIds, followerIds, createUserIds, updateUserIds) + .flatMap(Collection::stream) + .distinct() + .toList(); + Map userNameMap = baseService.getUserNameMap(userIds); + + List contactIds = list.stream() + .map(OpportunityListResponse::getContactId) + .distinct() + .toList(); + Map contactMap = baseService.getContactMap(contactIds); + + Map ownersDefaultRuleMap = opportunityRuleService.getOwnersDefaultRuleMap(ownerIds, orgId); + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + + List stageConfigList = extOpportunityStageConfigMapper.getStageConfigList(orgId); + Map endConfigMaps = stageConfigList.stream().filter(config -> + Strings.CI.equals(config.getType(), OpportunityStageType.END.name()) + ).collect(Collectors.toMap(StageConfigResponse::getId, Function.identity())); + + // 失败原因 + DictConfigDTO dictConf = dictService.getDictConf(DictModule.OPPORTUNITY_FAIL_RS.name(), orgId); + List dictList = dictConf.getDictList(); + Map dictMap = dictList.stream().collect(Collectors.toMap(Dict::getId, Dict::getName)); + + list.forEach(opportunityListResponse -> { + // 获取自定义字段 + boolean hasPermission = dataScopeService.hasDataPermission(userId, orgId, opportunityListResponse.getOwner(), PermissionConstants.OPPORTUNITY_MANAGEMENT_READ); + List opportunityFields = opportunityFiledMap.get(opportunityListResponse.getId()); + + opportunityListResponse.setReservedDays(endConfigMaps.containsKey(opportunityListResponse.getStage()) ? + null : opportunityRuleService.calcReservedDay(ownersDefaultRuleMap.get(opportunityListResponse.getOwner()), opportunityListResponse.getCreateTime())); + if (!hasPermission) { + opportunityListResponse.setModuleFields(new ArrayList<>()); + opportunityListResponse.setFailureReason(dictMap.get(null)); + opportunityListResponse.setAmount(null); + opportunityListResponse.setContactName(null); + } else { + opportunityListResponse.setModuleFields(opportunityFields); + opportunityListResponse.setFailureReason(dictMap.get(opportunityListResponse.getFailureReason())); + opportunityListResponse.setContactName(contactMap.get(opportunityListResponse.getContactId())); + } + opportunityListResponse.setFollowerName(userNameMap.get(opportunityListResponse.getFollower())); + opportunityListResponse.setCreateUserName(userNameMap.get(opportunityListResponse.getCreateUser())); + opportunityListResponse.setUpdateUserName(userNameMap.get(opportunityListResponse.getUpdateUser())); + opportunityListResponse.setOwnerName(userNameMap.get(opportunityListResponse.getOwner())); + + UserDeptDTO userDeptDTO = userDeptMap.get(opportunityListResponse.getOwner()); + if (userDeptDTO != null) { + opportunityListResponse.setDepartmentId(userDeptDTO.getDeptId()); + opportunityListResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + + opportunityListResponse.setHasPermission(hasPermission); + }); + return baseService.setCreateAndUpdateUserName(list); + } + + + public Map> buildOptionMap(String orgId, List list, List buildList) { + // 处理自定义字段选项数据 + ModuleFormConfigDTO customerFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.OPPORTUNITY.getKey(), orgId); + // 获取所有模块字段的值 + List moduleFieldValues = moduleFormService.getBaseModuleFieldValues(list, OpportunityListResponse::getModuleFields); + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, moduleFieldValues); + + // 补充负责人选项 + List ownerFieldOption = moduleFormService.getBusinessFieldOption(buildList, + OpportunityListResponse::getOwner, OpportunityListResponse::getOwnerName); + optionMap.put(BusinessModuleField.OPPORTUNITY_OWNER.getBusinessKey(), ownerFieldOption); + + // 联系人 + List contactFieldOption = moduleFormService.getBusinessFieldOption(buildList, + OpportunityListResponse::getContactId, OpportunityListResponse::getContactName); + optionMap.put(BusinessModuleField.OPPORTUNITY_CONTACT.getBusinessKey(), contactFieldOption); + + List productOption = extProductMapper.getOptions(orgId); + optionMap.put(BusinessModuleField.OPPORTUNITY_PRODUCTS.getBusinessKey(), productOption); + + return optionMap; + + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedSearchServiceFactory.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedSearchServiceFactory.java new file mode 100644 index 0000000..961169a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/advanced/AdvancedSearchServiceFactory.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.search.service.advanced; + +import cn.cordys.aspectj.constants.GlobalSearchModule; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.crm.search.service.BaseSearchService; + +import java.util.HashMap; + +public class AdvancedSearchServiceFactory { + + private static final HashMap> searchServiceMap = new HashMap<>(); + + static { + searchServiceMap.put(GlobalSearchModule.OPPORTUNITY, CommonBeanFactory.getBean(AdvancedOpportunitySearchService.class)); + searchServiceMap.put(GlobalSearchModule.CUSTOMER_POOL, CommonBeanFactory.getBean(AdvancedCustomerPoolSearchService.class)); + searchServiceMap.put(GlobalSearchModule.CUSTOMER, CommonBeanFactory.getBean(AdvancedCustomerSearchService.class)); + searchServiceMap.put(GlobalSearchModule.CUSTOMER_CONTACT, CommonBeanFactory.getBean(AdvancedCustomerContactSearchService.class)); + searchServiceMap.put(GlobalSearchModule.CLUE, CommonBeanFactory.getBean(AdvancedClueSearchService.class)); + searchServiceMap.put(GlobalSearchModule.CLUE_POOL, CommonBeanFactory.getBean(AdvancedCluePoolSearchService.class)); + } + + @SuppressWarnings("unchecked") + public static BaseSearchService getSearchService(String type) { + return (BaseSearchService) searchServiceMap.get(type); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalCluePoolSearchService.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalCluePoolSearchService.java new file mode 100644 index 0000000..2553a82 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalCluePoolSearchService.java @@ -0,0 +1,218 @@ +package cn.cordys.crm.search.service.global; + +import cn.cordys.common.constants.*; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.clue.domain.Clue; +import cn.cordys.crm.clue.mapper.ExtClueMapper; +import cn.cordys.crm.clue.service.ClueFieldService; +import cn.cordys.crm.search.constants.SearchModuleEnum; +import cn.cordys.crm.search.constants.SearchPhoneEnum; +import cn.cordys.crm.search.domain.SearchFieldMaskConfig; +import cn.cordys.crm.search.domain.UserSearchConfig; +import cn.cordys.crm.search.response.global.GlobalCluePoolResponse; +import cn.cordys.crm.search.service.BaseSearchService; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.constants.SystemResultCode; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class GlobalCluePoolSearchService extends BaseSearchService { + + @Resource + private ExtClueMapper extClueMapper; + @Resource + private ClueFieldService clueFieldService; + @Resource + private DataScopeService dataScopeService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + + + @Override + public Pager> startSearchNoOption(BasePageRequest request, String orgId, String userId) { + //获取查询关键字 + String keyword = request.getKeyword(); + if (StringUtils.isBlank(keyword)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + // 查询当前组织下已启用的模块列表 + List enabledModules = getEnabledModules(); + // 检查:如果有商机读取权限但商机模块未启用,抛出异常 + if (!enabledModules.contains(ModuleKey.CLUE.getKey())) { + throw new GenericException(SystemResultCode.MODULE_ENABLE); + } + //查询当前用户搜索配置 + List userSearchConfigs = getUserSearchConfigs(userId, orgId); + List phoneTypeFieldIds = userSearchConfigs.stream().filter(config -> Strings.CI.equals(config.getType(), FieldType.PHONE.name())).map(UserSearchConfig::getFieldId).toList(); + //记住当前一共有多少字段,避免固定展示列与自由选择列字段重复 + Set fieldIdSet = new HashSet<>(); + //记住选择的内置列,除自定义字段外,用户还会选择一些内置字段 + Map internalKeyMap = new HashMap<>(); + List conditions = new ArrayList<>(); + //用户配置设置: + // 1.用户没配置过,设置默认查询条件; + // 2.用户有配置,使用用户配置的查询条件; + // 3.用户当前模块没配置,直接返回; + if (CollectionUtils.isNotEmpty(userSearchConfigs)) { + List cluePoolSearchConfigs = userSearchConfigs.stream().filter(t -> Strings.CI.equals(t.getModuleType(), SearchModuleEnum.SEARCH_ADVANCED_CLUE_POOL)).toList(); + if (CollectionUtils.isEmpty(cluePoolSearchConfigs)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + for (UserSearchConfig userSearchConfig : cluePoolSearchConfigs) { + //如果和固定展示列名重复不加入fieldIdSet + if (StringUtils.isBlank(userSearchConfig.getBusinessKey())) { + fieldIdSet.add(userSearchConfig.getFieldId()); + } else if (!Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CLUE_NAME.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CLUE_PRODUCTS.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CLUE_CONTACT_PHONE.getBusinessKey())) { + fieldIdSet.add(userSearchConfig.getFieldId()); + } + if (StringUtils.isNotBlank(userSearchConfig.getBusinessKey()) && !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CLUE_NAME.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CLUE_PRODUCTS.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CLUE_CONTACT_PHONE.getBusinessKey())) { + internalKeyMap.put(userSearchConfig.getFieldId(), userSearchConfig.getBusinessKey()); + } + buildOtherFilterCondition(orgId, userSearchConfig, keyword, conditions); + } + } else { + //设置默认查询属性 + FilterCondition nameCondition = getFilterCondition("name", keyword, FilterCondition.CombineConditionOperator.CONTAINS.toString(), FieldType.INPUT.toString()); + conditions.add(nameCondition); + StringUtils.deleteWhitespace(keyword); + List phoneList = new ArrayList<>(); + phoneList.add(keyword); + for (String value : SearchPhoneEnum.VALUES) { + phoneList.add(value + keyword); + } + FilterCondition phoneCondition = getFilterCondition("phone", phoneList, FilterCondition.CombineConditionOperator.IN.toString(), FieldType.DATA_SOURCE.toString()); + conditions.add(phoneCondition); + } + if (CollectionUtils.isEmpty(conditions)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + //构造查询参数 + buildCombineSearch(conditions, request); + // 查询重复商机列表 + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + ConditionFilterUtils.parseCondition(request, FormKey.CLUE.getKey()); + List globalCluePoolResponses = extClueMapper.globalPoolSearchList(request, orgId); + if (CollectionUtils.isEmpty(globalCluePoolResponses)) { + return PageUtils.setPageInfo(page, List.of()); + } + //获取系统设置的脱敏字段 + List searchFieldMaskConfigs = getSearchFieldMaskConfigs(orgId, SearchModuleEnum.SEARCH_ADVANCED_CLUE_POOL); + List buildList = buildListData(globalCluePoolResponses, orgId, userId, searchFieldMaskConfigs, fieldIdSet, internalKeyMap, phoneTypeFieldIds); + return PageUtils.setPageInfo(page, buildList); + } + + + public List buildListData(List list, String orgId, String userId, List searchFieldMaskConfigs, Set fieldIdSet, Map internalKeyMap, List phoneTypeFieldIds) { + List clueIds = list.stream().map(GlobalCluePoolResponse::getId) + .collect(Collectors.toList()); + Map> clueFiledMap = clueFieldService.getResourceFieldMap(clueIds, true); + + Map productNameMap = getProductNameMap(orgId); + + Map userPoolMap = getUserCluePool(orgId, userId); + //处理内置字段的选项 + List clues = new ArrayList<>(); + if (MapUtils.isNotEmpty(internalKeyMap)) { + List columns = new ArrayList<>(); + for (String value : internalKeyMap.values()) { + String result = value.replaceAll("([A-Z])", "_$1").toLowerCase(); + columns.add(result); + } + columns.add("id"); + clues = extClueMapper.searchColumnsByIds(columns, clueIds); + } + Map internalKeyValueMap = clues.stream().collect(Collectors.toMap(Clue::getId, t -> t)); + // 处理自定义字段选项数据 + ModuleFormConfigDTO clueFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CLUE.getKey(), orgId); + Map searchFieldMaskConfigMap = searchFieldMaskConfigs.stream().collect(Collectors.toMap(SearchFieldMaskConfig::getFieldId, t -> t)); + list.forEach(globalClueResponse -> { + // 判断该数据是否有权限 + boolean hasPermission = getHasPermission(userId, orgId, globalClueResponse, userPoolMap); + // 处理自定义字段数据 + if (CollectionUtils.isNotEmpty(fieldIdSet)) { + List returnCluePoolFields = getBaseModuleFieldValues(fieldIdSet, globalClueResponse.getId(), clueFiledMap, clueFormConfig, searchFieldMaskConfigMap, hasPermission); + + globalClueResponse.setModuleFields(returnCluePoolFields); + } + //处理内置字段 + Clue clue = internalKeyValueMap.get(globalClueResponse.getId()); + List baseModuleFieldValues = buildInternalField(internalKeyMap, searchFieldMaskConfigMap, hasPermission, clue, Clue.class); + List moduleFields = globalClueResponse.getModuleFields(); + if (CollectionUtils.isEmpty(moduleFields)) { + globalClueResponse.setModuleFields(baseModuleFieldValues); + } else { + moduleFields.addAll(baseModuleFieldValues); + globalClueResponse.setModuleFields(moduleFields); + } + //固定展示列脱敏设置 + List productNames = getProductNames(globalClueResponse.getProducts(), productNameMap); + globalClueResponse.setProducts(productNames); + + if (CollectionUtils.isNotEmpty(globalClueResponse.getModuleFields())) { + globalClueResponse.getModuleFields().stream().forEach(moduleField -> { + if (phoneTypeFieldIds.contains(moduleField.getFieldId()) && StringUtils.isNotBlank(moduleField.getFieldValue().toString())) { + moduleField.setFieldValue((getPhoneFieldValue(moduleField.getFieldValue(), moduleField.getFieldValue().toString().length()))); + } + }); + } + if (StringUtils.isNotBlank(globalClueResponse.getPhone())) { + globalClueResponse.setPhone((String) getPhoneFieldValue(globalClueResponse.getPhone(), globalClueResponse.getPhone().length())); + } + + searchFieldMaskConfigMap.forEach((fieldId, searchFieldMaskConfig) -> { + if (Strings.CI.equals(searchFieldMaskConfig.getBusinessKey(), "name") && !hasPermission) { + globalClueResponse.setName((String) getInputFieldValue(globalClueResponse.getName(), globalClueResponse.getName().length())); + } + if (Strings.CI.equals(searchFieldMaskConfig.getBusinessKey(), "products") && !hasPermission) { + List maskProductNames = productNames.stream().map(t -> (String) getInputFieldValue(t, t.length())).toList(); + globalClueResponse.setProducts(maskProductNames); + } + }); + + + globalClueResponse.setHasPermission(hasPermission); + }); + return list; + } + + private boolean getHasPermission(String userId, String orgId, GlobalCluePoolResponse clueListResponse, Map userPoolMap) { + if (Strings.CI.equals(userId, InternalUser.ADMIN.getValue())) { + return true; + } + if (MapUtils.isEmpty(userPoolMap)) { + return false; + } + boolean hasPool = userPoolMap.containsKey(clueListResponse.getPoolId()); + boolean hasPermission = dataScopeService.hasDataPermission(userId, orgId, new ArrayList<>(), PermissionConstants.CLUE_MANAGEMENT_POOL_READ); + + return hasPool && hasPermission; + } + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalClueSearchService.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalClueSearchService.java new file mode 100644 index 0000000..f45d33d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalClueSearchService.java @@ -0,0 +1,217 @@ +package cn.cordys.crm.search.service.global; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.ModuleKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.UserDeptDTO; +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.clue.domain.Clue; +import cn.cordys.crm.clue.mapper.ExtClueMapper; +import cn.cordys.crm.clue.service.ClueFieldService; +import cn.cordys.crm.search.constants.SearchModuleEnum; +import cn.cordys.crm.search.constants.SearchPhoneEnum; +import cn.cordys.crm.search.domain.SearchFieldMaskConfig; +import cn.cordys.crm.search.domain.UserSearchConfig; +import cn.cordys.crm.search.response.global.GlobalClueResponse; +import cn.cordys.crm.search.service.BaseSearchService; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.constants.SystemResultCode; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class GlobalClueSearchService extends BaseSearchService { + + @Resource + private ExtClueMapper extClueMapper; + @Resource + private BaseService baseService; + @Resource + private ClueFieldService clueFieldService; + @Resource + private DataScopeService dataScopeService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + + + @Override + public Pager> startSearchNoOption(BasePageRequest request, String orgId, String userId) { + //获取查询关键字 + String keyword = request.getKeyword(); + if (StringUtils.isBlank(keyword)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + // 查询当前组织下已启用的模块列表 + List enabledModules = getEnabledModules(); + // 检查:如果有商机读取权限但商机模块未启用,抛出异常 + if (!enabledModules.contains(ModuleKey.CLUE.getKey())) { + throw new GenericException(SystemResultCode.MODULE_ENABLE); + } + //查询当前用户搜索配置 + List userSearchConfigs = getUserSearchConfigs(userId, orgId); + //记住当前一共有多少字段,避免固定展示列与自由选择列字段重复 + Set fieldIdSet = new HashSet<>(); + //记住选择的内置列,除自定义字段外,用户还会选择一些内置字段 + Map internalKeyMap = new HashMap<>(); + List conditions = new ArrayList<>(); + //用户配置设置: + // 1.用户没配置过,设置默认查询条件; + // 2.用户有配置,使用用户配置的查询条件; + // 3.用户当前模块没配置,直接返回; + if (CollectionUtils.isNotEmpty(userSearchConfigs)) { + List clueSearchConfigs = userSearchConfigs.stream().filter(t -> Strings.CI.equals(t.getModuleType(), SearchModuleEnum.SEARCH_ADVANCED_CLUE)).toList(); + if (CollectionUtils.isEmpty(clueSearchConfigs)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + for (UserSearchConfig userSearchConfig : clueSearchConfigs) { + //如果和固定展示列名重复不加入fieldIdSet + if (StringUtils.isBlank(userSearchConfig.getBusinessKey())) { + fieldIdSet.add(userSearchConfig.getFieldId()); + } else if (!Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CLUE_NAME.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CLUE_OWNER.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CLUE_PRODUCTS.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CLUE_CONTACT_PHONE.getBusinessKey())) { + fieldIdSet.add(userSearchConfig.getFieldId()); + } + if (StringUtils.isNotBlank(userSearchConfig.getBusinessKey()) && !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CLUE_NAME.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CLUE_OWNER.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CLUE_PRODUCTS.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CLUE_CONTACT_PHONE.getBusinessKey())) { + internalKeyMap.put(userSearchConfig.getFieldId(), userSearchConfig.getBusinessKey()); + } + + buildOtherFilterCondition(orgId, userSearchConfig, keyword, conditions); + } + } else { + //设置默认查询属性 + FilterCondition nameCondition = getFilterCondition("name", keyword, FilterCondition.CombineConditionOperator.CONTAINS.toString(), FieldType.INPUT.toString()); + conditions.add(nameCondition); + StringUtils.deleteWhitespace(keyword); + List phoneList = new ArrayList<>(); + phoneList.add(keyword); + for (String value : SearchPhoneEnum.VALUES) { + phoneList.add(value + keyword); + } + FilterCondition phoneCondition = getFilterCondition("phone", phoneList, FilterCondition.CombineConditionOperator.IN.toString(), FieldType.DATA_SOURCE.toString()); + conditions.add(phoneCondition); + } + if (CollectionUtils.isEmpty(conditions)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + //构造查询参数 + buildCombineSearch(conditions, request); + // 查询重复商机列表 + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + ConditionFilterUtils.parseCondition(request, FormKey.CLUE.getKey()); + List globalClueResponses = extClueMapper.globalSearchList(request, orgId); + if (CollectionUtils.isEmpty(globalClueResponses)) { + return PageUtils.setPageInfo(page, List.of()); + } + //获取系统设置的脱敏字段 + List searchFieldMaskConfigs = getSearchFieldMaskConfigs(orgId, SearchModuleEnum.SEARCH_ADVANCED_CLUE); + List buildList = buildListData(globalClueResponses, orgId, userId, searchFieldMaskConfigs, fieldIdSet, internalKeyMap); + return PageUtils.setPageInfo(page, buildList); + } + + + public List buildListData(List list, String orgId, String userId, List searchFieldMaskConfigs, Set fieldIdSet, Map internalKeyMap) { + List clueIds = list.stream().map(GlobalClueResponse::getId) + .collect(Collectors.toList()); + Map> clueFiledMap = clueFieldService.getResourceFieldMap(clueIds, true); + + List ownerIds = list.stream() + .map(GlobalClueResponse::getOwner) + .distinct() + .toList(); + + Map userNameMap = baseService.getUserNameMap(ownerIds); + + Map productNameMap = getProductNameMap(orgId); + + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + //处理内置字段的选项 + List clues = new ArrayList<>(); + if (MapUtils.isNotEmpty(internalKeyMap)) { + List columns = new ArrayList<>(); + for (String value : internalKeyMap.values()) { + String result = value.replaceAll("([A-Z])", "_$1").toLowerCase(); + columns.add(result); + } + columns.add("id"); + clues = extClueMapper.searchColumnsByIds(columns, clueIds); + } + Map internalKeyValueMap = clues.stream().collect(Collectors.toMap(Clue::getId, t -> t)); + // 处理自定义字段选项数据 + ModuleFormConfigDTO clueFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CLUE.getKey(), orgId); + Map searchFieldMaskConfigMap = searchFieldMaskConfigs.stream().collect(Collectors.toMap(SearchFieldMaskConfig::getFieldId, t -> t)); + list.forEach(globalClueResponse -> { + // 判断该数据是否有权限 + boolean hasPermission = dataScopeService.hasDataPermission(userId, orgId, globalClueResponse.getOwner(), PermissionConstants.CLUE_MANAGEMENT_READ); + // 处理自定义字段数据 + if (CollectionUtils.isNotEmpty(fieldIdSet)) { + List returnOpportunityFields = getBaseModuleFieldValues(fieldIdSet, globalClueResponse.getId(), clueFiledMap, clueFormConfig, searchFieldMaskConfigMap, hasPermission); + + globalClueResponse.setModuleFields(returnOpportunityFields); + } + //处理内置字段 + Clue clue = internalKeyValueMap.get(globalClueResponse.getId()); + List baseModuleFieldValues = buildInternalField(internalKeyMap, searchFieldMaskConfigMap, hasPermission, clue, Clue.class); + List moduleFields = globalClueResponse.getModuleFields(); + if (CollectionUtils.isEmpty(moduleFields)) { + globalClueResponse.setModuleFields(baseModuleFieldValues); + } else { + moduleFields.addAll(baseModuleFieldValues); + globalClueResponse.setModuleFields(moduleFields); + } + globalClueResponse.setOwnerName(userNameMap.get(globalClueResponse.getOwner())); + + UserDeptDTO userDeptDTO = userDeptMap.get(globalClueResponse.getOwner()); + if (userDeptDTO != null) { + globalClueResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + //固定展示列脱敏设置 + List productNames = getProductNames(globalClueResponse.getProducts(), productNameMap); + globalClueResponse.setProducts(productNames); + if (!hasPermission) { + searchFieldMaskConfigMap.forEach((fieldId, searchFieldMaskConfig) -> { + if (Strings.CI.equals(searchFieldMaskConfig.getBusinessKey(), "name")) { + globalClueResponse.setName((String) getInputFieldValue(globalClueResponse.getName(), globalClueResponse.getName().length())); + } + if (Strings.CI.equals(searchFieldMaskConfig.getBusinessKey(), "products")) { + List maskProductNames = productNames.stream().map(t -> (String) getInputFieldValue(t, t.length())).toList(); + globalClueResponse.setProducts(maskProductNames); + } + if (Strings.CI.equals(searchFieldMaskConfig.getBusinessKey(), "phone") && StringUtils.isNotBlank(globalClueResponse.getPhone())) { + globalClueResponse.setPhone((String) getPhoneFieldValue(globalClueResponse.getPhone(), globalClueResponse.getPhone().length())); + } + }); + } + globalClueResponse.setHasPermission(hasPermission); + }); + return list; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalCustomerContactSearchService.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalCustomerContactSearchService.java new file mode 100644 index 0000000..cc42e2d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalCustomerContactSearchService.java @@ -0,0 +1,191 @@ +package cn.cordys.crm.search.service.global; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.ModuleKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.UserDeptDTO; +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.customer.mapper.ExtCustomerContactMapper; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.crm.customer.service.CustomerContactFieldService; +import cn.cordys.crm.search.constants.SearchModuleEnum; +import cn.cordys.crm.search.constants.SearchPhoneEnum; +import cn.cordys.crm.search.domain.SearchFieldMaskConfig; +import cn.cordys.crm.search.domain.UserSearchConfig; +import cn.cordys.crm.search.response.global.GlobalCustomerContactResponse; +import cn.cordys.crm.search.service.BaseSearchService; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.constants.SystemResultCode; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class GlobalCustomerContactSearchService extends BaseSearchService { + + @Resource + private ExtCustomerContactMapper extCustomerContactMapper; + @Resource + private CustomerContactFieldService customerContactFieldService; + @Resource + private BaseService baseService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private DataScopeService dataScopeService; + @Resource + private ExtCustomerMapper extCustomerMapper; + + @Override + public Pager> startSearchNoOption(BasePageRequest request, String orgId, String userId) { + //获取查询关键字 + String keyword = request.getKeyword(); + if (StringUtils.isBlank(keyword)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + // 查询当前组织下已启用的模块列表 + List enabledModules = getEnabledModules(); + // 检查:如果有商机读取权限但商机模块未启用,抛出异常 + if (!enabledModules.contains(ModuleKey.CUSTOMER.getKey())) { + throw new GenericException(SystemResultCode.MODULE_ENABLE); + } + //查询当前用户搜索配置 + List userSearchConfigs = getUserSearchConfigs(userId, orgId); + //记住当前一共有多少字段,避免固定展示列与自由选择列字段重复 + Set fieldIdSet = new HashSet<>(); + List conditions = new ArrayList<>(); + //用户配置设置: + // 1.用户没配置过,设置默认查询条件; + // 2.用户有配置,使用用户配置的查询条件; + // 3.用户当前模块没配置,直接返回; + if (CollectionUtils.isNotEmpty(userSearchConfigs)) { + List contactSearchConfigs = userSearchConfigs.stream().filter(t -> Strings.CI.equals(t.getModuleType(), SearchModuleEnum.SEARCH_ADVANCED_CONTACT)).toList(); + if (CollectionUtils.isEmpty(contactSearchConfigs)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + for (UserSearchConfig userSearchConfig : contactSearchConfigs) { + //如果和固定展示列名重复不加入fieldIdSet + if (StringUtils.isBlank(userSearchConfig.getBusinessKey())) { + fieldIdSet.add(userSearchConfig.getFieldId()); + } else if (!Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CUSTOMER_CONTACT_CUSTOMER.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CUSTOMER_CONTACT_PHONE.getBusinessKey())) { + fieldIdSet.add(userSearchConfig.getFieldId()); + } + buildOtherFilterCondition(orgId, userSearchConfig, keyword, conditions); + } + } else { + //设置默认查询属性 + List list = getCustomerIds(keyword, orgId); + if (CollectionUtils.isNotEmpty(list)) { + FilterCondition customerCondition = getFilterCondition("customerId", list, FilterCondition.CombineConditionOperator.IN.toString(), FieldType.DATA_SOURCE.toString()); + conditions.add(customerCondition); + } + StringUtils.deleteWhitespace(keyword); + List phoneList = new ArrayList<>(); + phoneList.add(keyword); + for (String value : SearchPhoneEnum.VALUES) { + phoneList.add(value + keyword); + } + FilterCondition phoneCondition = getFilterCondition("phone", phoneList, FilterCondition.CombineConditionOperator.IN.toString(), FieldType.DATA_SOURCE.toString()); + conditions.add(phoneCondition); + } + + if (CollectionUtils.isEmpty(conditions)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + //构造查询参数 + buildCombineSearch(conditions, request); + //搜索客户 + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + ConditionFilterUtils.parseCondition(request, FormKey.CONTACT.getKey()); + List globalContactResponses = extCustomerContactMapper.globalSearchList(request, orgId); + if (CollectionUtils.isEmpty(globalContactResponses)) { + return PageUtils.setPageInfo(page, List.of()); + } + //获取系统设置的脱敏字段 + List searchFieldMaskConfigs = getSearchFieldMaskConfigs(orgId, SearchModuleEnum.SEARCH_ADVANCED_CONTACT); + List buildList = buildListData(globalContactResponses, orgId, userId, searchFieldMaskConfigs, fieldIdSet); + return PageUtils.setPageInfo(page, buildList); + } + + private List buildListData(List list, String orgId, String userId, List searchFieldMaskConfigs, Set fieldIdSet) { + List customerContactIds = list.stream().map(GlobalCustomerContactResponse::getId) + .collect(Collectors.toList()); + Map> customerFiledMap = customerContactFieldService.getResourceFieldMap(customerContactIds, true); + + List ownerIds = list.stream() + .map(GlobalCustomerContactResponse::getOwner) + .distinct() + .toList(); + + Map userNameMap = baseService.getUserNameMap(ownerIds); + + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + + List customerIds = list.stream().map(GlobalCustomerContactResponse::getCustomerId) + .distinct() + .collect(Collectors.toList()); + Map customNameMap = extCustomerMapper.selectOptionByIds(customerIds) + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + + // 处理自定义字段选项数据 + ModuleFormConfigDTO customerFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CONTACT.getKey(), orgId); + Map searchFieldMaskConfigMap = searchFieldMaskConfigs.stream().collect(Collectors.toMap(SearchFieldMaskConfig::getFieldId, t -> t)); + list.forEach(customerContactResponse -> { + // 判断该数据是否有权限 + boolean hasPermission = dataScopeService.hasDataPermission(userId, orgId, customerContactResponse.getOwner(), PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ); + // 处理自定义字段数据 + if (CollectionUtils.isNotEmpty(fieldIdSet)) { + List returnCustomerFields = getBaseModuleFieldValues(fieldIdSet, customerContactResponse.getId(), customerFiledMap, customerFormConfig, searchFieldMaskConfigMap, hasPermission); + + customerContactResponse.setModuleFields(returnCustomerFields); + } + customerContactResponse.setOwnerName(userNameMap.get(customerContactResponse.getOwner())); + UserDeptDTO userDeptDTO = userDeptMap.get(customerContactResponse.getOwner()); + if (userDeptDTO != null) { + customerContactResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + String customerName = customNameMap.get(customerContactResponse.getCustomerId()); + customerContactResponse.setCustomerName(customerName); + //固定展示列脱敏设置 + if (!hasPermission) { + searchFieldMaskConfigMap.forEach((fieldId, searchFieldMaskConfig) -> { + if (Strings.CI.equals(searchFieldMaskConfig.getBusinessKey(), "customerId")) { + customerContactResponse.setCustomerName((String) getInputFieldValue(customerName, customerName.length())); + } + if (Strings.CI.equals(searchFieldMaskConfig.getBusinessKey(), "name")) { + customerContactResponse.setName((String) getInputFieldValue(customerContactResponse.getName(), customerContactResponse.getName().length())); + } + if (Strings.CI.equals(searchFieldMaskConfig.getBusinessKey(), "phone") && StringUtils.isNotBlank(customerContactResponse.getPhone())) { + customerContactResponse.setPhone((String) getPhoneFieldValue(customerContactResponse.getPhone(), customerContactResponse.getPhone().length())); + } + }); + } + customerContactResponse.setHasPermission(hasPermission); + }); + return list; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalCustomerPoolSearchService.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalCustomerPoolSearchService.java new file mode 100644 index 0000000..2eb2f8a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalCustomerPoolSearchService.java @@ -0,0 +1,169 @@ +package cn.cordys.crm.search.service.global; + +import cn.cordys.common.constants.*; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.crm.customer.service.CustomerFieldService; +import cn.cordys.crm.search.constants.SearchModuleEnum; +import cn.cordys.crm.search.domain.SearchFieldMaskConfig; +import cn.cordys.crm.search.domain.UserSearchConfig; +import cn.cordys.crm.search.response.global.GlobalCustomerPoolResponse; +import cn.cordys.crm.search.service.BaseSearchService; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.constants.SystemResultCode; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class GlobalCustomerPoolSearchService extends BaseSearchService { + + @Resource + private ExtCustomerMapper extCustomerMapper; + @Resource + private CustomerFieldService customerFieldService; + @Resource + private DataScopeService dataScopeService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + + + @Override + public Pager> startSearchNoOption(BasePageRequest request, String orgId, String userId) { + //获取查询关键字 + String keyword = request.getKeyword(); + if (StringUtils.isBlank(keyword)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + // 查询当前组织下已启用的模块列表 + List enabledModules = getEnabledModules(); + // 检查:如果有商机读取权限但商机模块未启用,抛出异常 + if (!enabledModules.contains(ModuleKey.CUSTOMER.getKey())) { + throw new GenericException(SystemResultCode.MODULE_ENABLE); + } + //查询当前用户搜索配置 + List userSearchConfigs = getUserSearchConfigs(userId, orgId); + List phoneTypeFieldIds = userSearchConfigs.stream().filter(config -> Strings.CI.equals(config.getType(), FieldType.PHONE.name())).map(UserSearchConfig::getFieldId).toList(); + //记住当前一共有多少字段,避免固定展示列与自由选择列字段重复 + Set fieldIdSet = new HashSet<>(); + List conditions = new ArrayList<>(); + //用户配置设置: + // 1.用户没配置过,设置默认查询条件; + // 2.用户有配置,使用用户配置的查询条件; + // 3.用户当前模块没配置,直接返回; + if (CollectionUtils.isNotEmpty(userSearchConfigs)) { + List customerPoolSearchConfigs = userSearchConfigs.stream().filter(t -> Strings.CI.equals(t.getModuleType(), SearchModuleEnum.SEARCH_ADVANCED_PUBLIC)).toList(); + if (CollectionUtils.isEmpty(customerPoolSearchConfigs)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + for (UserSearchConfig userSearchConfig : customerPoolSearchConfigs) { + //如果和固定展示列名重复不加入fieldIdSet + if (StringUtils.isBlank(userSearchConfig.getBusinessKey())) { + fieldIdSet.add(userSearchConfig.getFieldId()); + } else if (!Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CUSTOMER_NAME.getBusinessKey())) { + fieldIdSet.add(userSearchConfig.getFieldId()); + } + buildOtherFilterCondition(orgId, userSearchConfig, keyword, conditions); + } + } else { + //设置默认查询属性 + FilterCondition nameCondition = getFilterCondition("name", keyword, FilterCondition.CombineConditionOperator.CONTAINS.toString(), FieldType.INPUT.toString()); + conditions.add(nameCondition); + } + if (CollectionUtils.isEmpty(conditions)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + //构造查询参数 + buildCombineSearch(conditions, request); + // 查询重复商机列表 + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + ConditionFilterUtils.parseCondition(request, FormKey.CUSTOMER.getKey()); + List customerPoolResponses = extCustomerMapper.globalPoolSearchList(request, orgId); + if (CollectionUtils.isEmpty(customerPoolResponses)) { + return PageUtils.setPageInfo(page, List.of()); + } + //获取系统设置的脱敏字段 + List searchFieldMaskConfigs = getSearchFieldMaskConfigs(orgId, SearchModuleEnum.SEARCH_ADVANCED_PUBLIC); + List buildList = buildListData(customerPoolResponses, orgId, userId, searchFieldMaskConfigs, fieldIdSet, phoneTypeFieldIds); + return PageUtils.setPageInfo(page, buildList); + } + + + public List buildListData(List list, String orgId, String userId, List searchFieldMaskConfigs, Set fieldIdSet, List phoneTypeFieldIds) { + List customerIds = list.stream().map(GlobalCustomerPoolResponse::getId) + .collect(Collectors.toList()); + Map> customerFiledMap = customerFieldService.getResourceFieldMap(customerIds, true); + + Map userPoolMap = getUserCustomerPool(orgId, userId); + // 处理自定义字段选项数据 + ModuleFormConfigDTO customerFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CUSTOMER.getKey(), orgId); + Map searchFieldMaskConfigMap = searchFieldMaskConfigs.stream().collect(Collectors.toMap(SearchFieldMaskConfig::getFieldId, t -> t)); + list.forEach(customerPoolResponse -> { + // 判断该数据是否有权限 + boolean hasPermission = getHasPermission(userId, orgId, customerPoolResponse, userPoolMap); + // 处理自定义字段数据 + if (CollectionUtils.isNotEmpty(fieldIdSet)) { + List returnCustomerFields = getBaseModuleFieldValues(fieldIdSet, customerPoolResponse.getId(), customerFiledMap, customerFormConfig, searchFieldMaskConfigMap, hasPermission); + + customerPoolResponse.setModuleFields(returnCustomerFields); + } + //固定展示列脱敏设置 + if (!hasPermission) { + searchFieldMaskConfigMap.forEach((fieldId, searchFieldMaskConfig) -> { + if (Strings.CI.equals(searchFieldMaskConfig.getBusinessKey(), "name")) { + customerPoolResponse.setName((String) getInputFieldValue(customerPoolResponse.getName(), customerPoolResponse.getName().length())); + } + }); + } + + if (CollectionUtils.isNotEmpty(customerPoolResponse.getModuleFields())) { + customerPoolResponse.getModuleFields().stream().forEach(moduleField -> { + if (phoneTypeFieldIds.contains(moduleField.getFieldId()) && StringUtils.isNotBlank(moduleField.getFieldValue().toString())) { + moduleField.setFieldValue((getPhoneFieldValue(moduleField.getFieldValue(), moduleField.getFieldValue().toString().length()))); + } + }); + } + + customerPoolResponse.setHasPermission(hasPermission); + }); + return list; + } + + private boolean getHasPermission(String userId, String orgId, GlobalCustomerPoolResponse customerPoolResponse, Map userPoolMap) { + + if (Strings.CI.equals(userId, InternalUser.ADMIN.getValue())) { + return true; + } + + if (MapUtils.isEmpty(userPoolMap)) { + return false; + } + + boolean hasPool = userPoolMap.containsKey(customerPoolResponse.getPoolId()); + boolean hasPermission = dataScopeService.hasDataPermission(userId, orgId, new ArrayList<>(), PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ); + + return hasPool && hasPermission; + } + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalCustomerSearchService.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalCustomerSearchService.java new file mode 100644 index 0000000..2ea81a8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalCustomerSearchService.java @@ -0,0 +1,161 @@ +package cn.cordys.crm.search.service.global; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.ModuleKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.UserDeptDTO; +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.crm.customer.service.CustomerFieldService; +import cn.cordys.crm.search.constants.SearchModuleEnum; +import cn.cordys.crm.search.domain.SearchFieldMaskConfig; +import cn.cordys.crm.search.domain.UserSearchConfig; +import cn.cordys.crm.search.response.global.GlobalCustomerResponse; +import cn.cordys.crm.search.service.BaseSearchService; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.constants.SystemResultCode; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class GlobalCustomerSearchService extends BaseSearchService { + + @Resource + private ExtCustomerMapper extCustomerMapper; + @Resource + private CustomerFieldService customerFieldService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private DataScopeService dataScopeService; + @Resource + private BaseService baseService; + + @Override + public Pager> startSearchNoOption(BasePageRequest request, String orgId, String userId) { + //获取查询关键字 + String keyword = request.getKeyword(); + if (StringUtils.isBlank(keyword)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + // 查询当前组织下已启用的模块列表 + List enabledModules = getEnabledModules(); + // 检查:如果有商机读取权限但商机模块未启用,抛出异常 + if (!enabledModules.contains(ModuleKey.CUSTOMER.getKey())) { + throw new GenericException(SystemResultCode.MODULE_ENABLE); + } + //查询当前用户搜索配置 + List userSearchConfigs = getUserSearchConfigs(userId, orgId); + //记住当前一共有多少字段,避免固定展示列与自由选择列字段重复 + Set fieldIdSet = new HashSet<>(); + List conditions = new ArrayList<>(); + //用户配置设置: + // 1.用户没配置过,设置默认查询条件; + // 2.用户有配置,使用用户配置的查询条件; + // 3.用户当前模块没配置,直接返回; + if (CollectionUtils.isNotEmpty(userSearchConfigs)) { + List customerSearchConfigs = userSearchConfigs.stream().filter(t -> Strings.CI.equals(t.getModuleType(), SearchModuleEnum.SEARCH_ADVANCED_CUSTOMER)).toList(); + if (CollectionUtils.isEmpty(customerSearchConfigs)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + for (UserSearchConfig userSearchConfig : customerSearchConfigs) { + //如果和固定展示列名重复不加入fieldIdSet + if (StringUtils.isBlank(userSearchConfig.getBusinessKey())) { + fieldIdSet.add(userSearchConfig.getFieldId()); + } else if (!Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CUSTOMER_NAME.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.CUSTOMER_OWNER.getBusinessKey())) { + fieldIdSet.add(userSearchConfig.getFieldId()); + } + buildOtherFilterCondition(orgId, userSearchConfig, keyword, conditions); + } + } else { + //设置默认查询属性 + FilterCondition nameCondition = getFilterCondition("name", keyword, FilterCondition.CombineConditionOperator.CONTAINS.toString(), FieldType.INPUT.toString()); + conditions.add(nameCondition); + } + if (CollectionUtils.isEmpty(conditions)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + //构造查询参数 + buildCombineSearch(conditions, request); + //搜索客户 + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + ConditionFilterUtils.parseCondition(request, FormKey.CUSTOMER.getKey()); + List globalCustomerResponses = extCustomerMapper.globalSearchList(request, orgId); + if (CollectionUtils.isEmpty(globalCustomerResponses)) { + return PageUtils.setPageInfo(page, List.of()); + } + //获取系统设置的脱敏字段 + List searchFieldMaskConfigs = getSearchFieldMaskConfigs(orgId, SearchModuleEnum.SEARCH_ADVANCED_CUSTOMER); + List buildList = buildListData(globalCustomerResponses, orgId, userId, searchFieldMaskConfigs, fieldIdSet); + return PageUtils.setPageInfo(page, buildList); + } + + private List buildListData(List list, String orgId, String userId, List searchFieldMaskConfigs, Set fieldIdSet) { + List customerIds = list.stream().map(GlobalCustomerResponse::getId) + .collect(Collectors.toList()); + Map> customerFiledMap = customerFieldService.getResourceFieldMap(customerIds, true); + + List ownerIds = list.stream() + .map(GlobalCustomerResponse::getOwner) + .distinct() + .toList(); + + Map userNameMap = baseService.getUserNameMap(ownerIds); + + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + + // 处理自定义字段选项数据 + ModuleFormConfigDTO customerFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.CUSTOMER.getKey(), orgId); + Map searchFieldMaskConfigMap = searchFieldMaskConfigs.stream().collect(Collectors.toMap(SearchFieldMaskConfig::getFieldId, t -> t)); + list.forEach(customerResponse -> { + // 判断该数据是否有权限 + boolean hasPermission = dataScopeService.hasDataPermission(userId, orgId, customerResponse.getOwner(), PermissionConstants.CUSTOMER_MANAGEMENT_READ); + // 处理自定义字段数据 + if (CollectionUtils.isNotEmpty(fieldIdSet)) { + List returnCustomerFields = getBaseModuleFieldValues(fieldIdSet, customerResponse.getId(), customerFiledMap, customerFormConfig, searchFieldMaskConfigMap, hasPermission); + + customerResponse.setModuleFields(returnCustomerFields); + } + customerResponse.setOwnerName(userNameMap.get(customerResponse.getOwner())); + UserDeptDTO userDeptDTO = userDeptMap.get(customerResponse.getOwner()); + if (userDeptDTO != null) { + customerResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + //固定展示列脱敏设置 + if (!hasPermission) { + searchFieldMaskConfigMap.forEach((fieldId, searchFieldMaskConfig) -> { + if (Strings.CI.equals(searchFieldMaskConfig.getBusinessKey(), "name")) { + customerResponse.setName((String) getInputFieldValue(customerResponse.getName(), customerResponse.getName().length())); + } + }); + } + customerResponse.setHasPermission(hasPermission); + }); + return list; + + + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalOpportunitySearchService.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalOpportunitySearchService.java new file mode 100644 index 0000000..945e8b8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalOpportunitySearchService.java @@ -0,0 +1,218 @@ +package cn.cordys.crm.search.service.global; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.ModuleKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.UserDeptDTO; +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityMapper; +import cn.cordys.crm.opportunity.service.OpportunityFieldService; +import cn.cordys.crm.search.constants.SearchModuleEnum; +import cn.cordys.crm.search.domain.SearchFieldMaskConfig; +import cn.cordys.crm.search.domain.UserSearchConfig; +import cn.cordys.crm.search.response.global.GlobalOpportunityResponse; +import cn.cordys.crm.search.service.BaseSearchService; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.constants.SystemResultCode; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class GlobalOpportunitySearchService extends BaseSearchService { + + @Resource + private ExtOpportunityMapper extOpportunityMapper; + @Resource + private BaseService baseService; + @Resource + private OpportunityFieldService opportunityFieldService; + @Resource + private DataScopeService dataScopeService; + @Resource + private ModuleFormCacheService moduleFormCacheService; + + + @Override + public Pager> startSearchNoOption(BasePageRequest request, String orgId, String userId) { + //获取查询关键字 + String keyword = request.getKeyword(); + if (StringUtils.isBlank(keyword)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + // 查询当前组织下已启用的模块列表 + List enabledModules = getEnabledModules(); + // 检查:如果有商机读取权限但商机模块未启用,抛出异常 + if (!enabledModules.contains(ModuleKey.BUSINESS.getKey())) { + throw new GenericException(SystemResultCode.MODULE_ENABLE); + } + //查询当前用户搜索配置 + List userSearchConfigs = getUserSearchConfigs(userId, orgId); + //记住选择的内置列,除自定义字段外,用户还会选择一些内置字段 + Map internalKeyMap = new HashMap<>(); + //记住当前一共有多少字段,避免固定展示列与自由选择列字段重复 + Set fieldIdSet = new HashSet<>(); + List conditions = new ArrayList<>(); + //用户配置设置: + // 1.用户没配置过,设置默认查询条件; + // 2.用户有配置,使用用户配置的查询条件; + // 3.用户当前模块没配置,直接返回; + if (CollectionUtils.isNotEmpty(userSearchConfigs)) { + List opportunitySearchConfigs = userSearchConfigs.stream().filter(t -> Strings.CI.equals(t.getModuleType(), SearchModuleEnum.SEARCH_ADVANCED_OPPORTUNITY)).toList(); + if (CollectionUtils.isEmpty(opportunitySearchConfigs)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + for (UserSearchConfig userSearchConfig : opportunitySearchConfigs) { + //如果和固定展示列名重复不加入fieldIdSet + if (StringUtils.isBlank(userSearchConfig.getBusinessKey())) { + fieldIdSet.add(userSearchConfig.getFieldId()); + } else if (!Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.OPPORTUNITY_OWNER.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.OPPORTUNITY_PRODUCTS.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.OPPORTUNITY_CUSTOMER_NAME.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.OPPORTUNITY_NAME.getBusinessKey())) { + fieldIdSet.add(userSearchConfig.getFieldId()); + } + if (StringUtils.isNotBlank(userSearchConfig.getBusinessKey()) && !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.OPPORTUNITY_OWNER.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.OPPORTUNITY_PRODUCTS.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.OPPORTUNITY_CUSTOMER_NAME.getBusinessKey()) && + !Strings.CI.equals(userSearchConfig.getBusinessKey(), BusinessModuleField.OPPORTUNITY_NAME.getBusinessKey())) { + internalKeyMap.put(userSearchConfig.getFieldId(), userSearchConfig.getBusinessKey()); + } + buildOtherFilterCondition(orgId, userSearchConfig, keyword, conditions); + } + } else { + //设置商机默认查询属性 + //查询客户源数据 + List list = getCustomerIds(keyword, orgId); + FilterCondition nameCondition = getFilterCondition("name", keyword, FilterCondition.CombineConditionOperator.CONTAINS.toString(), FieldType.INPUT.toString()); + conditions.add(nameCondition); + if (CollectionUtils.isNotEmpty(list)) { + FilterCondition customerCondition = getFilterCondition("customerId", list, FilterCondition.CombineConditionOperator.IN.toString(), FieldType.DATA_SOURCE.toString()); + conditions.add(customerCondition); + } + } + if (CollectionUtils.isEmpty(conditions)) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, List.of()); + } + //构造查询参数 + buildCombineSearch(conditions, request); + // 查询重复商机列表 + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + ConditionFilterUtils.parseCondition(request, FormKey.OPPORTUNITY.getKey()); + List globalOpportunityResponses = extOpportunityMapper.globalSearchList(request, orgId); + if (CollectionUtils.isEmpty(globalOpportunityResponses)) { + return PageUtils.setPageInfo(page, List.of()); + } + //获取系统设置的脱敏字段 + List searchFieldMaskConfigs = getSearchFieldMaskConfigs(orgId, SearchModuleEnum.SEARCH_ADVANCED_OPPORTUNITY); + List buildList = buildListData(globalOpportunityResponses, orgId, userId, searchFieldMaskConfigs, fieldIdSet, internalKeyMap); + return PageUtils.setPageInfo(page, buildList); + } + + + public List buildListData(List list, String orgId, String userId, List searchFieldMaskConfigs, Set fieldIdSet, Map internalKeyMap) { + List opportunityIds = list.stream().map(GlobalOpportunityResponse::getId) + .collect(Collectors.toList()); + Map> opportunityFiledMap = opportunityFieldService.getResourceFieldMap(opportunityIds, true); + + List ownerIds = list.stream() + .map(GlobalOpportunityResponse::getOwner) + .distinct() + .toList(); + + Map userNameMap = baseService.getUserNameMap(ownerIds); + + Map productNameMap = getProductNameMap(orgId); + + Map userDeptMap = baseService.getUserDeptMapByUserIds(ownerIds, orgId); + //处理内置字段的选项 + List opportunities = new ArrayList<>(); + if (MapUtils.isNotEmpty(internalKeyMap)) { + List columns = new ArrayList<>(); + for (String value : internalKeyMap.values()) { + String result = value.replaceAll("([A-Z])", "_$1").toLowerCase(); + columns.add(result); + } + columns.add("id"); + opportunities = extOpportunityMapper.searchColumnsByIds(columns, opportunityIds); + } + Map internalKeyValueMap = opportunities.stream().collect(Collectors.toMap(Opportunity::getId, t -> t)); + // 处理自定义字段选项数据 + ModuleFormConfigDTO opportunityFormConfig = moduleFormCacheService.getBusinessFormConfig(FormKey.OPPORTUNITY.getKey(), orgId); + Map searchFieldMaskConfigMap = searchFieldMaskConfigs.stream().collect(Collectors.toMap(SearchFieldMaskConfig::getFieldId, t -> t)); + list.forEach(opportunityListResponse -> { + // 判断该数据是否有权限 + boolean hasPermission = dataScopeService.hasDataPermission(userId, orgId, opportunityListResponse.getOwner(), PermissionConstants.OPPORTUNITY_MANAGEMENT_READ); + // 处理自定义字段数据 + if (CollectionUtils.isNotEmpty(fieldIdSet)) { + List returnOpportunityFields = getBaseModuleFieldValues(fieldIdSet, opportunityListResponse.getId(), opportunityFiledMap, opportunityFormConfig, searchFieldMaskConfigMap, hasPermission); + + opportunityListResponse.setModuleFields(returnOpportunityFields); + } + Opportunity opportunity = internalKeyValueMap.get(opportunityListResponse.getId()); + if (opportunity != null && StringUtils.isNotBlank(opportunity.getContactId())) { + opportunity.setContactId(opportunityListResponse.getContactName()); + } + List baseModuleFieldValues = buildInternalField(internalKeyMap, searchFieldMaskConfigMap, hasPermission, opportunity, Opportunity.class); + List moduleFields = opportunityListResponse.getModuleFields(); + if (CollectionUtils.isEmpty(moduleFields)) { + opportunityListResponse.setModuleFields(baseModuleFieldValues); + } else { + moduleFields.addAll(baseModuleFieldValues); + opportunityListResponse.setModuleFields(moduleFields); + } + opportunityListResponse.setOwnerName(userNameMap.get(opportunityListResponse.getOwner())); + + UserDeptDTO userDeptDTO = userDeptMap.get(opportunityListResponse.getOwner()); + if (userDeptDTO != null) { + opportunityListResponse.setDepartmentName(userDeptDTO.getDeptName()); + } + //固定展示列脱敏设置 + List productNames = getProductNames(opportunityListResponse.getProducts(), productNameMap); + opportunityListResponse.setProducts(productNames); + if (!hasPermission) { + searchFieldMaskConfigMap.forEach((fieldId, searchFieldMaskConfig) -> { + if (Strings.CI.equals(searchFieldMaskConfig.getBusinessKey(), "name")) { + opportunityListResponse.setName((String) getInputFieldValue(opportunityListResponse.getName(), opportunityListResponse.getName().length())); + } + if (Strings.CI.equals(searchFieldMaskConfig.getBusinessKey(), "contactId") && StringUtils.isNotBlank(opportunityListResponse.getContactName())) { + opportunityListResponse.setContactName((String) getInputFieldValue(opportunityListResponse.getContactName(), opportunityListResponse.getContactName().length())); + } + if (Strings.CI.equals(searchFieldMaskConfig.getBusinessKey(), "customerId") && StringUtils.isNotBlank(opportunityListResponse.getCustomerName())) { + opportunityListResponse.setCustomerName((String) getInputFieldValue(opportunityListResponse.getCustomerName(), opportunityListResponse.getCustomerName().length())); + } + if (Strings.CI.equals(searchFieldMaskConfig.getBusinessKey(), "products")) { + List maskProductNames = productNames.stream().map(t -> (String) getInputFieldValue(t, t.length())).toList(); + opportunityListResponse.setProducts(maskProductNames); + } + }); + } + opportunityListResponse.setHasPermission(hasPermission); + }); + return list; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalSearchCountService.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalSearchCountService.java new file mode 100644 index 0000000..3eb7409 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalSearchCountService.java @@ -0,0 +1,364 @@ +package cn.cordys.crm.search.service.global; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.ModuleKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.OptionCountDTO; +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.crm.clue.mapper.ExtClueMapper; +import cn.cordys.crm.customer.mapper.ExtCustomerContactMapper; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityMapper; +import cn.cordys.crm.search.constants.SearchModuleEnum; +import cn.cordys.crm.search.constants.SearchPhoneEnum; +import cn.cordys.crm.search.domain.UserSearchConfig; +import cn.cordys.crm.search.service.BaseSearchService; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.mapper.ExtUserRoleMapper; +import cn.cordys.security.SessionUtils; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class GlobalSearchCountService extends BaseSearchService { + @Resource + private ExtUserRoleMapper extUserRoleMapper; + @Resource + private ExtCustomerMapper extCustomerMapper; + @Resource + private ExtCustomerContactMapper extCustomerContactMapper; + @Resource + private ExtClueMapper extClueMapper; + @Resource + private ExtOpportunityMapper extOpportunityMapper; + + + public List startSearchModuleCount(String keyword, String orgId, String userId) { + List list = new ArrayList<>(); + if (StringUtils.isBlank(keyword)) { + return new ArrayList<>(); + } + //处理参数中(+区号)这种会被spring RequestParam参数处理为空格的情况 + if (keyword.contains("(") && keyword.contains(")")) { + // 去掉首尾空格 + keyword = keyword.trim(); + // 匹配形如 ( 86) 或 (86) 这样的区号 + keyword = keyword.replaceAll("\\(\\s*(\\d+)\\)", "(+$1)"); + } + List permissions = extUserRoleMapper.selectPermissionsByUserId(SessionUtils.getUserId()); + BasePageRequest request = new BasePageRequest(); + request.setCurrent(1); + request.setPageSize(1); + + List enabledModules = getEnabledModules(); + //查询当前用户搜索配置 + //用户配置设置: + // 1.用户没配置过,设置默认查询条件; + // 2.用户有配置,使用用户配置的查询条件; + // 3.用户当前模块没配置,直接返回; + List userSearchConfigs = getUserSearchConfigs(userId, orgId); + List conditions; + if (enabledModules.contains(ModuleKey.CUSTOMER.getKey())) { + if (permissions.contains(PermissionConstants.CUSTOMER_MANAGEMENT_READ) || Strings.CI.equals(userId, InternalUser.ADMIN.getValue())) { + //查客户 + conditions = new ArrayList<>(); + long customerCount = getCustomerCount(keyword, orgId, userSearchConfigs, list, conditions, request); + list.add(new OptionCountDTO(SearchModuleEnum.SEARCH_ADVANCED_CUSTOMER, (int) customerCount)); + } + if (permissions.contains(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ) || Strings.CI.equals(userId, InternalUser.ADMIN.getValue())) { + //查客户联系人 + conditions = new ArrayList<>(); + long contactCount = getContactCount(keyword, orgId, userSearchConfigs, list, conditions, request); + list.add(new OptionCountDTO(SearchModuleEnum.SEARCH_ADVANCED_CONTACT, (int) contactCount)); + } + if (permissions.contains(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ) || Strings.CI.equals(userId, InternalUser.ADMIN.getValue())) { + //查公海 + conditions = new ArrayList<>(); + long publicCount = getPublicCount(keyword, orgId, userSearchConfigs, list, conditions, request); + list.add(new OptionCountDTO(SearchModuleEnum.SEARCH_ADVANCED_PUBLIC, (int) publicCount)); + } + } + if (enabledModules.contains(ModuleKey.CLUE.getKey())) { + if (permissions.contains(PermissionConstants.CLUE_MANAGEMENT_READ) || Strings.CI.equals(userId, InternalUser.ADMIN.getValue())) { + conditions = new ArrayList<>(); + long clueCount = getClueCount(keyword, orgId, userSearchConfigs, conditions, request); + list.add(new OptionCountDTO(SearchModuleEnum.SEARCH_ADVANCED_CLUE, (int) clueCount)); + + } + if (permissions.contains(PermissionConstants.CLUE_MANAGEMENT_POOL_READ) || Strings.CI.equals(userId, InternalUser.ADMIN.getValue())) { + conditions = new ArrayList<>(); + long cluePoolCount = getCluePoolCount(keyword, orgId, userSearchConfigs, list, conditions, request); + list.add(new OptionCountDTO(SearchModuleEnum.SEARCH_ADVANCED_CLUE_POOL, (int) cluePoolCount)); + } + } + + if (enabledModules.contains(ModuleKey.BUSINESS.getKey())) { + if (permissions.contains(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) || Strings.CI.equals(userId, InternalUser.ADMIN.getValue())) { + conditions = new ArrayList<>(); + long opportunityCount = getOpportunityCount(keyword, orgId, userSearchConfigs, list, conditions, request); + list.add(new OptionCountDTO(SearchModuleEnum.SEARCH_ADVANCED_OPPORTUNITY, (int) opportunityCount)); + } + } + return list; + } + + /** + * 获取商机数量 + * + * @param keyword 关键字 + * @param orgId 组织ID + * @param userSearchConfigs 用户搜索配置 + * @param list 返回结果集 + * @param conditions 查询条件 + * @param request 分页参数 + * + * @return 数量 + */ + private long getOpportunityCount(String keyword, String orgId, List userSearchConfigs, List list, List conditions, BasePageRequest request) { + List customerIds = getCustomerIds(keyword, orgId); + if (CollectionUtils.isNotEmpty(userSearchConfigs)) { + List opportunitySearchConfigs = userSearchConfigs.stream().filter(t -> Strings.CI.equals(t.getModuleType(), SearchModuleEnum.SEARCH_ADVANCED_OPPORTUNITY)).toList(); + if (CollectionUtils.isEmpty(opportunitySearchConfigs)) { + return 0L; + } else { + for (UserSearchConfig userSearchConfig : opportunitySearchConfigs) { + //如果和固定展示列名重复不加入fieldIdSet + buildOtherFilterCondition(orgId, userSearchConfig, keyword, conditions); + } + } + } else { + //设置商机默认查询属性 + //查询客户源数据 + FilterCondition nameCondition = getFilterCondition("name", keyword, FilterCondition.CombineConditionOperator.CONTAINS.toString(), FieldType.INPUT.toString()); + conditions.add(nameCondition); + if (CollectionUtils.isNotEmpty(list)) { + FilterCondition customerCondition = getFilterCondition("customerId", customerIds, FilterCondition.CombineConditionOperator.IN.toString(), FieldType.DATA_SOURCE.toString()); + conditions.add(customerCondition); + } + } + if (CollectionUtils.isEmpty(conditions)) { + return 0L; + } + //构造查询参数 + buildCombineSearch(conditions, request); + ConditionFilterUtils.parseCondition(request, FormKey.OPPORTUNITY.getKey()); + return extOpportunityMapper.globalSearchListCount(request, orgId); + } + + /** + * 获取线索池数量 + * + * @param keyword 关键字 + * @param orgId 组织ID + * @param userSearchConfigs 用户搜索配置 + * @param list 返回结果集 + * @param conditions 查询条件 + * @param request 分页参数 + * + * @return 数量 + */ + private long getCluePoolCount(String keyword, String orgId, List userSearchConfigs, List list, List conditions, BasePageRequest request) { + if (CollectionUtils.isNotEmpty(userSearchConfigs)) { + List cluePoolSearchConfigs = userSearchConfigs.stream().filter(t -> Strings.CI.equals(t.getModuleType(), SearchModuleEnum.SEARCH_ADVANCED_CLUE_POOL)).toList(); + if (CollectionUtils.isEmpty(cluePoolSearchConfigs)) { + return 0L; + } else { + for (UserSearchConfig userSearchConfig : cluePoolSearchConfigs) { + buildOtherFilterCondition(orgId, userSearchConfig, keyword, conditions); + } + } + } else { + //设置默认查询属性 + FilterCondition nameCondition = getFilterCondition("name", keyword, FilterCondition.CombineConditionOperator.CONTAINS.toString(), FieldType.INPUT.toString()); + conditions.add(nameCondition); + StringUtils.deleteWhitespace(keyword); + List phoneList = new ArrayList<>(); + phoneList.add(keyword); + for (String value : SearchPhoneEnum.VALUES) { + phoneList.add(value + keyword); + } + FilterCondition phoneCondition = getFilterCondition("phone", phoneList, FilterCondition.CombineConditionOperator.IN.toString(), FieldType.DATA_SOURCE.toString()); + conditions.add(phoneCondition); + } + if (CollectionUtils.isEmpty(conditions)) { + return 0L; + } + //构造查询参数 + buildCombineSearch(conditions, request); + ConditionFilterUtils.parseCondition(request, FormKey.CLUE.getKey()); + return extClueMapper.globalPoolSearchListCount(request, orgId); + } + + /** + * 获取线索数量 + * + * @param keyword 关键字 + * @param orgId 组织ID + * @param userSearchConfigs 用户搜索配置 + * @param conditions 查询条件 + * @param request 分页参数 + * + * @return 数量 + */ + private long getClueCount(String keyword, String orgId, List userSearchConfigs, List conditions, BasePageRequest request) { + if (CollectionUtils.isNotEmpty(userSearchConfigs)) { + List clueSearchConfigs = userSearchConfigs.stream().filter(t -> Strings.CI.equals(t.getModuleType(), SearchModuleEnum.SEARCH_ADVANCED_CLUE)).toList(); + if (CollectionUtils.isEmpty(clueSearchConfigs)) { + return 0L; + } else { + for (UserSearchConfig userSearchConfig : clueSearchConfigs) { + buildOtherFilterCondition(orgId, userSearchConfig, keyword, conditions); + } + } + } else { + //设置默认查询属性 + FilterCondition nameCondition = getFilterCondition("name", keyword, FilterCondition.CombineConditionOperator.CONTAINS.toString(), FieldType.INPUT.toString()); + conditions.add(nameCondition); + StringUtils.deleteWhitespace(keyword); + List phoneList = new ArrayList<>(); + phoneList.add(keyword); + for (String value : SearchPhoneEnum.VALUES) { + phoneList.add(value + keyword); + } + FilterCondition phoneCondition = getFilterCondition("phone", phoneList, FilterCondition.CombineConditionOperator.IN.toString(), FieldType.DATA_SOURCE.toString()); + conditions.add(phoneCondition); + } + if (CollectionUtils.isEmpty(conditions)) { + return 0L; + } + //构造查询参数 + buildCombineSearch(conditions, request); + ConditionFilterUtils.parseCondition(request, FormKey.CLUE.getKey()); + return extClueMapper.globalSearchListCount(request, orgId); + } + + /** + * 获取公海数量 + * + * @param keyword 关键字 + * @param orgId 组织ID + * @param userSearchConfigs 用户搜索配置 + * @param list 返回结果集 + * @param conditions 查询条件 + * @param request 分页参数 + * + * @return 数量 + */ + private long getPublicCount(String keyword, String orgId, List userSearchConfigs, List list, List conditions, BasePageRequest request) { + if (CollectionUtils.isNotEmpty(userSearchConfigs)) { + List customerPoolSearchConfigs = userSearchConfigs.stream().filter(t -> Strings.CI.equals(t.getModuleType(), SearchModuleEnum.SEARCH_ADVANCED_PUBLIC)).toList(); + if (CollectionUtils.isEmpty(customerPoolSearchConfigs)) { + return 0L; + } else { + for (UserSearchConfig userSearchConfig : customerPoolSearchConfigs) { + buildOtherFilterCondition(orgId, userSearchConfig, keyword, conditions); + } + } + } else { + //设置默认查询属性 + FilterCondition nameCondition = getFilterCondition("name", keyword, FilterCondition.CombineConditionOperator.CONTAINS.toString(), FieldType.INPUT.toString()); + conditions.add(nameCondition); + } + if (CollectionUtils.isEmpty(conditions)) { + return 0L; + } + //构造查询参数 + buildCombineSearch(conditions, request); + ConditionFilterUtils.parseCondition(request, FormKey.CUSTOMER.getKey()); + return extCustomerMapper.globalPoolSearchListCount(request, orgId); + } + + /** + * 获取客户联系人数量 + * + * @param keyword 关键字 + * @param orgId 组织ID + * @param userSearchConfigs 用户搜索配置 + * @param list 返回结果集 + * @param conditions 查询条件 + * @param request 分页参数 + * + * @return 数量 + */ + private long getContactCount(String keyword, String orgId, List userSearchConfigs, List list, List conditions, BasePageRequest request) { + List customerIds = getCustomerIds(keyword, orgId); + if (CollectionUtils.isNotEmpty(userSearchConfigs)) { + List contactSearchConfigs = userSearchConfigs.stream().filter(t -> Strings.CI.equals(t.getModuleType(), SearchModuleEnum.SEARCH_ADVANCED_CONTACT)).toList(); + if (CollectionUtils.isEmpty(contactSearchConfigs)) { + return 0L; + } else { + for (UserSearchConfig userSearchConfig : contactSearchConfigs) { + buildOtherFilterCondition(orgId, userSearchConfig, keyword, conditions); + } + } + } else { + //设置默认查询属性 + if (CollectionUtils.isNotEmpty(customerIds)) { + FilterCondition customerCondition = getFilterCondition("customerId", list, FilterCondition.CombineConditionOperator.IN.toString(), FieldType.DATA_SOURCE.toString()); + conditions.add(customerCondition); + } + StringUtils.deleteWhitespace(keyword); + List phoneList = new ArrayList<>(); + phoneList.add(keyword); + for (String value : SearchPhoneEnum.VALUES) { + phoneList.add(value + keyword); + } + FilterCondition phoneCondition = getFilterCondition("phone", phoneList, FilterCondition.CombineConditionOperator.IN.toString(), FieldType.DATA_SOURCE.toString()); + conditions.add(phoneCondition); + } + if (CollectionUtils.isEmpty(conditions)) { + return 0L; + } + //构造查询参数 + buildCombineSearch(conditions, request); + ConditionFilterUtils.parseCondition(request, FormKey.CONTACT.getKey()); + return extCustomerContactMapper.globalSearchListCount(request, orgId); + } + + /** + * 获取客户数量 + * + * @param keyword 关键字 + * @param orgId 组织ID + * @param userSearchConfigs 用户搜索配置 + * @param list 返回结果集 + * @param conditions 查询条件 + * @param request 分页参数 + * + * @return 数量 + */ + private long getCustomerCount(String keyword, String orgId, List userSearchConfigs, List list, List conditions, BasePageRequest request) { + if (CollectionUtils.isNotEmpty(userSearchConfigs)) { + List customerSearchConfigs = userSearchConfigs.stream().filter(t -> Strings.CI.equals(t.getModuleType(), SearchModuleEnum.SEARCH_ADVANCED_CUSTOMER)).toList(); + if (CollectionUtils.isEmpty(customerSearchConfigs)) { + return 0L; + } else { + for (UserSearchConfig userSearchConfig : customerSearchConfigs) { + buildOtherFilterCondition(orgId, userSearchConfig, keyword, conditions); + } + } + } else { + //设置默认查询属性 + FilterCondition nameCondition = getFilterCondition("name", keyword, FilterCondition.CombineConditionOperator.CONTAINS.toString(), FieldType.INPUT.toString()); + conditions.add(nameCondition); + } + if (CollectionUtils.isEmpty(conditions)) { + return 0L; + } + //构造查询参数 + buildCombineSearch(conditions, request); + ConditionFilterUtils.parseCondition(request, FormKey.CUSTOMER.getKey()); + return extCustomerMapper.globalSearchListCount(request, orgId); + } + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalSearchServiceFactory.java b/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalSearchServiceFactory.java new file mode 100644 index 0000000..2711d1f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/search/service/global/GlobalSearchServiceFactory.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.search.service.global; + +import cn.cordys.aspectj.constants.GlobalSearchModule; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.crm.search.service.BaseSearchService; + +import java.util.HashMap; + +public class GlobalSearchServiceFactory { + + private static final HashMap> searchServiceMap = new HashMap<>(); + + static { + searchServiceMap.put(GlobalSearchModule.OPPORTUNITY, CommonBeanFactory.getBean(GlobalOpportunitySearchService.class)); + searchServiceMap.put(GlobalSearchModule.CUSTOMER_POOL, CommonBeanFactory.getBean(GlobalCustomerPoolSearchService.class)); + searchServiceMap.put(GlobalSearchModule.CLUE, CommonBeanFactory.getBean(GlobalClueSearchService.class)); + searchServiceMap.put(GlobalSearchModule.CLUE_POOL, CommonBeanFactory.getBean(GlobalCluePoolSearchService.class)); + searchServiceMap.put(GlobalSearchModule.CUSTOMER, CommonBeanFactory.getBean(GlobalCustomerSearchService.class)); + searchServiceMap.put(GlobalSearchModule.CUSTOMER_CONTACT, CommonBeanFactory.getBean(GlobalCustomerContactSearchService.class)); + } + + @SuppressWarnings("unchecked") + public static BaseSearchService getSearchService(String type) { + return (BaseSearchService) searchServiceMap.get(type); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/CirculationFieldValueTypeEnum.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/CirculationFieldValueTypeEnum.java new file mode 100644 index 0000000..e7e1d58 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/CirculationFieldValueTypeEnum.java @@ -0,0 +1,8 @@ +package cn.cordys.crm.system.constants; + +public enum CirculationFieldValueTypeEnum { + // 固定值 + FIXED_VALUE, + // 字段值 + FIELD_VALUE +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/CirculationTypeEnum.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/CirculationTypeEnum.java new file mode 100644 index 0000000..885996f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/CirculationTypeEnum.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.system.constants; + +public enum CirculationTypeEnum { + NORMAL("NORMAL"), + ADVANCED("ADVANCED"); + + private final String name; + + CirculationTypeEnum(String name) { + this.name = name; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/DictModule.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/DictModule.java new file mode 100644 index 0000000..7cb0e71 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/DictModule.java @@ -0,0 +1,30 @@ +package cn.cordys.crm.system.constants; + +public enum DictModule { + + /** + * 商机失败 + */ + OPPORTUNITY_FAIL_RS, + /** + * 公海原因 + */ + CUSTOMER_POOL_RS, + /** + * 线索池原因 + */ + + CLUE_POOL_RS, + /** + * 报价审批 + */ + QUOTATION_APPROVAL, + /** + * 合同审批 + */ + CONTRACT_APPROVAL, + /** + * 发票审批 + */ + INVOICE_APPROVAL, +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/ExportConstants.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/ExportConstants.java new file mode 100644 index 0000000..9867271 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/ExportConstants.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.system.constants; + +public class ExportConstants { + + public enum ExportType { + CUSTOMER, CLUE, OPPORTUNITY, CUSTOMER_POOL, CLUE_POOL, CUSTOMER_CONTACT, CONTRACT, CONTRACT_PAYMENT_PLAN, CONTRACT_INVOICE, PRODUCT_PRICE, BUSINESS_TITLE, CONTRACT_PAYMENT_RECORD, CUSTOM_FORM_DATA + } + + public enum ExportStatus { + PREPARED, STOP, SUCCESS, ERROR + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/FieldSourceType.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/FieldSourceType.java new file mode 100644 index 0000000..4481747 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/FieldSourceType.java @@ -0,0 +1,91 @@ +package cn.cordys.crm.system.constants; + +import lombok.Getter; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +@Getter +public enum FieldSourceType { + + /** + * 客户来源 + */ + CUSTOMER("customer"), + /** + * 线索 + */ + CLUE("clue"), + /** + * 联系人来源 + */ + CONTACT("customer_contact"), + /** + * 商机来源 + */ + OPPORTUNITY("opportunity"), + /** + * 产品来源 + */ + PRODUCT("product"), + /** + * 价格来源 + */ + PRICE("product_price"), + /** + * 合同来源 + */ + CONTRACT("contract"), + /** + * 报价单来源 + */ + QUOTATION("opportunity_quotation"), + /** + * 回款计划 + */ + PAYMENT_PLAN("contract_payment_plan"), + /** + * 工商抬头 + */ + BUSINESS_TITLE("business_title"), + /** + * 回款记录 + */ + CONTRACT_PAYMENT_RECORD("contract_payment_record"), + /** + * 订单 + */ + ORDER("sales_order"), + /** + * 自定义表单 + */ + CUSTOM_FORM("custom_form_data"), + /** + * 发票 + */ + INVOICE("invoice"); + + private final String tableName; + + private static final Set SOURCE_TYPE_NAMES = Arrays.stream(values()) + .map(Enum::name) + .collect(Collectors.toSet()); + + FieldSourceType(String tableName) { + this.tableName = tableName; + } + + /** + * 如果是系统数据源类型,返回对应的枚举值, 否则返回自定义表单类型. + * @param type 数据源类型字符串 + * @return 数据源类型枚举值 + */ + public static FieldSourceType safeValueOf(String type) { + if (type != null && SOURCE_TYPE_NAMES.contains(type)) { + return valueOf(type); + } + return CUSTOM_FORM; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/FieldType.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/FieldType.java new file mode 100644 index 0000000..a5c5555 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/FieldType.java @@ -0,0 +1,108 @@ +package cn.cordys.crm.system.constants; + +import lombok.Getter; + +@Getter +public enum FieldType { + /** + * 单行输入 + */ + INPUT, + /** + * 多行输入 + */ + TEXTAREA, + /** + * 数字 + */ + INPUT_NUMBER, + /** + * 日期时间 + */ + DATE_TIME, + /** + * 单选 + */ + RADIO, + /** + * 多选 + */ + CHECKBOX, + /** + * 单选下拉 + */ + SELECT, + /** + * 多选下拉 + */ + SELECT_MULTIPLE, + /** + * 多值输入 + */ + INPUT_MULTIPLE, + /** + * 成员 + */ + MEMBER, + /** + * 多选成员 + */ + MEMBER_MULTIPLE, + /** + * 部门 + */ + DEPARTMENT, + /** + * 多选部门 + */ + DEPARTMENT_MULTIPLE, + /** + * 分割线 + */ + DIVIDER, + /** + * 图片 + */ + PICTURE, + /** + * 地址 + */ + LOCATION, + /** + * 电话 + */ + PHONE, + /** + * 数据源 + */ + DATA_SOURCE, + /** + * 多选数据源 + */ + DATA_SOURCE_MULTIPLE, + /** + * 流水号 + */ + SERIAL_NUMBER, + /** + * 附件 + */ + ATTACHMENT, + /** + * 链接 + */ + LINK, + /** + * 行业 + */ + INDUSTRY, + /** + * 公式 + */ + FORMULA, + /** + * 子表-产品, 子表-价格 + */ + SUB_PRODUCT, + SUB_PRICE +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/LicenseStatus.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/LicenseStatus.java new file mode 100644 index 0000000..e96efa4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/LicenseStatus.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.system.constants; + +import lombok.Getter; + +/** + * LicenseStatus + */ + +@Getter +public enum LicenseStatus { + VALID("valid"), + INVALID("invalid"), + EXPIRED("expired"), + NOT_FOUND("not_found"); + + private final String name; + + LicenseStatus(String status) { + this.name = status; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/LocationScope.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/LocationScope.java new file mode 100644 index 0000000..f664aaa --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/LocationScope.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.system.constants; + +/** + * @Author: jianxing + * @CreateTime: 2026-03-31 14:00 + */ +public enum LocationScope { + /** + * 全部 + */ + ALL, + /** + * 中国(包含港澳台) + */ + CN +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/LoginType.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/LoginType.java new file mode 100644 index 0000000..4422d27 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/LoginType.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.system.constants; + +import lombok.Getter; + +@Getter +public enum LoginType { + /** + * 电脑端 + */ + WEB("WEB"), + /** + * 手机端 + */ + MOBILE("MOBILE"); + + private final String name; + + LoginType(String name) { + this.name = name; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/MoveTypeEnum.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/MoveTypeEnum.java new file mode 100644 index 0000000..d143193 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/MoveTypeEnum.java @@ -0,0 +1,7 @@ +package cn.cordys.crm.system.constants; + +public enum MoveTypeEnum { + BEFORE, + AFTER, + APPEND +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/NotificationConstants.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/NotificationConstants.java new file mode 100644 index 0000000..eb4a6c2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/NotificationConstants.java @@ -0,0 +1,320 @@ +package cn.cordys.crm.system.constants; + +import io.swagger.v3.oas.annotations.media.Schema; + +public class NotificationConstants { + + public enum Type { + ANNOUNCEMENT_NOTICE, SYSTEM_NOTICE + } + + public enum Status { + READ, UNREAD + } + + public interface SendType { + String MAIL = "MAIL"; + String IN_SITE = "IN_SITE"; + } + + public interface Module { + //客户管理 + @Schema(description = "message.customer") + String CUSTOMER = "CUSTOMER"; + //客户管理 + @Schema(description = "message.approval") + String APPROVAL = "APPROVAL"; + //线索管理 + @Schema(description = "message.clue") + String CLUE = "CLUE"; + //商机管理 + @Schema(description = "message.business") + String OPPORTUNITY = "OPPORTUNITY"; + //公告 + @Schema(description = "message.announcement") + String ANNOUNCEMENT = "ANNOUNCEMENT"; + //合同管理 + @Schema(description = "message.contract") + String CONTRACT = "CONTRACT"; + //订单管理 + @Schema(description = "message.order") + String ORDER = "ORDER"; + //系统 + @Schema(description = "message.system") + String SYSTEM = "SYSTEM"; + + } + + public interface Event { + // -----客户管理---- + //新建客户 + @Schema(description = "message.customer_add") + String CUSTOMER_ADD = "CUSTOMER_ADD"; + @Schema(description = "message.customer_concat_add") + String CUSTOMER_CONCAT_ADD = "CUSTOMER_CONCAT_ADD"; + @Schema(description = "message.customer_collaboration_add") + String CUSTOMER_COLLABORATION_ADD = "CUSTOMER_COLLABORATION_ADD"; + //被转移客户 + @Schema(description = "message.customer_transferred_customer") + String CUSTOMER_TRANSFERRED_CUSTOMER = "CUSTOMER_TRANSFERRED_CUSTOMER"; + //客户自动移入公海 + @Schema(description = "message.customer_automatic_move_high_seas") + String CUSTOMER_AUTOMATIC_MOVE_HIGH_SEAS = "CUSTOMER_AUTOMATIC_MOVE_HIGH_SEAS"; + //客户被动移入公海(到时间) + @Schema(description = "message.customer_moved_high_seas") + String CUSTOMER_MOVED_HIGH_SEAS = "CUSTOMER_MOVED_HIGH_SEAS"; + //客户被删除 + @Schema(description = "message.customer_deleted") + String CUSTOMER_DELETED = "CUSTOMER_DELETED"; + //公海客户被分配 + @Schema(description = "message.high_seas_customer_distributed") + String HIGH_SEAS_CUSTOMER_DISTRIBUTED = "HIGH_SEAS_CUSTOMER_DISTRIBUTED"; + //跟进计划到期 + @Schema(description = "message.customer_follow_up_plan_due") + String CUSTOMER_FOLLOW_UP_PLAN_DUE = "CUSTOMER_FOLLOW_UP_PLAN_DUE"; + //----线索管理---- + //新建线索 + @Schema(description = "message.clue_add") + String CLUE_ADD = "CLUE_ADD"; + //自动移入线索池 + @Schema(description = "message.clue_automatic_move_pool") + String CLUE_AUTOMATIC_MOVE_POOL = "CLUE_AUTOMATIC_MOVE_POOL"; + //被动移入线索池 + @Schema(description = "message.clue_moved_pool") + String CLUE_MOVED_POOL = "CLUE_MOVED_POOL"; + //转为客户 + @Schema(description = "message.clue_convert_customer") + String CLUE_CONVERT_CUSTOMER = "CLUE_CONVERT_CUSTOMER"; + /// 转为商机 + @Schema(description = "message.clue_convert_business") + String CLUE_CONVERT_BUSINESS = "CLUE_CONVERT_BUSINESS"; + //转移线索 + @Schema(description = "message.transfer_clue") + String TRANSFER_CLUE = "TRANSFER_CLUE"; + //删除线索 + @Schema(description = "message.clue_deleted") + String CLUE_DELETED = "CLUE_DELETED"; + //分配线索 + @Schema(description = "message.clue_distributed") + String CLUE_DISTRIBUTED = "CLUE_DISTRIBUTED"; + //跟进计划到期 + @Schema(description = "message.clue_follow_up_plan_due") + String CLUE_FOLLOW_UP_PLAN_DUE = "CLUE_FOLLOW_UP_PLAN_DUE"; + //-------商机管理---- + //新建商机 + @Schema(description = "message.business_add") + String BUSINESS_ADD = "BUSINESS_ADD"; + //商机删除 + @Schema(description = "message.business_deleted") + String BUSINESS_DELETED = "BUSINESS_DELETED"; + //商机转移 + @Schema(description = "message.business_transfer") + String BUSINESS_TRANSFER = "BUSINESS_TRANSFER"; + //跟进计划提醒 + @Schema(description = "message.business_follow_up_plan_due") + String BUSINESS_FOLLOW_UP_PLAN_DUE = "BUSINESS_FOLLOW_UP_PLAN_DUE"; + + @Schema(description = "message.business_quotation_approval") + String BUSINESS_QUOTATION_APPROVAL = "BUSINESS_QUOTATION_APPROVAL"; + + @Schema(description = "message.business_quotation_deleted") + String BUSINESS_QUOTATION_DELETED = "BUSINESS_QUOTATION_DELETED"; + + @Schema(description = "message.business_quotation_expired") + String BUSINESS_QUOTATION_EXPIRED = "BUSINESS_QUOTATION_EXPIRED"; + + @Schema(description = "message.business_quotation_expiring") + String BUSINESS_QUOTATION_EXPIRING = "BUSINESS_QUOTATION_EXPIRING"; + + @Schema(description = "message.contract_archived") + String CONTRACT_ARCHIVED = "CONTRACT_ARCHIVED"; + + @Schema(description = "message.contract_void") + String CONTRACT_VOID = "CONTRACT_VOID"; + + @Schema(description = "message.contract_expired") + String CONTRACT_EXPIRED = "CONTRACT_EXPIRED"; + + @Schema(description = "message.contract_expiring") + String CONTRACT_EXPIRING = "CONTRACT_EXPIRING"; + + @Schema(description = "message.contract_payment_expired") + String CONTRACT_PAYMENT_EXPIRED = "CONTRACT_PAYMENT_EXPIRED"; + + @Schema(description = "message.contract_payment_expiring") + String CONTRACT_PAYMENT_EXPIRING = "CONTRACT_PAYMENT_EXPIRING"; + + //同步组织架构 + @Schema(description = "message.sync_organization_structure") + String SYNC_ORGANIZATION_STRUCTURE = "SYNC_ORGANIZATION_STRUCTURE"; + + @Schema(description = "message.contract_approval") + String CONTRACT_APPROVAL = "CONTRACT_APPROVAL"; + + @Schema(description = "message.order_approval") + String ORDER_APPROVAL = "ORDER_APPROVAL"; + + @Schema(description = "message.invoice_approval") + String INVOICE_APPROVAL = "INVOICE_APPROVAL"; + + @Schema(description = "message.approval_todo") + String APPROVAL_TODO = "APPROVAL_TODO"; + + } + + public interface RelatedUser { + @Schema(description = "message.operator") + String OPERATOR = "OPERATOR"; //操作人 + @Schema(description = "message.owner") + String OWNER = "OWNER"; //负责人 + @Schema(description = "message.create_user") + String CREATE_USER = "CREATE_USER"; //创建人 + } + + + public interface TemplateText { + //请注意!${OPERATOR}新建${customerName}客户给您,请知悉! + @Schema(description = "message.customer_add_text") + String CUSTOMER_ADD_TEXT = "CUSTOMER_ADD_TEXT"; + + @Schema(description = "message.customer_concat_add_text") + String CUSTOMER_CONCAT_ADD_TEXT = "CUSTOMER_CONCAT_ADD_TEXT"; + + @Schema(description = "message.customer_collaboration_add_text") + String CUSTOMER_COLLABORATION_ADD_TEXT = "CUSTOMER_COLLABORATION_ADD_TEXT"; + + // 请注意!${OPERATOR}将${customerName}客户转移给您,请知悉! + @Schema(description = "message.customer_transferred_customer_text") + String CUSTOMER_TRANSFERRED_CUSTOMER_TEXT = "CUSTOMER_TRANSFERRED_CUSTOMER_TEXT"; + + //请注意!根据系统规则,您负责的${customerName}客户已被移入公海,请知悉! + @Schema(description = "message.customer_automatic_move_high_seas_text") + String CUSTOMER_AUTOMATIC_MOVE_HIGH_SEAS_TEXT = "CUSTOMER_AUTOMATIC_MOVE_HIGH_SEAS_TEXT"; + + //请注意!${OPERATOR}已将您负责的${customerName}移入公海,请知悉! + @Schema(description = "message.customer_moved_high_seas_text") + String CUSTOMER_MOVED_HIGH_SEAS_TEXT = "CUSTOMER_MOVED_HIGH_SEAS_TEXT"; + + //请注意!您负责的${customerName}已被${OPERATOR}删除! + @Schema(description = "message.customer_deleted_text") + String CUSTOMER_DELETED_TEXT = "CUSTOMER_DELETED_TEXT"; + + //请注意!${customerName}已由公海分配给您,请及时跟进处理! + @Schema(description = "message.high_seas_customer_distributed_text") + String HIGH_SEAS_CUSTOMER_DISTRIBUTED_TEXT = "HIGH_SEAS_CUSTOMER_DISTRIBUTED_TEXT"; + + //请注意!您创建的${customerName}跟进计划,已到预定时间,请及时跟进! + @Schema(description = "message.customer_follow_up_plan_due_text") + String CUSTOMER_FOLLOW_UP_PLAN_DUE_TEXT = "CUSTOMER_FOLLOW_UP_PLAN_DUE_TEXT"; + + //请注意!${OPERATOR}新建${customerName}线索给您,请知悉! + @Schema(description = "message.clue_add_text") + String CLUE_ADD_TEXT = "CLUE_ADD_TEXT"; + + //请注意!根据系统规则,您负责的${clueName}的销售线索,已被移入线索池! + @Schema(description = "message.clue_automatic_move_pool_text") + String CLUE_AUTOMATIC_MOVE_POOL_TEXT = "CLUE_AUTOMATIC_MOVE_POOL_TEXT"; + + //请注意!${OPERATOR}已将您负责的${clueName}线索已被移入线索池,请知悉! + @Schema(description = "message.clue_moved_pool_text") + String CLUE_MOVED_POOL_TEXT = "CLUE_MOVED_POOL_TEXT"; + + //请注意!您负责的${clueName}的销售线索,已成功转为客户!请知悉! + @Schema(description = "message.clue_convert_customer_text") + String CLUE_CONVERT_CUSTOMER_TEXT = "CLUE_CONVERT_CUSTOMER_TEXT"; + + //请注意!您负责的${clueName}的销售线索,已成功转为商机!请知悉! + @Schema(description = "message.clue_convert_business_text") + String CLUE_CONVERT_BUSINESS_TEXT = "CLUE_CONVERT_BUSINESS_TEXT"; + + //请注意!${OPERATOR}将${clueName}线索转移给您,请知悉! + @Schema(description = "message.transfer_clue_text") + String TRANSFER_CLUE_TEXT = "TRANSFER_CLUE_TEXT"; + + //请注意!您负责的${clueName}线索,已被${OPERATOR}删除! + @Schema(description = "message.clue_deleted_text") + String CLUE_DELETED_TEXT = "CLUE_DELETED_TEXT"; + + //请注意!${clueName}由线索,已由线索池分配给您,请及时跟进处理! + @Schema(description = "message.clue_distributed_text") + String CLUE_DISTRIBUTED_TEXT = "CLUE_DISTRIBUTED_TEXT"; + + //请注意!您创建的${clueName}线索跟进计划,已到预定时间,请及时跟进! + @Schema(description = "message.clue_follow_up_plan_due_text") + String CLUE_FOLLOW_UP_PLAN_DUE_TEXT = "CLUE_FOLLOW_UP_PLAN_DUE_TEXT"; + + //请注意!${OPERATOR}新建${customerName}客户给您,请知悉! + @Schema(description = "message.business_add_text") + String BUSINESS_ADD_TEXT = "BUSINESS_ADD_TEXT"; + + //请注意!您负责的${businessName}商机,已被${OPERATOR}删除! + @Schema(description = "message.business_deleted_text") + String BUSINESS_DELETED_TEXT = "BUSINESS_DELETED_TEXT"; + + //请注意!${OPERATOR}将${businessName}商机转移给您,请知悉! + @Schema(description = "message.business_transfer_text") + String BUSINESS_TRANSFER_TEXT = "BUSINESS_TRANSFER_TEXT"; + + //请注意!您创建的${businessName}商机跟进计划,已到预定时间,请及时跟进! + @Schema(description = "message.business_follow_up_plan_due_text") + String BUSINESS_FOLLOW_UP_PLAN_DUE_TEXT = "BUSINESS_FOLLOW_UP_PLAN_DUE_TEXT"; + + //${OPERATOR}审批了${name}报价单,审批结果为${state} + @Schema(description = "message.business_quotation_approval_text") + String BUSINESS_QUOTATION_APPROVAL_TEXT = "BUSINESS_QUOTATION_APPROVAL_TEXT"; + + //${OPERATOR}删除了${name}报价单 + @Schema(description = "message.business_quotation_deleted_text") + String BUSINESS_QUOTATION_DELETED_TEXT = "BUSINESS_QUOTATION_DELETED_TEXT"; + + //您负责的${customerName}报价单还有${expireDays}天到期; + @Schema(description = "message.business_quotation_expiring_text") + String BUSINESS_QUOTATION_EXPIRING_TEXT = "BUSINESS_QUOTATION_EXPIRING_TEXT"; + + //您负责的${customerName}的报价单已经到期; + @Schema(description = "message.business_quotation_expired_text") + String BUSINESS_QUOTATION_EXPIRED_TEXT = "BUSINESS_QUOTATION_EXPIRED_TEXT"; + + //您负责的${customerName}合同已被归档; + @Schema(description = "message.contract_archived_text") + String CONTRACT_ARCHIVED_TEXT = "CONTRACT_ARCHIVED_TEXT"; + //您负责的${customerName}合同已被作废; + @Schema(description = "message.contract_void_text") + String CONTRACT_VOID_TEXT = "CONTRACT_VOID_TEXT"; + //您负责的${customerName}合同还有${expireDays}天到期; + @Schema(description = "message.contract_expiring_text") + String CONTRACT_EXPIRING_TEXT = "CONTRACT_EXPIRING_TEXT"; + //您负责的${customerName}合同已经到期; + @Schema(description = "message.contract_expired_text") + String CONTRACT_EXPIRED_TEXT = "CONTRACT_EXPIRED_TEXT"; + + //您负责的${customerName}合同的回款计划还有${expireDays}天到期; + @Schema(description = "message.contract_payment_expiring_text") + String CONTRACT_PAYMENT_EXPIRING_TEXT = "CONTRACT_PAYMENT_EXPIRING_TEXT"; + //您负责的${customerName}合同的回款计划已经到期; + @Schema(description = "message.contract_payment_expired_text") + String CONTRACT_PAYMENT_EXPIRED_TEXT = "CONTRACT_PAYMENT_EXPIRED_TEXT"; + + //同步组织架构完成 + @Schema(description = "message.sync_organization_structure_text") + String SYNC_ORGANIZATION_STRUCTURE_TEXT = "SYNC_ORGANIZATION_STRUCTURE_TEXT"; + + @Schema(description = "message.contract_approval_text") + String CONTRACT_APPROVAL_TEXT = "CONTRACT_APPROVAL_TEXT"; + + @Schema(description = "message.order_approval_text") + String ORDER_APPROVAL_TEXT = "ORDER_APPROVAL_TEXT"; + + @Schema(description = "message.invoice_approval_text") + String INVOICE_APPROVAL_TEXT = "INVOICE_APPROVAL_TEXT"; + + @Schema(description = "message.approval_todo_text") + String APPROVAL_TODO_TEXT = "APPROVAL_TODO_TEXT"; + } + + + public interface SensitiveField { + String id = "id"; + String organizationId = "organizationId"; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/OrganizationConfigConstants.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/OrganizationConfigConstants.java new file mode 100644 index 0000000..6c00ebb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/OrganizationConfigConstants.java @@ -0,0 +1,8 @@ +package cn.cordys.crm.system.constants; + +public class OrganizationConfigConstants { + + public enum ConfigType { + EMAIL, THIRD, AUTH + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/RecycleConditionColumnKey.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/RecycleConditionColumnKey.java new file mode 100644 index 0000000..c1c2056 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/RecycleConditionColumnKey.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.system.constants; + +import org.apache.commons.lang3.Strings; + +public class RecycleConditionColumnKey { + + public static final String STORAGE_TIME = "storageTime"; + public static final String CREATE_TIME = "createTime"; + public static final String OPPORTUNITY_STAGE = "opportunityStage"; + + public static boolean matchReserved(String key) { + return Strings.CS.equalsAny(key, STORAGE_TIME, CREATE_TIME); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/RecycleConditionOperator.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/RecycleConditionOperator.java new file mode 100644 index 0000000..20cb233 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/RecycleConditionOperator.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.system.constants; + +public enum RecycleConditionOperator { + + /** + * 固定时间 + */ + FIXED, + /** + * 动态时间 + */ + DYNAMICS, + /** + * 属于 + */ + IN, +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/RecycleConditionScopeKey.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/RecycleConditionScopeKey.java new file mode 100644 index 0000000..d4fa1d8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/RecycleConditionScopeKey.java @@ -0,0 +1,7 @@ +package cn.cordys.crm.system.constants; + +public class RecycleConditionScopeKey { + + public static final String PICKED = "Picked"; + public static final String CREATED = "Created"; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/ScopeKey.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/ScopeKey.java new file mode 100644 index 0000000..ebd7453 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/ScopeKey.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.system.constants; + +public enum ScopeKey { + + /** + * 用户 + */ + USER, + /** + * 角色 + */ + ROLE, + /** + * 部门 + */ + DEPARTMENT, +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/SheetKey.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/SheetKey.java new file mode 100644 index 0000000..9823bd3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/SheetKey.java @@ -0,0 +1,7 @@ +package cn.cordys.crm.system.constants; + +public class SheetKey { + + public static final String COMMENT = "sheet.comment"; + public static final String DATA = "sheet.data"; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/SystemResultCode.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/SystemResultCode.java new file mode 100644 index 0000000..bb42df6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/SystemResultCode.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.system.constants; + +import cn.cordys.common.exception.IResultCode; + +/** + * @author jianxing + */ +public enum SystemResultCode implements IResultCode { + + ROLE_EXIST(101001, "role.exist"), + INTERNAL_ROLE_PERMISSION(101002, "internal.role.permission.error"), + MODULE_ENABLE(101003, "module.has.closed"); + + + private final int code; + private final String message; + + SystemResultCode(int code, String message) { + this.code = code; + this.message = message; + } + + @Override + public int getCode() { + return code; + } + + @Override + public String getMessage() { + return getTranslationMessage(this.message); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/UserViewConditionValueType.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/UserViewConditionValueType.java new file mode 100644 index 0000000..af929f6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/UserViewConditionValueType.java @@ -0,0 +1,5 @@ +package cn.cordys.crm.system.constants; + +public enum UserViewConditionValueType { + ARRAY, STRING, INT, FLOAT, BOOLEAN +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/constants/UserViewResourceType.java b/backend/crm/src/main/java/cn/cordys/crm/system/constants/UserViewResourceType.java new file mode 100644 index 0000000..377e3ad --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/constants/UserViewResourceType.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.system.constants; + +public enum UserViewResourceType { + OPPORTUNITY, + OPPORTUNITY_QUOTATION, + CUSTOMER, + CUSTOMER_CONTACT, + CLUE, + CUSTOMER_POOL, + CLUE_POOL, + FOLLOW_PLAN, + FOLLOW_RECORD, + CONTRACT, + CONTRACT_INVOICE, + CONTRACT_PAYMENT_PLAN, + CONTRACT_PAYMENT_RECORD, + ORDER +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/consumer/ExportConsumer.java b/backend/crm/src/main/java/cn/cordys/crm/system/consumer/ExportConsumer.java new file mode 100644 index 0000000..022b0a2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/consumer/ExportConsumer.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.system.consumer; + +import cn.cordys.common.constants.TopicConstants; +import cn.cordys.common.redis.TopicConsumer; +import cn.cordys.registry.ExportThreadRegistry; +import org.springframework.stereotype.Component; + +@Component +class ExportConsumer implements TopicConsumer { + + @Override + public String getChannel() { + return TopicConstants.DOWNLOAD_TOPIC; + } + + @Override + public void consume(String message) { + ExportThreadRegistry.stop(message); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/consumer/SSEConsumer.java b/backend/crm/src/main/java/cn/cordys/crm/system/consumer/SSEConsumer.java new file mode 100644 index 0000000..7d99d8b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/consumer/SSEConsumer.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.system.consumer; + +import cn.cordys.common.constants.TopicConstants; +import cn.cordys.common.redis.TopicConsumer; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.notice.dto.NoticeRedisMessage; +import cn.cordys.crm.system.notice.sse.SseService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +@Component +public class SSEConsumer implements TopicConsumer { + + @Resource + private SseService sseService; + + @Override + public String getChannel() { + return TopicConstants.SSE_TOPIC; + } + + @Override + public void consume(String message) { + NoticeRedisMessage noticeRedisMessage = JSON.parseObject(message, NoticeRedisMessage.class); + sseService.broadcastPeriodically(noticeRedisMessage.getMessage(), noticeRedisMessage.getNoticeType()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/AnnouncementController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/AnnouncementController.java new file mode 100644 index 0000000..dfb5aa7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/AnnouncementController.java @@ -0,0 +1,94 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.crm.system.dto.request.AnnouncementPageRequest; +import cn.cordys.crm.system.dto.request.AnnouncementRequest; +import cn.cordys.crm.system.dto.response.AnnouncementDTO; +import cn.cordys.crm.system.service.AnnouncementService; +import cn.cordys.security.SessionUtils; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 公告控制器,负责处理公告编辑操作。 + *

+ * 该控制器包含公告列表,查看,增加,编辑和删除操作。 + *

+ */ +@RestController +@RequestMapping +@Tag(name = "公告") +public class AnnouncementController { + + @Resource + private AnnouncementService announcementService; + + + @PostMapping("/announcement/page") + @Operation(summary = "公告列表分页查询") + @RequiresPermissions(PermissionConstants.SYSTEM_NOTICE_READ) + public Pager> getAnnouncementPage(@Validated @RequestBody AnnouncementPageRequest request) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, announcementService.page(request)); + } + + + /** + * 获取公告。 + * + * @param announcementId 公告对象。 + */ + @GetMapping(value = "/announcement/get/{announcementId}") + @Operation(summary = "获取公告") + @RequiresPermissions(PermissionConstants.SYSTEM_NOTICE_READ) + public AnnouncementDTO addAnnouncement(@PathVariable String announcementId) { + return announcementService.detail(announcementId); + } + + /** + * 新增公告。 + * + * @param announcementRequest 公告对象。 + */ + @PostMapping(value = "/announcement/add") + @RequiresPermissions(PermissionConstants.SYSTEM_NOTICE_ADD) + @Operation(summary = "新增公告") + public void addAnnouncement(@Validated @RequestBody AnnouncementRequest announcementRequest) { + announcementService.add(announcementRequest, SessionUtils.getUserId()); + } + + /** + * 编辑公告。 + * + * @param announcementRequest 公告对象。 + */ + @PostMapping(value = "/announcement/edit") + @Operation(summary = "编辑公告") + @RequiresPermissions(PermissionConstants.SYSTEM_NOTICE_UPDATE) + public void updateAnnouncement(@Validated @RequestBody AnnouncementRequest announcementRequest) { + announcementService.update(announcementRequest, SessionUtils.getUserId()); + } + + /** + * 删除公告。 + * + * @param announcementId 公告id。 + */ + @GetMapping(value = "/announcement/delete/{announcementId}") + @Operation(summary = "删除公告") + @RequiresPermissions(PermissionConstants.SYSTEM_NOTICE_DELETE) + public void deleteAnnouncement(@PathVariable String announcementId) { + announcementService.delete(announcementId); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/AttachmentController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/AttachmentController.java new file mode 100644 index 0000000..0b87c01 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/AttachmentController.java @@ -0,0 +1,42 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.crm.system.service.AttachmentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * @author song-cc-rock + */ +@RestController +@RequestMapping("/attachment") +@Tag(name = "附件管理") +public class AttachmentController { + + + @Resource + private AttachmentService attachmentService; + + @PostMapping("/upload/temp") + @Operation(summary = "上传临时附件") + public List uploadTmp(@RequestParam("files") List files) { + return attachmentService.uploadTemp(files); + } + + @GetMapping("/preview/{id}") + @Operation(summary = "预览附件") + public ResponseEntity preview(@PathVariable String id) { + return attachmentService.getResource(id); + } + + @GetMapping("/download/{id}") + @Operation(summary = "下载附件") + public ResponseEntity download(@PathVariable String id) { + return attachmentService.getResource(id); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/DepartmentController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/DepartmentController.java new file mode 100644 index 0000000..4a53cb6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/DepartmentController.java @@ -0,0 +1,87 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.domain.Department; +import cn.cordys.crm.system.dto.request.DepartmentAddRequest; +import cn.cordys.crm.system.dto.request.DepartmentCommanderRequest; +import cn.cordys.crm.system.dto.request.DepartmentRenameRequest; +import cn.cordys.crm.system.dto.request.NodeMoveRequest; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.NotEmpty; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + + +@RestController +@RequestMapping("/department") +@Tag(name = "组织架构") +public class DepartmentController { + @Resource + private DepartmentService departmentService; + + @GetMapping("/tree") + @Operation(summary = "组织架构-部门树查询") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_READ) + public List getTree() { + return departmentService.getTree(OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_ADD) + @Operation(summary = "组织架构-添加子部门") + public Department addDepartment(@Validated @RequestBody DepartmentAddRequest request) { + return departmentService.addDepartment(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + + @PostMapping("/rename") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_UPDATE) + @Operation(summary = "组织架构-重命名子部门") + public void rename(@Validated @RequestBody DepartmentRenameRequest request) { + departmentService.rename(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/set-commander") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_UPDATE) + @Operation(summary = "组织架构-设置部门负责人") + public void setCommander(@Validated @RequestBody DepartmentCommanderRequest request) { + departmentService.setCommander(request, SessionUtils.getUserId()); + } + + @PostMapping("/delete/check") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_DELETE) + @Operation(summary = "组织架构-删除部门校验") + public boolean deleteCheck(@RequestBody @NotEmpty List ids) { + return departmentService.deleteCheck(ids, OrganizationContext.getOrganizationId()); + + } + + + @PostMapping("/delete") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_DELETE) + @Operation(summary = "组织架构-删除部门") + public void deleteDepartment(@RequestBody @NotEmpty List ids) { + departmentService.delete(ids, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + + } + + + @PostMapping("/sort") + @Operation(summary = "组织架构-部门排序") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_UPDATE) + public void sort(@Validated @RequestBody NodeMoveRequest request) { + departmentService.sort(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/DictController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/DictController.java new file mode 100644 index 0000000..e9d8113 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/DictController.java @@ -0,0 +1,83 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.domain.Dict; +import cn.cordys.crm.system.dto.DictConfigDTO; +import cn.cordys.crm.system.dto.request.DictAddRequest; +import cn.cordys.crm.system.dto.request.DictSortRequest; +import cn.cordys.crm.system.dto.request.DictSwitchRequest; +import cn.cordys.crm.system.dto.request.DictUpdateRequest; +import cn.cordys.crm.system.service.DictService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/dict") +@Tag(name = "模块配置-字典管理") +public class DictController { + + @Resource + private DictService dictService; + + @GetMapping("/get/{module}") + @Operation(summary = "获取字典列表") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_READ}) + public List getDictListByType( + @Schema(description = "类型", requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"OPPORTUNITY_FAIL_RS", "CUSTOMER_POOL_RS", "CLUE_POOL_RS"}) + @PathVariable("module") String module) { + return dictService.getDictListByType(module, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/add") + @Operation(summary = "添加字典值") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void addDict(@RequestBody DictAddRequest request) { + dictService.addDict(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @Operation(summary = "修改字典值") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void updateDict(@RequestBody DictUpdateRequest request) { + dictService.updateDict(request, SessionUtils.getUserId()); + } + + @GetMapping("/delete/{id}") + @Operation(summary = "删除字典值") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void deleteDict(@PathVariable("id") String id) { + dictService.deleteDict(id); + } + + @PostMapping("/switch") + @Operation(summary = "字典开启关闭") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE}) + public void switchDict(@RequestBody DictSwitchRequest request) { + dictService.switchDict(request, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/sort") + @Operation(summary = "字典排序") + @RequiresPermissions(PermissionConstants.MODULE_SETTING_UPDATE) + public void sortModule(@Validated @RequestBody DictSortRequest request) { + dictService.sort(request, SessionUtils.getUserId()); + } + + @GetMapping("/config/{module}") + @Operation(summary = "获取字典配置") + public DictConfigDTO getDictConf( + @Schema(description = "字典类型", requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"OPPORTUNITY_FAIL_RS", "CUSTOMER_POOL_RS", "CLUE_POOL_RS"}) + @PathVariable("module") String module) { + return dictService.getDictConf(module, OrganizationContext.getOrganizationId()); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/ExportTaskCenterController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/ExportTaskCenterController.java new file mode 100644 index 0000000..c3f04d0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/ExportTaskCenterController.java @@ -0,0 +1,44 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.crm.system.domain.ExportTask; +import cn.cordys.crm.system.dto.request.ExportTaskCenterQueryRequest; +import cn.cordys.crm.system.service.ExportTaskCenterService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * @author song-cc-rock + */ + +@RestController +@RequestMapping("/export/center") +@Tag(name = "导出中心") +public class ExportTaskCenterController { + + @Resource + private ExportTaskCenterService exportTaskCenterService; + + @PostMapping("/list") + @Operation(summary = "查询导出任务列表") + public List queryList(@RequestBody ExportTaskCenterQueryRequest request) { + return exportTaskCenterService.list(request, SessionUtils.getUserId()); + } + + @GetMapping("/cancel/{taskId}") + @Operation(summary = "取消导出") + public void cancel(@PathVariable("taskId") String taskId) { + exportTaskCenterService.cancel(taskId, SessionUtils.getUserId()); + } + + @GetMapping("/download/{taskId}") + @Operation(summary = "下载") + public ResponseEntity download(@PathVariable("taskId") String taskId) { + return exportTaskCenterService.download(taskId, SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/LicenseController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/LicenseController.java new file mode 100644 index 0000000..f7331a0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/LicenseController.java @@ -0,0 +1,34 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.crm.system.dto.LicenseDTO; +import cn.cordys.crm.system.service.LicenseService; +import cn.cordys.security.SessionUtils; +import com.fasterxml.jackson.databind.node.TextNode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/license") +@Tag(name = "系统设置-授权管理") +public class LicenseController { + + @Resource + private LicenseService licenseService; + + @GetMapping("/validate") + @Operation(summary = "License 校验") + public LicenseDTO validate() { + return licenseService.validate(); + } + + @PostMapping("/add") + @Operation(summary = "添加License") + @RequiresPermissions(PermissionConstants.LICENSE_EDIT) + public LicenseDTO addLicense(@RequestBody TextNode licenseCode) { + return licenseService.add(licenseCode.asText(), SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/LocaleLanguageController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/LocaleLanguageController.java new file mode 100644 index 0000000..58e4b51 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/LocaleLanguageController.java @@ -0,0 +1,28 @@ +package cn.cordys.crm.system.controller; + + +import cn.cordys.crm.system.dto.request.LocaleLanguageRequest; +import cn.cordys.crm.system.service.LocalLanguageService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "本地语言") +@RestController +public class LocaleLanguageController { + + @Resource + private LocalLanguageService localLanguageService; + + + @PostMapping("/locale-language/change") + @Operation(summary = "修改本地语言") + public void localeLanguageChange(@Validated @RequestBody LocaleLanguageRequest request) { + localLanguageService.localeLanguageChange(request, SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/LoginController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/LoginController.java new file mode 100644 index 0000000..8e0a5c5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/LoginController.java @@ -0,0 +1,113 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.UserSource; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.request.LoginRequest; +import cn.cordys.common.util.Translator; +import cn.cordys.common.util.rsa.RsaKey; +import cn.cordys.common.util.rsa.RsaUtils; +import cn.cordys.common.utils.IpUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.service.UserLoginService; +import cn.cordys.security.SessionUser; +import cn.cordys.security.SessionUtils; +import cn.cordys.security.UserDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.lang3.Strings; +import org.apache.shiro.SecurityUtils; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * 登录控制器,负责处理用户登录、校验和退出操作。 + *

+ * 该控制器包含检查是否已登录、获取公钥、用户登录和退出登录功能。 + *

+ */ +@RestController +@RequestMapping +@Tag(name = "登录") +public class LoginController { + + @Resource + private UserLoginService userLoginService; + + /** + * 检查用户是否已登录。 + * + * @return 返回用户会话信息,未登录则返回 401 错误。 + */ + @GetMapping(value = "/is-login") + @Operation(summary = "是否登录") + public SessionUser isLogin() { + SessionUser user = SessionUtils.getUser(); + if (user != null) { + // 检查当前组织的手机认证配置 + userLoginService.checkMobileAuthConfig(OrganizationContext.getOrganizationId()); + + UserDTO userDTO = userLoginService.authenticateUser(user.getId()); + SessionUser sessionUser = SessionUser.fromUser(userDTO, SessionUtils.getSessionId()); + SessionUtils.putUser(sessionUser); + return sessionUser; + } + return null; + } + + /** + * 获取 RSA 公钥。 + * + * @return 返回 RSA 公钥。 + * + * @throws Exception 可能抛出的异常。 + */ + @GetMapping(value = "/get-key") + @Operation(summary = "获取公钥") + public String getKey() throws Exception { + RsaKey rsaKey = RsaUtils.getRsaKey(); + return rsaKey.getPublicKey(); + } + + /** + * 用户登录。 + * + * @param request 登录请求对象,包含用户名和密码。 + * + * @return 登录结果。 + * + * @throws GenericException 如果已登录且当前用户与请求用户名不同,抛出异常。 + */ + @PostMapping(value = "/login") + @Operation(summary = "登录") + public SessionUser login(@Validated @RequestBody LoginRequest request, HttpServletRequest httpServletRequest) { + SessionUser sessionUser = SessionUtils.getUser(); + if (sessionUser != null) { + // 如果当前用户已登录且用户名与请求用户名不匹配,抛出异常 + if (!Strings.CS.equals(sessionUser.getId(), request.getUsername())) { + throw new GenericException(Translator.get("please_logout_current_user")); + } + } + // 设置认证方式为 LOCAL + SecurityUtils.getSubject().getSession().setAttribute("authenticate", UserSource.LOCAL.name()); + request.setLoginAddress(IpUtils.getClientIpAddress(httpServletRequest)); + return userLoginService.login(request); + } + + /** + * 退出登录。 + * + * @return 返回退出成功信息。 + */ + @GetMapping(value = "/logout") + @Operation(summary = "退出登录") + public String logout() { + if (SessionUtils.getUser() == null) { + return "logout success"; + } + // 退出当前会话 + SecurityUtils.getSubject().logout(); + return "logout success"; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/LoginLogController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/LoginLogController.java new file mode 100644 index 0000000..674894d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/LoginLogController.java @@ -0,0 +1,39 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.dto.request.LoginLogRequest; +import cn.cordys.crm.system.dto.response.LoginLogListResponse; +import cn.cordys.crm.system.service.SysLoginLogService; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/login/log") +@Tag(name = "登入日志") +public class LoginLogController { + + @Resource + private SysLoginLogService sysLoginLogService; + + @PostMapping("/list") + @Operation(summary = "系统管理-登录日志-列表查询") + @RequiresPermissions(PermissionConstants.OPERATION_LOG_READ) + public Pager> loginList(@Validated @RequestBody LoginLogRequest request) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, sysLoginLogService.list(request, OrganizationContext.getOrganizationId())); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/McpController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/McpController.java new file mode 100644 index 0000000..9b9c412 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/McpController.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.dto.field.base.SimpleField; +import cn.cordys.crm.system.service.ModuleFormService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/mcp") +@Tag(name = "MCP三方接口") +public class McpController { + + @Resource + private ModuleFormService moduleFormService; + + @GetMapping("/form/config/{formKey}") + @Operation(summary = "获取表单配置") + public List getMcpField(@PathVariable String formKey) { + return moduleFormService.getMcpFields(formKey, OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/MessageTaskController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/MessageTaskController.java new file mode 100644 index 0000000..750fcbb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/MessageTaskController.java @@ -0,0 +1,63 @@ +package cn.cordys.crm.system.controller; + + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.domain.MessageTask; +import cn.cordys.crm.system.dto.MessageTaskConfigWithNameDTO; +import cn.cordys.crm.system.dto.request.MessageTaskBatchRequest; +import cn.cordys.crm.system.dto.request.MessageTaskConfigRequest; +import cn.cordys.crm.system.dto.request.MessageTaskRequest; +import cn.cordys.crm.system.dto.response.MessageTaskDTO; +import cn.cordys.crm.system.service.MessageNotificationService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "消息设置") +@RestController +@RequestMapping("/message/task/") +public class MessageTaskController { + @Resource + private MessageNotificationService messageNotificationService; + + @PostMapping("save") + @Operation(summary = "项目管理-消息管理-消息设置-保存消息设置") + @RequiresPermissions(PermissionConstants.SYSTEM_NOTICE_UPDATE) + public MessageTask saveMessage(@Validated({Created.class, Updated.class}) @RequestBody MessageTaskRequest messageTaskRequest) { + return messageNotificationService.saveMessageTask(messageTaskRequest, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("batch/save") + @Operation(summary = "项目管理-消息管理-消息设置-批量编辑") + @RequiresPermissions(PermissionConstants.SYSTEM_NOTICE_UPDATE) + public void batchSaveMessage(@Validated({Created.class, Updated.class}) @RequestBody MessageTaskBatchRequest messageTaskBatchRequest) { + messageNotificationService.batchSaveMessageTask(messageTaskBatchRequest, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @GetMapping("get") + @Operation(summary = "项目管理-消息管理-消息设置-获取消息设置") + @RequiresPermissions(PermissionConstants.SYSTEM_NOTICE_READ) + public List getMessageList() { + return messageNotificationService.getMessageList(OrganizationContext.getOrganizationId()); + } + + //获取消息配置的config + @PostMapping("config/query") + @Operation(summary = "项目管理-消息管理-消息设置-获取消息配置") + @RequiresPermissions(PermissionConstants.SYSTEM_NOTICE_READ) + public MessageTaskConfigWithNameDTO getMessageConfig(@Validated @RequestBody MessageTaskConfigRequest request) { + return messageNotificationService.getMessageConfig(request.getModule(), request.getEvent(), OrganizationContext.getOrganizationId()); + } + + +} + diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/ModuleController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/ModuleController.java new file mode 100644 index 0000000..81c9a16 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/ModuleController.java @@ -0,0 +1,83 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.DeptUserTreeNode; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.RoleUserTreeNode; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.dto.ModuleDTO; +import cn.cordys.crm.system.dto.request.ModuleRequest; +import cn.cordys.crm.system.dto.request.ModuleSortRequest; +import cn.cordys.crm.system.service.ModuleService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/module") +@Tag(name = "模块配置") +public class ModuleController { + + @Resource + private ModuleService moduleService; + + @PostMapping("/list") + @Operation(summary = "获取模块设置列表") + public List getModuleList(@Validated @RequestBody ModuleRequest request) { + return moduleService.getModuleList(request); + } + + @GetMapping("/form/list") + @Operation(summary = "获取表单列表") + public List getFormList() { + return moduleService.getFormList(); + } + + @GetMapping("/switch/{id}") + @Operation(summary = "单个模块开启或关闭") + @RequiresPermissions(PermissionConstants.MODULE_SETTING_UPDATE) + public void switchModule(@PathVariable String id) { + moduleService.switchModule(id, SessionUtils.getUserId()); + } + + @PostMapping("/sort") + @Operation(summary = "模块排序") + @RequiresPermissions(PermissionConstants.MODULE_SETTING_UPDATE) + public void sortModule(@Validated @RequestBody ModuleSortRequest request) { + moduleService.sort(request, SessionUtils.getUserId()); + } + + @GetMapping("/user/dept/tree") + @Operation(summary = "获取部门用户树") + @RequiresPermissions(PermissionConstants.MODULE_SETTING_READ) + public List getDeptUserTree() { + return moduleService.getDeptUserTree(OrganizationContext.getOrganizationId()); + } + + @GetMapping("/role/tree") + @Operation(summary = "获取角色树") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_READ, PermissionConstants.PROCESS_SETTING_READ}, logical = Logical.OR) + public List getRoleTree() { + return moduleService.getRoleTree(OrganizationContext.getOrganizationId()); + } + + @GetMapping("/advanced-search/settings") + @Operation(summary = "高级搜索开关设置") + public boolean getAdvancedSetting() { + return moduleService.getAdvancedSetting(); + } + + @GetMapping("/advanced-search/switch") + @Operation(summary = "高级搜索开关设置") + @RequiresPermissions(PermissionConstants.MODULE_SETTING_UPDATE) + public void switchAdvanced() { + moduleService.switchAdvanced(SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/ModuleFieldController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/ModuleFieldController.java new file mode 100644 index 0000000..75d4619 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/ModuleFieldController.java @@ -0,0 +1,285 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.InternalUserView; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.DeptDataPermissionDTO; +import cn.cordys.common.dto.DeptUserTreeNode; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.service.DataScopeService; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.clue.dto.request.CluePageRequest; +import cn.cordys.crm.clue.dto.response.ClueListResponse; +import cn.cordys.crm.clue.service.ClueService; +import cn.cordys.crm.contract.dto.request.*; +import cn.cordys.crm.contract.dto.response.*; +import cn.cordys.crm.contract.service.*; +import cn.cordys.crm.customer.dto.request.CustomerContactPageRequest; +import cn.cordys.crm.customer.dto.request.CustomerPageRequest; +import cn.cordys.crm.customer.dto.response.CustomerContactListResponse; +import cn.cordys.crm.customer.dto.response.CustomerListResponse; +import cn.cordys.crm.customer.service.CustomerContactService; +import cn.cordys.crm.customer.service.CustomerService; +import cn.cordys.crm.form.dto.request.CustomFormDataPageRequest; +import cn.cordys.crm.form.dto.response.CustomFormDataListResponse; +import cn.cordys.crm.form.service.CustomFormDataService; +import cn.cordys.crm.opportunity.dto.request.OpportunityPageRequest; +import cn.cordys.crm.opportunity.dto.request.OpportunityQuotationPageRequest; +import cn.cordys.crm.opportunity.dto.response.OpportunityListResponse; +import cn.cordys.crm.opportunity.dto.response.OpportunityQuotationListResponse; +import cn.cordys.crm.opportunity.service.OpportunityQuotationService; +import cn.cordys.crm.opportunity.service.OpportunityService; +import cn.cordys.crm.order.dto.request.OrderPageRequest; +import cn.cordys.crm.order.dto.response.OrderListResponse; +import cn.cordys.crm.order.service.OrderService; +import cn.cordys.crm.product.dto.request.ProductPageRequest; +import cn.cordys.crm.product.dto.request.ProductPricePageRequest; +import cn.cordys.crm.product.dto.response.ProductListResponse; +import cn.cordys.crm.product.dto.response.ProductPriceResponse; +import cn.cordys.crm.product.service.ProductPriceService; +import cn.cordys.crm.product.service.ProductService; +import cn.cordys.crm.system.dto.DatasourceRefDTO; +import cn.cordys.crm.system.dto.request.DatasourceRefQueryRequest; +import cn.cordys.crm.system.dto.request.FieldRepeatCheckRequest; +import cn.cordys.crm.system.dto.request.FieldResolveRequest; +import cn.cordys.crm.system.dto.response.FieldRepeatCheckResponse; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFieldService; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.crm.system.service.ModuleService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * @author song-cc-rock + */ +@RestController +@RequestMapping("/field") +@Tag(name = "字段管理-数据") +public class ModuleFieldController { + + @Resource + private ModuleService moduleService; + @Resource + private ModuleFieldService moduleFieldService; + @Resource + private ModuleFormService moduleFormService; + @Resource + private ModuleFormCacheService formCacheService; + @Resource + private CustomerService customerService; + @Resource + private CustomerContactService customerContactService; + @Resource + private ClueService clueService; + @Resource + private OpportunityService opportunityService; + @Resource + private OpportunityQuotationService opportunityQuotationService; + @Resource + private ContractService contractService; + @Resource + private ProductService productService; + @Resource + private ProductPriceService productPriceService; + @Resource + private ContractPaymentPlanService contractPaymentPlanService; + @Resource + private ContractPaymentRecordService contractPaymentRecordService; + @Resource + private DataScopeService dataScopeService; + @Resource + private BusinessTitleService businessTitleService; + @Resource + private OrderService orderService; + @Resource + private CustomFormDataService customFormDataService; + @Resource + private ContractInvoiceService contractInvoiceService; + + @GetMapping("/dept/tree") + @Operation(summary = "获取部门树") + public List getDeptTree() { + return moduleFieldService.getDeptTree(OrganizationContext.getOrganizationId()); + } + + @GetMapping("/user/dept/tree") + @Operation(summary = "获取部门用户树") + public List getDeptUserTree() { + return moduleService.getDeptUserTree(OrganizationContext.getOrganizationId()); + } + + @PostMapping("/source/lead") + @Operation(summary = "分页获取线索") + public Pager> sourceCluePage(@Valid @RequestBody CluePageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CLUE.getKey()); + request.setCombineSearch(request.getCombineSearch().convert()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), + InternalUserView.ALL.name(), PermissionConstants.CLUE_MANAGEMENT_READ); + return clueService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission, true); + } + + @PostMapping("/source/custom-form-data") + @Operation(summary = "分页获取自定义表单数据") + public Pager> sourceCustomFormDataPage(@Valid @RequestBody CustomFormDataPageRequest request) { + ConditionFilterUtils.parseCondition(request, request.getCustomFormId()); + request.setCombineSearch(request.getCombineSearch().convert()); + return customFormDataService.page(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), false); + } + + @PostMapping("/source/account") + @Operation(summary = "分页获取客户") + public Pager> sourceCustomerPage(@Valid @RequestBody CustomerPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CUSTOMER.getKey()); + request.setCombineSearch(request.getCombineSearch().convert()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), + InternalUserView.ALL.name(), PermissionConstants.CUSTOMER_MANAGEMENT_READ); + return customerService.sourceList(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @PostMapping("/source/contact") + @Operation(summary = "分页获取联系人") + public Pager> sourceContactPage(@Valid @RequestBody CustomerContactPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTACT.getKey()); + request.setCombineSearch(request.getCombineSearch().convert()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), + InternalUserView.ALL.name(), PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ); + return customerContactService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @PostMapping("/source/opportunity") + @Operation(summary = "分页获取商机") + public Pager> sourceOpportunityPage(@Valid @RequestBody OpportunityPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.OPPORTUNITY.getKey()); + request.setCombineSearch(request.getCombineSearch().convert()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), InternalUserView.ALL.name(), + PermissionConstants.OPPORTUNITY_MANAGEMENT_READ); + return opportunityService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission, true); + } + + @PostMapping("/source/quotation") + @Operation(summary = "分页获取报价单") + public Pager> sourceOpportunityQuotationPage(@Valid @RequestBody OpportunityQuotationPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.QUOTATION.getKey()); + request.setCombineSearch(request.getCombineSearch().convert()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), InternalUserView.ALL.name(), + PermissionConstants.OPPORTUNITY_MANAGEMENT_READ); + return opportunityQuotationService.list(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId(), deptDataPermission, true); + } + + @PostMapping("/source/contract") + @Operation(summary = "分页获取合同") + public Pager> sourceContractPage(@Valid @RequestBody ContractPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTRACT.getKey()); + request.setCombineSearch(request.getCombineSearch().convert()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), InternalUserView.ALL.name(), + PermissionConstants.CONTRACT_READ); + return contractService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission, true); + } + + @PostMapping("/source/product") + @Operation(summary = "分页获取产品") + public Pager> sourceProductPage(@Valid @RequestBody ProductPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.PRODUCT.getKey()); + request.setCombineSearch(request.getCombineSearch().convert()); + // 数据源接口只展示上架数据 + request.setStatus("1"); + return productService.list(request, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/source/price") + @Operation(summary = "分页获取产品价格表") + public Pager> sourceProductPage(@Valid @RequestBody ProductPricePageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.PRICE.getKey()); + request.setCombineSearch(request.getCombineSearch().convert()); + return productPriceService.list(request, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/source/contract/payment-plan") + @Operation(summary = "分页获取合同回款计划") + public Pager> sourcePlanPage(@Valid @RequestBody ContractPaymentPlanPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTRACT_PAYMENT_PLAN.getKey()); + request.setCombineSearch(request.getCombineSearch().convert()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), InternalUserView.ALL.name(), + PermissionConstants.CONTRACT_PAYMENT_PLAN_READ); + return contractPaymentPlanService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @PostMapping("/source/contract/payment-record") + @Operation(summary = "分页获取合同回款记录") + public Pager> sourceRecordPage(@Valid @RequestBody ContractPaymentRecordPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.CONTRACT_PAYMENT_RECORD.getKey()); + request.setCombineSearch(request.getCombineSearch().convert()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), InternalUserView.ALL.name(), + PermissionConstants.CONTRACT_PAYMENT_RECORD_READ); + return contractPaymentRecordService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @PostMapping("/source/order") + @Operation(summary = "分页获取订单列表") + public PagerWithOption> list(@Validated @RequestBody OrderPageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.ORDER.getKey()); + request.setCombineSearch(request.getCombineSearch().convert()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), + InternalUserView.ALL.name(), PermissionConstants.ORDER_READ); + return orderService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission, false); + } + + @PostMapping("/source/business-title") + @Operation(summary = "分页获取工商抬头信息") + public Pager> sourceBusinessTitlePage(@Valid @RequestBody BusinessTitlePageRequest request) { + return businessTitleService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/source/invoice") + @Operation(summary = "分页获取发票信息") + public PagerWithOption> list(@Validated @RequestBody ContractInvoicePageRequest request) { + ConditionFilterUtils.parseCondition(request, FormKey.INVOICE.getKey()); + DeptDataPermissionDTO deptDataPermission = dataScopeService.getDeptDataPermission(SessionUtils.getUserId(), + OrganizationContext.getOrganizationId(), request.getViewId(), PermissionConstants.CONTRACT_INVOICE_READ); + return contractInvoiceService.list(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), deptDataPermission); + } + + @PostMapping("/check/repeat") + @Operation(summary = "校验重复值") + public FieldRepeatCheckResponse checkRepeat(@Valid @RequestBody FieldRepeatCheckRequest checkRequest) { + return moduleFieldService.checkRepeat(checkRequest, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/resolve/business") + @Operation(summary = "解析业务ID") + public List resolveBusinessId(@Valid @RequestBody FieldResolveRequest request) { + return moduleFormService.getSourceOptionsByKeywords(request.getSourceType(), request.getKeywords()); + } + + @GetMapping("/display/{formKey}") + @Operation(summary = "获取表单配置") + public ModuleFormConfigDTO getFieldList(@PathVariable String formKey) { + return moduleFormService.getSourceDisplayFields(formKey, OrganizationContext.getOrganizationId()); + } + + @GetMapping("/source/config/{formKey}") + @Operation(summary = "获取数据源表单配置") + public ModuleFormConfigDTO getSourceFormConfig(@PathVariable String formKey) { + return formCacheService.getBusinessFormConfig(formKey, OrganizationContext.getOrganizationId()); + } + + @PostMapping("/source/ref-detail") + @Operation(summary = "批量获取数据源引用详情") + public List getDatasourceRefDetail(@Valid @RequestBody DatasourceRefQueryRequest request) { + return moduleFieldService.getSourceRefDetail(request); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/ModuleFormController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/ModuleFormController.java new file mode 100644 index 0000000..639a2b9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/ModuleFormController.java @@ -0,0 +1,44 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.dto.request.ModuleFormSaveRequest; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ModuleFormCacheService; +import cn.cordys.crm.system.service.ModuleFormService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * @author song-cc-rock + */ +@RestController +@RequestMapping("/module/form") +@Tag(name = "模块-表单设置") +public class ModuleFormController { + + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ModuleFormService moduleFormService; + + @PostMapping("/save") + @Operation(summary = "保存") + @RequiresPermissions(PermissionConstants.MODULE_SETTING_UPDATE) + public ModuleFormConfigDTO save(@Validated @RequestBody ModuleFormSaveRequest request) { + return moduleFormCacheService.save(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/config/{formKey}") + @Operation(summary = "获取表单配置") + @RequiresPermissions(value = {PermissionConstants.MODULE_SETTING_UPDATE, PermissionConstants.PROCESS_SETTING_READ}, logical = Logical.OR) + public ModuleFormConfigDTO getFieldList(@PathVariable String formKey) { + return moduleFormService.getBusinessFormConfig(formKey, OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/NavigationController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/NavigationController.java new file mode 100644 index 0000000..72b2717 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/NavigationController.java @@ -0,0 +1,39 @@ +package cn.cordys.crm.system.controller; + + +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.domain.Navigation; +import cn.cordys.crm.system.dto.request.ModuleSortRequest; +import cn.cordys.crm.system.service.NavigationService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/navigation") +@Tag(name = "导航栏设置") +public class NavigationController { + + @Resource + private NavigationService navigationService; + + + @GetMapping("/list") + @Operation(summary = "获取导航栏列表") + public List getNavigationList() { + return navigationService.getNavigationList(OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/sort") + @Operation(summary = "导航栏排序") + public void sortModule(@Validated @RequestBody ModuleSortRequest request) { + navigationService.sort(request, SessionUtils.getUserId()); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/NotificationController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/NotificationController.java new file mode 100644 index 0000000..9ccff1b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/NotificationController.java @@ -0,0 +1,67 @@ +package cn.cordys.crm.system.controller; + + +import cn.cordys.common.dto.OptionCountDTO; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.dto.request.NotificationRequest; +import cn.cordys.crm.system.dto.response.NotificationDTO; +import cn.cordys.crm.system.service.NotificationService; +import cn.cordys.security.SessionUtils; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "消息中心") +@RestController +@RequestMapping(value = "/notification") +public class NotificationController { + + @Resource + private NotificationService notificationService; + + @PostMapping(value = "/list/all/page") + @Operation(summary = "消息中心-获取消息中心所有消息列表") + public Pager> listNotification(@Validated @RequestBody NotificationRequest notificationRequest) { + Page page = PageHelper.startPage(notificationRequest.getCurrent(), notificationRequest.getPageSize(), true); + return PageUtils.setPageInfo(page, notificationService.listNotification(notificationRequest, SessionUtils.getUserId(), OrganizationContext.getOrganizationId())); + } + + + @GetMapping(value = "/last/list") + @Operation(summary = "消息中心-获取消息中未读的最新部分消息") + public List listLastNotification() { + return notificationService.listLastNotification(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping(value = "/last/announcement/list") + @Operation(summary = "消息中心-获取用户未读的公告列表") + public List listLastAnnouncement() { + return notificationService.listLastAnnouncement(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping(value = "/read/{id}") + @Operation(summary = "消息中心-将消息设置为已读") + public Integer read(@PathVariable String id) { + return notificationService.read(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping(value = "/read/all") + @Operation(summary = "消息中心-将消息中心所有信息设置为已读消息") + public Integer readAll() { + return notificationService.readAll(OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping(value = "/count") + @Operation(summary = "消息中心-获取消息中心消息具体类型具体状态的数量") + public List countNotification(@RequestBody NotificationRequest notificationRequest) { + return notificationService.countNotification(notificationRequest, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/OperationLogController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/OperationLogController.java new file mode 100644 index 0000000..ef7285f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/OperationLogController.java @@ -0,0 +1,44 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.dto.request.OperationLogRequest; +import cn.cordys.crm.system.dto.response.OperationLogDetailResponse; +import cn.cordys.crm.system.dto.response.OperationLogResponse; +import cn.cordys.crm.system.service.SysOperationLogService; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/operation/log") +@Tag(name = "日志") +public class OperationLogController { + + @Resource + private SysOperationLogService sysOperationLogService; + + @PostMapping("/list") + @Operation(summary = "系统管理-操作日志-列表查询") + @RequiresPermissions(PermissionConstants.OPERATION_LOG_READ) + public Pager> list(@Validated @RequestBody OperationLogRequest request) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, sysOperationLogService.list(request, OrganizationContext.getOrganizationId())); + } + + @GetMapping("/detail/{id}") + @Operation(summary = "系统管理-操作日志-详情") + @RequiresPermissions(PermissionConstants.OPERATION_LOG_READ) + public OperationLogDetailResponse logDetail(@PathVariable String id) { + return sysOperationLogService.getLogDetail(id, OrganizationContext.getOrganizationId()); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/OrganizationSettingsController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/OrganizationSettingsController.java new file mode 100644 index 0000000..f4de3df --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/OrganizationSettingsController.java @@ -0,0 +1,138 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.integration.common.dto.ThirdConfigBaseDTO; +import cn.cordys.crm.integration.common.request.DeThirdConfigRequest; +import cn.cordys.crm.integration.dataease.dto.DeAuthDTO; +import cn.cordys.crm.integration.dataease.service.DataEaseService; +import cn.cordys.crm.integration.dataease.service.DataEaseSyncService; +import cn.cordys.crm.system.domain.OrganizationConfig; +import cn.cordys.crm.system.dto.response.EmailDTO; +import cn.cordys.crm.system.service.IntegrationConfigService; +import cn.cordys.crm.system.service.OrganizationConfigService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Optional; + +@Tag(name = "企业设置") +@RestController +@RequestMapping("/organization/settings") +public class OrganizationSettingsController { + private static final String DEFAULT_ORGANIZATION_ID = "100001"; + @Resource + private IntegrationConfigService integrationConfigService; + @Resource + private DataEaseService dataEaseService; + @Resource + private DataEaseSyncService dataEaseSyncService; + @Resource + private OrganizationConfigService organizationConfigService; + + //获取邮件设置 + @GetMapping("/email") + @Operation(summary = "获取邮件设置") + @RequiresPermissions(PermissionConstants.SYSTEM_SETTING_READ) + public EmailDTO getEmail() { + return organizationConfigService.getEmail(OrganizationContext.getOrganizationId()); + } + + @PostMapping("/email/edit") + @Operation(summary = "编辑邮件设置") + @RequiresPermissions(PermissionConstants.SYSTEM_SETTING_UPDATE) + public void editEmail(@Validated @RequestBody EmailDTO emailDTO) { + organizationConfigService.editEmail(emailDTO, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping("/email/test") + @Operation(summary = "系统设置-系统-系统参数-基本设置-邮件设置-测试连接") + @RequiresPermissions(PermissionConstants.SYSTEM_SETTING_READ) + public void verifyEmailConnection(@Validated @RequestBody EmailDTO emailDTO) { + organizationConfigService.verifyEmailConnection(emailDTO); + } + + //获取同步组织设置 + @GetMapping("/third-party") + @Operation(summary = "获取三方设置") + @RequiresPermissions(PermissionConstants.SYSTEM_SETTING_READ) + public List> getThirdConfig() { + return integrationConfigService.getThirdConfig(OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/switch-third-party") + @Operation(summary = "切换三方设置") + @RequiresPermissions(PermissionConstants.SYSTEM_SETTING_UPDATE) + public void switchThirdPartySetting(@RequestParam String type) { + integrationConfigService.switchThirdPartySetting(type, OrganizationContext.getOrganizationId()); + } + + //获取最新的三方同步标识 + @GetMapping("/third-party/sync/resource") + @Operation(summary = "获取最新的三方同步来源") + public OrganizationConfig getLatestSyncResource() { + String organizationId = Optional.ofNullable(OrganizationContext.getOrganizationId()).orElse(DEFAULT_ORGANIZATION_ID); + return integrationConfigService.getLatestSyncResource(organizationId); + } + + @PostMapping("/third-party/edit") + @Operation(summary = "编辑三方设置") + @RequiresPermissions(PermissionConstants.SYSTEM_SETTING_UPDATE) + public void editThirdConfig(@Validated @RequestBody ThirdConfigBaseDTO baseDTO) { + integrationConfigService.editThirdConfig(baseDTO, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @PostMapping(value = "/third-party/test") + @Operation(summary = "校验配置是否链接成功") + @RequiresPermissions(PermissionConstants.SYSTEM_SETTING_READ) + public boolean validate(@RequestBody ThirdConfigBaseDTO baseDTO) { + return integrationConfigService.testConnection(baseDTO, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + @GetMapping(value = "/de-token") + @Operation(summary = "生成DE-Token") + @RequiresPermissions(PermissionConstants.DASHBOARD_READ) + public DeAuthDTO generateToken(@RequestParam(required = false) boolean isModule) { + return dataEaseService.getEmbeddedDeToken(OrganizationContext.getOrganizationId(), SessionUtils.getUserId(), isModule); + } + + @GetMapping(value = "/de/sync") + @Operation(summary = "同步DE数据") + @RequiresPermissions(PermissionConstants.SYSTEM_SETTING_UPDATE) + public void syncDataEase() { + dataEaseSyncService.syncDataEase(OrganizationContext.getOrganizationId()); + } + + @PostMapping(value = "/de/org/list") + @Operation(summary = "获取de组织列表") + @RequiresPermissions(PermissionConstants.SYSTEM_SETTING_READ) + public List getDeOrgList(@RequestBody DeThirdConfigRequest deThirdConfigRequest) { + return dataEaseSyncService.getDeOrgList(deThirdConfigRequest); + } + + @GetMapping("/third-party/get/{type}") + @Operation(summary = "根据类型获取开启的三方扫码设置") + public ThirdConfigBaseDTO getThirdConfigByType(@PathVariable String type) { + String organizationId = Optional.ofNullable(OrganizationContext.getOrganizationId()).orElse(DEFAULT_ORGANIZATION_ID); + ThirdConfigBaseDTO thirdConfig= integrationConfigService.getThirdConfigForPublic(type, organizationId); + integrationConfigService.handleData(thirdConfig); + return thirdConfig; + } + + @GetMapping("/third-party/types") + @Operation(summary = "获取三方应用扫码类型集合") + public List getThirdTypeList() { + String organizationId = Optional.ofNullable(OrganizationContext.getOrganizationId()).orElse(DEFAULT_ORGANIZATION_ID); + return integrationConfigService.getThirdTypeList(organizationId); + } + + +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/OrganizationUserController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/OrganizationUserController.java new file mode 100644 index 0000000..b7dbae6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/OrganizationUserController.java @@ -0,0 +1,192 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.DeptUserTreeNode; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.utils.ConditionFilterUtils; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.dto.request.*; +import cn.cordys.crm.system.dto.response.RoleListResponse; +import cn.cordys.crm.system.dto.response.UserImportResponse; +import cn.cordys.crm.system.dto.response.UserPageResponse; +import cn.cordys.crm.system.dto.response.UserResponse; +import cn.cordys.crm.system.service.OrganizationConfigService; +import cn.cordys.crm.system.service.OrganizationUserService; +import cn.cordys.crm.system.service.RoleService; +import cn.cordys.security.SessionUtils; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + + +@RestController +@RequestMapping("/user") +@Tag(name = "用户(员工)") +public class OrganizationUserController { + + @Resource + private OrganizationUserService organizationUserService; + @Resource + private RoleService roleService; + @Resource + private OrganizationConfigService organizationConfigService; + + @PostMapping("/list") + @Operation(summary = "用户(员工)-列表查询") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_READ) + public Pager> list(@Validated @RequestBody UserPageRequest request) { + ConditionFilterUtils.parseCondition(request); + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, organizationUserService.list(request)); + } + + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_ADD) + @Operation(summary = "用户(员工)-添加员工") + public void addUser(@Validated @RequestBody UserAddRequest request) { + organizationUserService.addUser(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); + } + + + @GetMapping("/detail/{id}") + @Operation(summary = "用户(员工)-员工详情") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_READ) + public UserResponse getUserDetail(@PathVariable String id) { + return organizationUserService.getUserDetail(id); + } + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_UPDATE) + @Operation(summary = "用户(员工)-更新") + public void updateUser(@Validated @RequestBody UserUpdateRequest request) { + organizationUserService.updateUser(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/reset-password/{userId}") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_USER_RESET_PASSWORD) + @Operation(summary = "用户(员工)-重置密码") + public void resetPassword(@PathVariable String userId) { + organizationUserService.resetPassword(userId, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/batch-enable") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_UPDATE) + @Operation(summary = "用户(员工)-批量启用/禁用") + public void enable(@Validated @RequestBody UserBatchEnableRequest request) { + organizationUserService.enable(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/batch/reset-password") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_USER_RESET_PASSWORD) + @Operation(summary = "用户(员工)-批量重置密码") + public void batchResetPassword(@Validated @RequestBody UserBatchRequest request) { + organizationUserService.batchResetPassword(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update/name") + @Operation(summary = "用户(员工)-更新名称") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_UPDATE) + public void updateName(@Validated @RequestBody UserUpdateName request) { + organizationUserService.updateUserName(request, SessionUtils.getUserId()); + } + + + @PostMapping("/batch/edit") + @Operation(summary = "用户(员工)-批量编辑") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_UPDATE) + public void batchEditUser(@Validated @RequestBody UserBatchEditRequest request) { + organizationUserService.batchEditUser(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/download/template") + @Operation(summary = "用户(员工)-excel导入-下载模板") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_ADD) + public void userTemplateExport(HttpServletResponse response) { + organizationUserService.downloadExcelTemplate(response); + } + + + @PostMapping("/import/pre-check") + @Operation(summary = "用户(员工)-excel导入检查") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_IMPORT) + public UserImportResponse preCheckXMind(@RequestPart(value = "file", required = false) MultipartFile file) { + return organizationUserService.preCheck(file, OrganizationContext.getOrganizationId()); + } + + + @PostMapping(value = "/import") + @Operation(summary = "用户(员工)-excel导入") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_IMPORT) + public UserImportResponse importUser(@RequestPart(value = "file", required = false) MultipartFile file) { + return organizationUserService.importByExcel(file, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @GetMapping(value = "/option") + @Operation(summary = "获取用户下拉option") + public List getUserList() { + return organizationUserService.getUserOptions(OrganizationContext.getOrganizationId()); + } + + @GetMapping(value = "/admin/option") + @Operation(summary = "获取管理员用户下拉option") + public List getAdminUserList() { + return organizationUserService.getAdminUserOptions(OrganizationContext.getOrganizationId()); + } + + @GetMapping(value = "/role/option") + @Operation(summary = "获取用户角色下拉option") + @RequiresPermissions(value = {PermissionConstants.SYS_ORGANIZATION_READ, PermissionConstants.PROCESS_SETTING_READ}, logical = Logical.OR) + public List getUserRoleList() { + List list = roleService.list(OrganizationContext.getOrganizationId()); + return list.stream().map(role -> new OptionDTO(role.getId(), role.getName())).toList(); + } + + + @GetMapping("/sync-check") + @Operation(summary = "用户(员工)-是否为第三方同步数据") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_READ) + public boolean syncCheck() { + return organizationConfigService.syncCheck(OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_DELETE) + @Operation(summary = "用户(员工)-删除") + public void deleteUser(@PathVariable String id) { + organizationUserService.deleteUserById(id, OrganizationContext.getOrganizationId()); + } + + + @GetMapping("/delete/check/{id}") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_DELETE) + @Operation(summary = "用户(员工)-删除校验") + public boolean deleteCheck(@PathVariable String id) { + return organizationUserService.deleteCheck(id); + } + + + @GetMapping("/get/{departmentId}") + @Operation(summary = "获取指定部门下用户") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_READ) + public List getDepartmentUser(@PathVariable String departmentId) { + return organizationUserService.getUserTreeByDepId(departmentId, OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/PersonalCenterController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/PersonalCenterController.java new file mode 100644 index 0000000..f7a1194 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/PersonalCenterController.java @@ -0,0 +1,68 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.follow.dto.request.FollowUpPlanPageRequest; +import cn.cordys.crm.follow.dto.response.FollowUpPlanListResponse; +import cn.cordys.crm.system.dto.request.PersonalInfoRequest; +import cn.cordys.crm.system.dto.request.PersonalPasswordRequest; +import cn.cordys.crm.system.dto.request.SendEmailDTO; +import cn.cordys.crm.system.dto.response.UserResponse; +import cn.cordys.crm.system.service.PersonalCenterService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.NotNull; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + + +@RestController +@RequestMapping("/personal/center") +@Tag(name = "个人中心") +public class PersonalCenterController { + @Resource + private PersonalCenterService personalCenterService; + + + @GetMapping("/info") + @Operation(summary = "当前用户详情") + public UserResponse getUserDetail() { + return personalCenterService.getUserDetail(SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + + @PostMapping("/update") + @Operation(summary = "更新户详情") + public UserResponse updateInfo(@Validated @RequestBody PersonalInfoRequest personalInfoRequest) { + return personalCenterService.updateInfo(personalInfoRequest, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + /** + * 发送验证码 + */ + @PostMapping("/mail/code/send") + public void sendCode(@RequestBody @NotNull SendEmailDTO email) { + personalCenterService.sendCode(email, OrganizationContext.getOrganizationId()); + } + + /** + * 更新密码 + */ + @PostMapping("/info/reset") + @Operation(summary = "用户密码重置") + public void resetUserPassword(@Validated @RequestBody PersonalPasswordRequest personalPasswordRequest) { + personalCenterService.resetUserPassword(personalPasswordRequest, SessionUtils.getUserId()); + } + + + @PostMapping("/follow/plan/list") + @Operation(summary = "用户跟进计划列表") + public PagerWithOption> list(@Validated @RequestBody FollowUpPlanPageRequest request) { + request.setMyPlan(true); + return personalCenterService.getPlanList(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/PicController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/PicController.java new file mode 100644 index 0000000..1855459 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/PicController.java @@ -0,0 +1,35 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.crm.system.service.PicService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * @author song-cc-rock + */ +@RestController +@RequestMapping("/pic") +@Tag(name = "图片管理") +public class PicController { + + @Resource + private PicService picService; + + @PostMapping("/upload/temp") + @Operation(summary = "上传临时图片") + public List uploadTmpPic(@RequestParam("files") List files) { + return picService.uploadTempPic(files); + } + + @GetMapping("/preview/{id}") + @Operation(summary = "预览图片") + public ResponseEntity preview(@PathVariable String id) { + return picService.getResource(id); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/RoleController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/RoleController.java new file mode 100644 index 0000000..ea372c6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/RoleController.java @@ -0,0 +1,148 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.DeptUserTreeNode; +import cn.cordys.common.dto.RoleUserTreeNode; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.permission.PermissionDefinitionItem; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.domain.Role; +import cn.cordys.crm.system.dto.request.RoleAddRequest; +import cn.cordys.crm.system.dto.request.RoleUpdateRequest; +import cn.cordys.crm.system.dto.request.RoleUserPageRequest; +import cn.cordys.crm.system.dto.request.RoleUserRelateRequest; +import cn.cordys.crm.system.dto.response.RoleGetResponse; +import cn.cordys.crm.system.dto.response.RoleListResponse; +import cn.cordys.crm.system.dto.response.RoleUserListResponse; +import cn.cordys.crm.system.dto.response.RoleUserOptionResponse; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.crm.system.service.RoleService; +import cn.cordys.crm.system.service.UserRoleService; +import cn.cordys.security.SessionUtils; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * @author jianxing + * @date 2025-01-03 16:52:34 + */ +@Tag(name = "角色") +@RestController +@RequestMapping("/role") +public class RoleController { + @Resource + private RoleService roleService; + @Resource + private UserRoleService userRoleService; + @Resource + private DepartmentService departmentService; + + @GetMapping("/list") + @RequiresPermissions(PermissionConstants.SYSTEM_ROLE_READ) + @Operation(summary = "角色列表") + public List list() { + return roleService.list(OrganizationContext.getOrganizationId()); + } + + @GetMapping("/get/{id}") + @RequiresPermissions(PermissionConstants.SYSTEM_ROLE_READ) + @Operation(summary = "角色详情") + public RoleGetResponse get(@PathVariable String id) { + return roleService.get(id); + } + + @PostMapping("/add") + @RequiresPermissions(PermissionConstants.SYSTEM_ROLE_ADD) + @Operation(summary = "添加角色") + public Role add(@Validated @RequestBody RoleAddRequest request) { + return roleService.add(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/update") + @RequiresPermissions(PermissionConstants.SYSTEM_ROLE_UPDATE) + @Operation(summary = "更新角色") + public Role update(@Validated @RequestBody RoleUpdateRequest request) { + return roleService.update(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/delete/{id}") + @Operation(summary = "删除角色") + @RequiresPermissions(PermissionConstants.SYSTEM_ROLE_DELETE) + public void delete(@PathVariable String id) { + roleService.delete(id, OrganizationContext.getOrganizationId()); + } + + @GetMapping("/permission/setting") + @Operation(summary = "获取权限配置") + @RequiresPermissions(PermissionConstants.SYSTEM_ROLE_READ) + public List getPermissionSetting() { + return roleService.getPermissionSetting(); + } + + @PostMapping("/user/page") + @Operation(summary = "查看拥有该权限的用户") + @RequiresPermissions(PermissionConstants.SYSTEM_ROLE_READ) + public Pager> listUser(@Validated @RequestBody RoleUserPageRequest request) { + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + return PageUtils.setPageInfo(page, userRoleService.listUserByRoleId(request, OrganizationContext.getOrganizationId())); + } + + @GetMapping("/dept/tree") + @Operation(summary = "获取部门树") + @RequiresPermissions(PermissionConstants.SYSTEM_ROLE_READ) + public List getDeptTree() { + return departmentService.getTree(OrganizationContext.getOrganizationId()); + } + + @GetMapping("/user/dept/tree/{roleId}") + @Operation(summary = "获取部门用户树") + @RequiresPermissions(PermissionConstants.SYSTEM_ROLE_ADD_USER) + public List getDeptUserTree(@PathVariable String roleId) { + return userRoleService.getDeptUserTree(OrganizationContext.getOrganizationId(), roleId); + } + + @GetMapping("/user/role/tree/{roleId}") + @Operation(summary = "获取角色用户树") + @RequiresPermissions(PermissionConstants.SYSTEM_ROLE_ADD_USER) + public List getRoleUserTree(@PathVariable String roleId) { + return userRoleService.getRoleUserTree(OrganizationContext.getOrganizationId(), roleId); + } + + @GetMapping("/user/option/{roleId}") + @Operation(summary = "获取所有用户选项") + @RequiresPermissions(PermissionConstants.SYSTEM_ROLE_ADD_USER) + public List RoleUserOptionResponse(@PathVariable String roleId) { + return userRoleService.getUserOptionByRoleId(OrganizationContext.getOrganizationId(), roleId); + } + + @PostMapping("/user/relate") + @Operation(summary = "角色关联添加用户") + @RequiresPermissions(PermissionConstants.SYSTEM_ROLE_ADD_USER) + public void relateUser(@Validated @RequestBody RoleUserRelateRequest request) { + userRoleService.relateUser(request, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @GetMapping("/user/delete/{id}") + @Operation(summary = "角色移除用户") + @RequiresPermissions(PermissionConstants.SYSTEM_ROLE_REMOVE_USER) + public void deleteRoleUser(@PathVariable String id) { + userRoleService.deleteRoleUser(id, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } + + @PostMapping("/user/batch/delete") + @Operation(summary = "角色批量移除用户") + @RequiresPermissions(PermissionConstants.SYSTEM_ROLE_REMOVE_USER) + public void batchDeleteRoleUser(@RequestBody List ids) { + userRoleService.batchDeleteRoleUser(ids, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/SystemVersionController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/SystemVersionController.java new file mode 100644 index 0000000..10d42bc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/SystemVersionController.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.crm.system.dto.VersionInfoDTO; +import cn.cordys.crm.system.service.SystemService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "系统版本") +@RestController +public class SystemVersionController { + + @Resource + private SystemService systemService; + + @GetMapping("/system/version") + @Operation(summary = "获取当前系统版本") + public VersionInfoDTO getVersion() { + return systemService.getVersion(); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/UserKeyController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/UserKeyController.java new file mode 100644 index 0000000..6499202 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/UserKeyController.java @@ -0,0 +1,72 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.crm.system.domain.UserKey; +import cn.cordys.crm.system.dto.request.UserKeyUpdateRequest; +import cn.cordys.crm.system.service.UserKeyService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/user/api/key") +@Tag(name = "个人中心-API Keys") +public class UserKeyController { + + @Resource + private UserKeyService userKeyService; + + + @GetMapping("/add") + @RequiresPermissions(PermissionConstants.PERSONAL_API_KEY_ADD) + @Operation(summary = "个人中心-APIKEY-新增") + public void add() { + userKeyService.addUserKey(SessionUtils.getUserId()); + } + + + @GetMapping("/list") + @RequiresPermissions(PermissionConstants.PERSONAL_API_KEY_READ) + @Operation(summary = "个人中心-APIKEY-列表") + public List getUserKeysInfo() { + return userKeyService.getUserKeysInfo(SessionUtils.getUserId()); + } + + + @PostMapping("/update") + @Operation(summary = "个人中心-APIKEY-更新") + @RequiresPermissions(PermissionConstants.PERSONAL_API_KEY_UPDATE) + public void update(@Validated @RequestBody UserKeyUpdateRequest request) { + userKeyService.updateUserKey(request); + } + + + @GetMapping("/delete/{id}") + @RequiresPermissions(PermissionConstants.PERSONAL_API_KEY_DELETE) + @Operation(summary = "个人中心-APIKEY-删除") + public void delete(@PathVariable String id) { + userKeyService.deleteUserKey(id); + } + + @GetMapping("/enable/{id}") + @Operation(summary = "个人中心-APIKEY-启用") + @RequiresPermissions(PermissionConstants.PERSONAL_API_KEY_UPDATE) + public void enable(@PathVariable String id) { + userKeyService.enableUserKey(id); + } + + @GetMapping("/disable/{id}") + @Operation(summary = "个人中心-APIKEY-启用") + @RequiresPermissions(PermissionConstants.PERSONAL_API_KEY_UPDATE) + public void disable(@PathVariable String id) { + userKeyService.disableUserKey(id); + } + +} + diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/controller/UserSyncController.java b/backend/crm/src/main/java/cn/cordys/crm/system/controller/UserSyncController.java new file mode 100644 index 0000000..d004881 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/controller/UserSyncController.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.integration.sync.service.ThirdDepartmentService; +import cn.cordys.security.SessionUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/user/sync") +@Tag(name = "用户(员工)同步") +public class UserSyncController { + + @Resource + private ThirdDepartmentService thirdDepartmentService; + + @GetMapping("/{type}") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_SYNC) + @Operation(summary = "用户(员工)-同步组织架构") + public void syncUser(@PathVariable String type) { + thirdDepartmentService.syncUser(SessionUtils.getUserId(), OrganizationContext.getOrganizationId(), type, LocaleContextHolder.getLocale()); + } + + @GetMapping("/check") + @RequiresPermissions(PermissionConstants.SYS_ORGANIZATION_SYNC) + @Operation(summary = "用户(员工)-检查是否还在同步组织架构") + public Boolean checkSyncUser() { + return thirdDepartmentService.getSyncStatus(OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/Announcement.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Announcement.java new file mode 100644 index 0000000..f10242f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Announcement.java @@ -0,0 +1,41 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "sys_announcement") +public class Announcement extends BaseModel { + @Schema(description = "公告标题") + private String subject; + + @Schema(description = "公告内容") + private byte[] content; + + @Schema(description = "开始时间") + private Long startTime; + + @Schema(description = "结束时间") + private Long endTime; + + @Schema(description = "链接") + private String url; + + @Schema(description = "重命名链接") + private String renameUrl; + + @Schema(description = "接收者") + private byte[] receiver; + + @Schema(description = "组织ID") + private String organizationId; + + @Schema(description = "转为通知") + private Boolean notice; + + @Schema(description = "接收类型组合") + private byte[] receiveType; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/Attachment.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Attachment.java new file mode 100644 index 0000000..defcf7e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Attachment.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * @author song-cc-rock + */ +@Data +@Table(name = "sys_attachment") +public class Attachment extends BaseModel { + + @Schema(description = "名称") + private String name; + + @Schema(description = "类型") + private String type; + + @Schema(description = "大小") + private Long size; + + @Schema(description = "存储方式") + private String storage; + + @Schema(description = "资源ID") + private String resourceId; + + @Schema(description = "组织ID") + private String organizationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/Department.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Department.java new file mode 100644 index 0000000..a8ce3e3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Department.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "sys_department") +public class Department extends BaseModel { + + @Schema(description = "名称") + private String name; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "父级") + private String parentId; + + @Schema(description = "排序") + private Long pos; + + @Schema(description = "来源") + private String resource; + + @Schema(description = "来源id") + private String resourceId; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/DepartmentCommander.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/DepartmentCommander.java new file mode 100644 index 0000000..c1aaaa9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/DepartmentCommander.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "sys_department_commander") +public class DepartmentCommander extends BaseModel { + + @Schema(description = "用户id") + private String userId; + + @Schema(description = "部门id") + private String departmentId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/Dict.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Dict.java new file mode 100644 index 0000000..5e060ab --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Dict.java @@ -0,0 +1,28 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * 客户字典表; + */ +@Data +@Table(name = "sys_dict") +public class Dict extends BaseModel { + @Schema(description = "字典值") + private String name; + + @Schema(description = "字典模块") + private String module; + + @Schema(description = "字典值类型") + private String type; + + @Schema(description = "自定义排序") + private Long pos; + + @Schema(description = "组织ID") + private String organizationId; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/DictConfig.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/DictConfig.java new file mode 100644 index 0000000..1dd5406 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/DictConfig.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.system.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * 系统字典配置表; + */ +@Data +@Table(name = "sys_dict_config") +public class DictConfig { + + @Schema(description = "字典类型") + private String module; + + @Schema(description = "组织ID") + private String organizationId; + + @Schema(description = "是否启用") + private Boolean enabled; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/ExportTask.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/ExportTask.java new file mode 100644 index 0000000..96c38d9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/ExportTask.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "export_task") +public class ExportTask extends BaseModel { + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "资源类型") + private String resourceType; + + @Schema(description = "文件id") + private String fileId; + + @Schema(description = "状态") + private String status; + + @Schema(description = "组织id") + private String organizationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/License.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/License.java new file mode 100644 index 0000000..5350d13 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/License.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "sys_license") +public class License extends BaseModel { + + @Schema(description = "license_code") + private String licenseCode; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/LoginLog.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/LoginLog.java new file mode 100644 index 0000000..ebd9696 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/LoginLog.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.system.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 登入日志 + * + * @author jianxing + * @date 2025-01-24 16:12:06 + */ +@Data +@Table(name = "sys_login_log") +public class LoginLog { + + @Schema(description = "主键") + private String id; + + @Schema(description = "操作时间") + private Long createTime; + + @Schema(description = "操作人") + private String operator; + + @Schema(description = "登录地") + private String loginAddress; + + @Schema(description = "平台") + private String platform; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/MessageTask.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/MessageTask.java new file mode 100644 index 0000000..77aeff5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/MessageTask.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "sys_message_task") +public class MessageTask extends BaseModel { + @Schema(description = "通知事件类型") + private String event; + + @Schema(description = "任务类型") + private String taskType; + + @Schema(description = "邮件启用") + private Boolean emailEnable; + + @Schema(description = "系统启用") + private Boolean sysEnable; + + @Schema(description = "企业微信启用") + private Boolean weComEnable; + + @Schema(description = "钉钉启用") + private Boolean dingTalkEnable; + + @Schema(description = "飞书启用") + private Boolean larkEnable; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "消息模版") + private byte[] template; +} + diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/MessageTaskConfig.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/MessageTaskConfig.java new file mode 100644 index 0000000..588aacd --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/MessageTaskConfig.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.system.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "sys_message_task_config") +public class MessageTaskConfig { + + @Schema(description = "ID") + private String id; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "通知事件类型") + private String event; + + @Schema(description = "任务类型") + private String taskType; + + @Schema(description = "通知配置值") + private String value; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/Module.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Module.java new file mode 100644 index 0000000..866bef3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Module.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "sys_module") +public class Module extends BaseModel { + + @Schema(description = "组织ID") + private String organizationId; + + @Schema(description = "模块KEY") + private String moduleKey; + + @Schema(description = "启用/禁用") + private Boolean enable; + + @Schema(description = "自定义排序") + private Long pos; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/ModuleField.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/ModuleField.java new file mode 100644 index 0000000..58a0a14 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/ModuleField.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 模块字段配置 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "sys_module_field") +public class ModuleField extends BaseModel { + + @Schema(description = "所属表单") + private String formId; + + @Schema(description = "名称") + private String name; + + @Schema(description = "字段内置Key") + private String internalKey; + + @Schema(description = "字段类型") + private String type; + + @Schema(description = "是否移动端") + private Boolean mobile; + + @Schema(description = "排序") + private Long pos; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/ModuleFieldBlob.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/ModuleFieldBlob.java new file mode 100644 index 0000000..6829925 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/ModuleFieldBlob.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.system.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "sys_module_field_blob") +public class ModuleFieldBlob { + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "属性") + private String prop; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/ModuleForm.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/ModuleForm.java new file mode 100644 index 0000000..08736c9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/ModuleForm.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "sys_module_form") +public class ModuleForm extends BaseModel { + + @Schema(description = "表单Key") + private String formKey; + + @Schema(description = "组织id") + private String organizationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/ModuleFormBlob.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/ModuleFormBlob.java new file mode 100644 index 0000000..f67f260 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/ModuleFormBlob.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.system.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "sys_module_form_blob") +public class ModuleFormBlob { + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "属性") + private String prop; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/Navigation.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Navigation.java new file mode 100644 index 0000000..3006aa6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Navigation.java @@ -0,0 +1,24 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "sys_navigation") +public class Navigation extends BaseModel { + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "导航栏key") + private String navigationKey; + + @Schema(description = "启用/禁用") + private Boolean enable; + + @Schema(description = "自定义排序") + private Long pos; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/Notification.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Notification.java new file mode 100644 index 0000000..fadbae1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Notification.java @@ -0,0 +1,58 @@ +package cn.cordys.crm.system.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "sys_notification") +public class Notification { + + @Schema(description = "ID") + private String id; + + @Schema(description = "通知类型") + private String type; + + @Schema(description = "接收人") + private String receiver; + + @Schema(description = "标题") + private String subject; + + @Schema(description = "状态") + private String status; + + @Schema(description = "操作人") + private String operator; + + @Schema(description = "操作") + private String operation; + + @Schema(description = "组织ID") + private String organizationId; + + @Schema(description = "资源ID") + private String resourceId; + + @Schema(description = "资源类型") + private String resourceType; + + @Schema(description = "资源名称") + private String resourceName; + + @Schema(description = "通知内容") + private byte[] content; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/OperationLog.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/OperationLog.java new file mode 100644 index 0000000..d9cf7d2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/OperationLog.java @@ -0,0 +1,59 @@ +package cn.cordys.crm.system.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 操作日志 + * + * @author jianxing + * @date 2025-01-23 14:21:05 + */ +@Data +@Table(name = "sys_operation_log") +public class OperationLog { + + @Schema(description = "主键") + private String id; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "操作类型/add/update/delete") + private String type; + + @Schema(description = "操作模块") + private String module; + + @Schema(description = "资源id") + private String resourceId; + + @Schema(description = "资源名称") + private String resourceName; + + /** + * 无需对比的操作日志详情 + * 例如可以移入公海 + * 详情记录为:客户 xxx 移入公海 xxx + */ + @Schema(description = "日志详情") + private String detail; + + @Schema(description = "操作时间") + private Long createTime; + + @Schema(description = "操作人") + private String createUser; + + @Schema(description = "操作路径") + private String path; + + @Schema(description = "操作方法") + private String method; + + @Schema(description = "请求来源") + private String requestSource; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/OperationLogBlob.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/OperationLogBlob.java new file mode 100644 index 0000000..5ead5a0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/OperationLogBlob.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.system.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + + +@Data +@Schema(description = "操作日志Blob") +@Table(name = "sys_operation_log_blob") +public class OperationLogBlob implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + @Schema(description = "主键,与operation_log表id一致", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + @Schema(description = "变更前内容") + private byte[] originalValue; + @Schema(description = "变更后内容") + private byte[] modifiedValue; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/OrganizationConfig.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/OrganizationConfig.java new file mode 100644 index 0000000..5d84067 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/OrganizationConfig.java @@ -0,0 +1,30 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * 企业设置 + */ +@Data +@Table(name = "sys_organization_config") +public class OrganizationConfig extends BaseModel { + + @Schema(description = "配置类型") + private String type; + + @Schema(description = "企业id") + private String organizationId; + + @Schema(description = "是否同步(只对同步企业生效)") + private boolean sync; + + @Schema(description = "同步来源") + private String syncResource; + + @Schema(description = "是否开启") + private boolean enable; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/OrganizationConfigDetail.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/OrganizationConfigDetail.java new file mode 100644 index 0000000..d2f3370 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/OrganizationConfigDetail.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * 企业设置详情表; + */ +@Data +@Table(name = "sys_organization_config_detail") +public class OrganizationConfigDetail extends BaseModel { + @Schema(description = "配置id") + private String configId; + + @Schema(description = "配置内容类型") + private String type; + + @Schema(description = "配置名称") + private String name; + + @Schema(description = "是否启用") + private Boolean enable; + + @Schema(description = "配置内容") + private byte[] content; + + @Schema(description = "描述") + private String description; +} + diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/OrganizationUser.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/OrganizationUser.java new file mode 100644 index 0000000..f42c193 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/OrganizationUser.java @@ -0,0 +1,51 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 组织成员 + * + * @author jianxing + * @date 2025-01-08 17:00:38 + */ +@Data +@Table(name = "sys_organization_user") +public class OrganizationUser extends BaseModel { + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "用户id") + private String userId; + + @Schema(description = "三方唯一id") + private String resourceUserId; + + @Schema(description = "部门id") + private String departmentId; + + @Schema(description = "是否启用") + private Boolean enable; + + @Schema(description = "工号") + private String employeeId; + + @Schema(description = "职位") + private String position; + + @Schema(description = "员工类型") + private String employeeType; + + @Schema(description = "直属上级") + private String supervisorId; + + @Schema(description = "工作城市") + private String workCity; + + @Schema(description = "入职时间") + private Long onboardingDate; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/Parameter.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Parameter.java new file mode 100644 index 0000000..e69b9e1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Parameter.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.system.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "sys_parameter") +public class Parameter { + + @Id + @Schema(description = "key") + private String paramKey; + + @Schema(description = "value") + private String paramValue; + + @Schema(description = "type") + private String type; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/Role.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Role.java new file mode 100644 index 0000000..a35a0d2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Role.java @@ -0,0 +1,33 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 角色 + * + * @author jianxing + * @date 2025-01-02 15:30:44 + */ +@Data +@Table(name = "sys_role") +public class Role extends BaseModel { + + @Schema(description = "角色名称") + private String name; + + @Schema(description = "是否是内置角色") + private Boolean internal; + + @Schema(description = "数据范围(全部数据权限/指定部门权限/本部门数据权限/本部门及以下数据权限/仅本人数据)") + private String dataScope; + + @Schema(description = "描述") + private String description; + + @Schema(description = "组织id") + private String organizationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/RolePermission.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/RolePermission.java new file mode 100644 index 0000000..470e9f4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/RolePermission.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.system.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 角色权限 + * + * @author jianxing + * @date 2025-01-03 17:58:31 + */ +@Data +@Table(name = "sys_role_permission") +public class RolePermission { + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "角色id") + private String roleId; + + @Schema(description = "权限id") + private String permissionId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/RoleScopeDept.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/RoleScopeDept.java new file mode 100644 index 0000000..3fe5ab4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/RoleScopeDept.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.system.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 角色与部门的关联表,角色 data_scope 为指定部门时使用 + * + * @author jianxing + * @date 2025-01-23 18:18:10 + */ +@Data +@Table(name = "sys_role_scope_dept") +public class RoleScopeDept { + + @Schema(description = "ID") + private String id; + + @Schema(description = "角色ID") + private String roleId; + + @Schema(description = "部门ID") + private String departmentId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/Schedule.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Schedule.java new file mode 100644 index 0000000..6f84fe3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/Schedule.java @@ -0,0 +1,43 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; + +@Data +public class Schedule extends BaseModel { + @Serial + private static final long serialVersionUID = 1L; + @Schema(description = "id") + private String id; + @Schema(description = "Qrtz UUID") + private String key; + @Schema(description = "组织ID") + private String organizationId; + @Schema(description = "执行类型 cron", requiredMode = Schema.RequiredMode.REQUIRED) + private String type; + @Schema(description = "cron 表达式", requiredMode = Schema.RequiredMode.REQUIRED) + private String value; + @Schema(description = "Schedule Job Class Name", requiredMode = Schema.RequiredMode.REQUIRED) + private String job; + @Schema(description = "资源类型", requiredMode = Schema.RequiredMode.REQUIRED) + private String resourceType; + @Schema(description = "是否开启") + private Boolean enable; + @Schema(description = "资源ID") + private String resourceId; + @Schema(description = "创建人") + private String createUser; + @Schema(description = "创建时间") + private Long createTime; + @Schema(description = "更新时间") + private Long updateTime; + @Schema(description = "名称") + private String name; + @Schema(description = "配置") + private String config; + @Schema(description = "业务ID") + private Long num; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/StageAdvancedConfig.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/StageAdvancedConfig.java new file mode 100644 index 0000000..e8b4748 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/StageAdvancedConfig.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "stage_advanced_config") +public class StageAdvancedConfig extends BaseModel { + + @Schema(description = "源id") + private String originId; + + @Schema(description = "目标id") + private String targetId; + + @Schema(description = "是否允许流转") + private Boolean enable; + + @Schema(description = "字段配置") + private String fieldConfig; + + @Schema(description = "模块类型(order-订单/contract-合同)") + private String moduleType; + + @Schema(description = "组织id") + private String organizationId; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/User.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/User.java new file mode 100644 index 0000000..9480cab --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/User.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +@Data +@Table(name = "sys_user") +public class User extends BaseModel { + + @Schema(description = "用户名") + private String name; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "邮箱") + private String email; + + @Schema(description = "密码") + private String password; + + @Schema(description = "性别(0-男/1-女)") + private Boolean gender; + + @Schema(description = "语言") + private String language; + + @Schema(description = "当前组织ID") + private String lastOrganizationId; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/UserExtend.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/UserExtend.java new file mode 100644 index 0000000..e2102aa --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/UserExtend.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.system.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + + +@Data +@Schema(description = "用户扩展信息") +@Table(name = "sys_user_extend") +public class UserExtend implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + @Schema(description = "用户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + @Schema(description = "头像") + private String avatar; + @Schema(description = "其他平台对接信息") + private byte[] platformInfo; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/UserKey.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/UserKey.java new file mode 100644 index 0000000..3845eb5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/UserKey.java @@ -0,0 +1,44 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +@Data +@Table(name = "user_key") +public class UserKey implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + @Schema(description = "user_key ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{user_key.id.not_blank}", groups = {Updated.class}) + @Size(min = 1, max = 32, message = "{user_key.id.length_range}", groups = {Created.class, Updated.class}) + private String id; + @Schema(description = "用户ID") + private String createUser; + @Schema(description = "access_key", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{user_key.access_key.not_blank}", groups = {Created.class}) + @Size(min = 1, max = 50, message = "{user_key.access_key.length_range}", groups = {Created.class, Updated.class}) + private String accessKey; + @Schema(description = "secret key", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{user_key.secret_key.not_blank}", groups = {Created.class}) + @Size(min = 1, max = 50, message = "{user_key.secret_key.length_range}", groups = {Created.class, Updated.class}) + private String secretKey; + @Schema(description = "创建时间") + private Long createTime; + @Schema(description = "状态") + private Boolean enable; + @Schema(description = "是否永久有效", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "{user_key.forever.not_blank}", groups = {Created.class}) + private Boolean forever; + @Schema(description = "到期时间") + private Long expireTime; + private String description; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/UserRole.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/UserRole.java new file mode 100644 index 0000000..07b5dfd --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/UserRole.java @@ -0,0 +1,24 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +/** + * 用户关联角色 + * + * @author jianxing + * @date 2025-01-06 14:31:33 + */ +@Data +@Table(name = "sys_user_role") +public class UserRole extends BaseModel { + + @Schema(description = "角色id") + private String roleId; + + @Schema(description = "用户id") + private String userId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/UserView.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/UserView.java new file mode 100644 index 0000000..9ba8050 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/UserView.java @@ -0,0 +1,36 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "sys_user_view") +public class UserView extends BaseModel { + + @Schema(description = "用户id") + private String userId; + + @Schema(description = "视图名称") + private String name; + + @Schema(description = "是否固定视图") + private Boolean fixed; + + @Schema(description = "状态") + private Boolean enable; + + @Schema(description = "资源类型(客户/线索/商机)") + private String resourceType; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "排序") + private Long pos; + + @Schema(description = "匹配模式(AND/OR)") + private String searchMode; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/UserViewCondition.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/UserViewCondition.java new file mode 100644 index 0000000..1e9b557 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/UserViewCondition.java @@ -0,0 +1,36 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.domain.BaseModel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "sys_user_view_condition") +public class UserViewCondition extends BaseModel { + + @Schema(description = "视图id") + private String sysUserViewId; + + @Schema(description = "参数名称") + private String name; + + @Schema(description = "参数值") + private String value; + + @Schema(description = "参数值类型") + private String valueType; + + @Schema(description = "类型") + private String type; + + @Schema(description = "是否是多选值") + private Boolean multipleValue; + + @Schema(description = "操作符") + private String operator; + + @Schema(description = "参数值列表") + private String childrenValue; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/domain/WorkerNode.java b/backend/crm/src/main/java/cn/cordys/crm/system/domain/WorkerNode.java new file mode 100644 index 0000000..d9a0388 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/domain/WorkerNode.java @@ -0,0 +1,41 @@ +package cn.cordys.crm.system.domain; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +@Data +public class WorkerNode implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + @Schema(description = "auto increment id", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{worker_node.id.not_blank}", groups = {Updated.class}) + private Long id; + @Schema(description = "host name", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{worker_node.host_name.not_blank}", groups = {Created.class}) + @Size(min = 1, max = 64, message = "{worker_node.host_name.length_range}", groups = {Created.class, Updated.class}) + private String hostName; + @Schema(description = "port", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{worker_node.port.not_blank}", groups = {Created.class}) + @Size(min = 1, max = 64, message = "{worker_node.port.length_range}", groups = {Created.class, Updated.class}) + private String port; + @Schema(description = "node type: ACTUAL or CONTAINER", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "{worker_node.type.not_blank}", groups = {Created.class}) + private Integer type; + @Schema(description = "launch date", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "{worker_node.launch_date.not_blank}", groups = {Created.class}) + private Long launchDate; + @Schema(description = "modified time", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "{worker_node.modified.not_blank}", groups = {Created.class}) + private Long modified; + @Schema(description = "created time", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "{worker_node.created.not_blank}", groups = {Created.class}) + private Long created; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/AnnouncementReceiveTypeDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/AnnouncementReceiveTypeDTO.java new file mode 100644 index 0000000..54a7a67 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/AnnouncementReceiveTypeDTO.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class AnnouncementReceiveTypeDTO { + @Schema(description = "部门ID") + private List deptIds; + + @Schema(description = "角色ID") + private List roleIds; + + @Schema(description = "用户ID") + private List userIds; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/DatasourceRefBusinessDetail.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/DatasourceRefBusinessDetail.java new file mode 100644 index 0000000..e50a364 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/DatasourceRefBusinessDetail.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.system.dto; + +import cn.cordys.crm.contract.domain.BusinessTitle; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 数据源引用业务详情字段 + * @author song-cc-rock + */ +@Data +public class DatasourceRefBusinessDetail extends BusinessTitle { + + @Schema(description = "价格") + private BigDecimal price; + + @Schema(description = "状态") + private String status; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/DatasourceRefDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/DatasourceRefDTO.java new file mode 100644 index 0000000..6227842 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/DatasourceRefDTO.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.system.dto; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * 数据源引用详情信息 + * @author song-cc-rock + */ +@Data +public class DatasourceRefDTO extends DatasourceRefBusinessDetail { + + @Schema(description = "数据源ID") + private String id; + + @Schema(description = "自定义字段集合") + private List moduleFields; + + @Schema(description = "产品子列表") + private List> products; + + @Schema(description = "选项集合") + private Map> optionMap; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/DictConfigDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/DictConfigDTO.java new file mode 100644 index 0000000..54267f9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/DictConfigDTO.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.system.dto; + +import cn.cordys.crm.system.domain.Dict; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class DictConfigDTO { + + @Schema(description = "字典值是否启用") + private Boolean enable; + @Schema(description = "字典值列表") + private List dictList; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/FilterConditionDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/FilterConditionDTO.java new file mode 100644 index 0000000..1f69daa --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/FilterConditionDTO.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * @author song-cc-rock + */ +@Data +public class FilterConditionDTO { + + @Schema(description = "列") + private String column; + @Schema(description = "操作符") + private String operator; + @Schema(description = "值") + private List value; + @Schema(description = "类型") + private String type; + + @Schema(description = "显示字段的主表名") + private String refMainTableName; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/LicenseDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/LicenseDTO.java new file mode 100644 index 0000000..de1c87b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/LicenseDTO.java @@ -0,0 +1,55 @@ +package cn.cordys.crm.system.dto; + +import cn.cordys.crm.system.constants.LicenseStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +@Data +public class LicenseDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + /** + * 客户名称 + */ + @Schema(description = "客户名称") + private String corporation = "CORDYS"; + /** + * 授权截止时间 + */ + @Schema(description = "授权截止时间") + private String expired; + /** + * 产品名称 + */ + @Schema(description = "产品名称") + private String product = "CRM"; + /** + * 产品版本 + */ + @Schema(description = "产品版本") + private String edition; + /** + * License版本 + */ + @Schema(description = "license版本") + private String licenseVersion; + /** + * 授权数量 + */ + @Schema(description = "授权数量") + private int count; + + /** + * 状态 + */ + @Schema(example = "invalid", description = "License 状态包括[ valid, invalid, expired, not_found ]") + private String status; + + public LicenseDTO() { + this.status = LicenseStatus.NOT_FOUND.getName(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/MessageDetailDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/MessageDetailDTO.java new file mode 100644 index 0000000..31cf420 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/MessageDetailDTO.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.system.dto; + +import lombok.Data; + +import java.io.Serializable; + +@Data +public class MessageDetailDTO implements Serializable { + private String id; + private String event; + private String taskType; + private Long createTime; + private String template; + private String subject; + private String organizationId; + private boolean emailEnable; + private boolean sysEnable; + private boolean weComEnable; + private boolean dingTalkEnable; + private boolean larkEnable; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/MessageTaskConfigDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/MessageTaskConfigDTO.java new file mode 100644 index 0000000..153a0d3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/MessageTaskConfigDTO.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +public class MessageTaskConfigDTO implements Serializable { + + @Schema(description = "时间列表") + private List timeList; + + @Schema(description = "通知人员ids,包含特殊值,负责人OWNER/创建人CREATE_USER") + private List userIds; + + @Schema(description = "通知角色ids") + private List roleIds; + + @Schema(description = "是否通知负责人") + private boolean ownerEnable; + + @Schema(description = "负责人层级") + private int ownerLevel; + + @Schema(description = "是否通知角色") + private boolean roleEnable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/MessageTaskConfigWithNameDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/MessageTaskConfigWithNameDTO.java new file mode 100644 index 0000000..998ed79 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/MessageTaskConfigWithNameDTO.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.system.dto; + +import cn.cordys.common.dto.OptionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +public class MessageTaskConfigWithNameDTO extends MessageTaskConfigDTO implements Serializable { + + @Schema(description = "通知人员ids,包含特殊值,负责人OWNER/创建人CREATE_USER") + private List userIdNames; + + @Schema(description = "通知角色ids") + private List roleIdNames; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/ModuleDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/ModuleDTO.java new file mode 100644 index 0000000..16dfb3c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/ModuleDTO.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.system.dto; + +import cn.cordys.crm.system.domain.Module; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class ModuleDTO extends Module { + + @Schema(description = "是否禁用模块") + private Boolean disabled; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/RuleConditionDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/RuleConditionDTO.java new file mode 100644 index 0000000..e0d4c23 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/RuleConditionDTO.java @@ -0,0 +1,169 @@ +package cn.cordys.crm.system.dto; + +import cn.cordys.crm.system.constants.RecycleConditionOperator; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.apache.commons.lang3.Strings; + +import java.time.*; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RuleConditionDTO { + + @Schema(description = "列") + private String column; + @Schema(description = "操作符") + private String operator; + @Schema(description = "值") + private String value; + @Schema(description = "范围, 只有列为入库时间有范围值") + private List scope; + + /** + * 获取动态时间 + * + * @return 动态时间 + */ + public LocalDateTime getDynamicTime() { + if (!Strings.CS.equals(this.operator, RecycleConditionOperator.DYNAMICS.name())) { + return null; + } + LocalDateTime now = LocalDateTime.now(); + String[] timeArr = this.value.split(","); + //兼容旧数据的计算 + if (timeArr.length == 2) { + return switch (timeArr[1]) { + case "month" -> now.minusMonths(Long.parseLong(timeArr[0])); + case "week" -> now.minusWeeks(Long.parseLong(timeArr[0])); + case "day" -> now.minusDays(Long.parseLong(timeArr[0])); + default -> null; + }; + } else if (timeArr.length == 3) { + String dateValue = timeArr[1]; + String dateUnit = timeArr[2]; + long dateNumber = Long.parseLong(dateValue); + return switch (dateUnit) { + case "BEFORE_DAY" -> now.minusDays(dateNumber); + case "AFTER_DAY" -> now.plusDays(dateNumber); + case "BEFORE_WEEK" -> now.minusWeeks(dateNumber); + case "AFTER_WEEK" -> now.plusWeeks(dateNumber); + case "BEFORE_MONTH" -> now.minusMonths(dateNumber); + case "AFTER_MONTH" -> now.plusMonths(dateNumber); + default -> null; + }; + } + return null; + } + + + public List getDynamicTimeList() { + if (!Strings.CS.equals(this.operator, RecycleConditionOperator.DYNAMICS.name())) { + return null; + } + List timestamps = new ArrayList<>(); + String[] timeArr = this.value.split(","); + //TODO:兼容旧数据的计算 + if (timeArr.length == 1) { + switch (timeArr[0]) { + case "TODAY" -> { + // 获取今天的日期 + LocalDate today = LocalDate.now(); + long timestamp = getTimestamp(today.atStartOfDay()); + timestamps.add(timestamp); + long timestampEnd = getTimestamp(today.atTime(23, 59, 59, 999_000_000)); + timestamps.add(timestampEnd); + } + case "YESTERDAY" -> { + LocalDate yesterday = LocalDate.now().minusDays(1); + long timestamp = getTimestamp(yesterday.atStartOfDay()); + timestamps.add(timestamp); + long timestampEnd = getTimestamp(yesterday.atTime(23, 59, 59, 999_000_000)); + timestamps.add(timestampEnd); + } + case "TOMORROW" -> { + LocalDate tomorrow = LocalDate.now().plusDays(1); + long timestamp = getTimestamp(tomorrow.atStartOfDay()); + timestamps.add(timestamp); + long timestampEnd = getTimestamp(tomorrow.atTime(23, 59, 59, 999_000_000)); + timestamps.add(timestampEnd); + } + case "WEEK" -> { + LocalDate startOfWeek = LocalDate.now().with(java.time.DayOfWeek.MONDAY); + long timestamp = getTimestamp(startOfWeek.atStartOfDay()); + timestamps.add(timestamp); + LocalDate now = LocalDate.now().with(DayOfWeek.SUNDAY); + long timestampEnd = getTimestamp(now.atTime(23, 59, 59, 999_000_000)); + timestamps.add(timestampEnd); + } + case "LAST_WEEK" -> { + LocalDate startOfLastWeek = LocalDate.now().minusWeeks(1).with(java.time.DayOfWeek.MONDAY); + long timestamp = getTimestamp(startOfLastWeek.atStartOfDay()); + timestamps.add(timestamp); + LocalDate startOfLastWeekEnd = LocalDate.now().minusWeeks(1).with(DayOfWeek.SUNDAY); + long timestampEnd = getTimestamp(startOfLastWeekEnd.plusDays(7).atTime(23, 59, 59, 999_000_000)); + timestamps.add(timestampEnd); + } + case "MONTH" -> { + LocalDate startOfMonth = LocalDate.now().withDayOfMonth(1); + long timestamp = getTimestamp(startOfMonth.atStartOfDay()); + timestamps.add(timestamp); + LocalDate now = LocalDate.now().with(TemporalAdjusters.lastDayOfMonth()); + long timestampEnd = getTimestamp(now.atTime(23, 59, 59, 999_000_000)); + timestamps.add(timestampEnd); + } + case "LAST_MONTH" -> { + LocalDate startOfLastMonth = LocalDate.now().minusMonths(1).withDayOfMonth(1); + long timestamp = getTimestamp(startOfLastMonth.atStartOfDay()); + timestamps.add(timestamp); + LocalDate startOfLastMonthEnd = LocalDate.now().minusMonths(1).with(TemporalAdjusters.lastDayOfMonth()); + long timestampEnd = getTimestamp(startOfLastMonthEnd.atTime(23, 59, 59, 999_000_000)); + timestamps.add(timestampEnd); + } + case "LAST_SEVEN" -> { + LocalDate startOfLastSevenDays = LocalDate.now().minusDays(7); + long timestamp = getTimestamp(startOfLastSevenDays.atStartOfDay()); + timestamps.add(timestamp); + long timestampEnd = getTimestamp(LocalDate.now().atStartOfDay()); + timestamps.add(timestampEnd); + } + case "SEVEN" -> { + LocalDate startOfNextSevenDays = LocalDate.now().plusDays(6); + long timestamp = getTimestamp(LocalDate.now().atStartOfDay()); + timestamps.add(timestamp); + long timestampEnd = getTimestamp(startOfNextSevenDays.atTime(23, 59, 59, 999_000_000)); + timestamps.add(timestampEnd); + } + case "LAST_THIRTY" -> { + LocalDate startOfLastThirtyDays = LocalDate.now().minusDays(30); + long timestamp = getTimestamp(startOfLastThirtyDays.atStartOfDay()); + timestamps.add(timestamp); + long timestampEnd = getTimestamp(LocalDate.now().atStartOfDay()); + timestamps.add(timestampEnd); + } + case "THIRTY" -> { + LocalDate startOfNextThirtyDays = LocalDate.now().plusDays(29); + long timestamp = getTimestamp(LocalDate.now().atStartOfDay()); + timestamps.add(timestamp); + long timestampEnd = getTimestamp(startOfNextThirtyDays.atTime(23, 59, 59, 999_000_000)); + timestamps.add(timestampEnd); + } + } + } + return timestamps; + } + + private long getTimestamp(LocalDateTime today) { + // 使用系统默认时区 + ZonedDateTime zonedEndOfDay = today.atZone(ZoneId.systemDefault()); + // 转为时间戳(毫秒) + return zonedEndOfDay.toInstant().toEpochMilli(); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/ScopeNameDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/ScopeNameDTO.java new file mode 100644 index 0000000..887b9b8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/ScopeNameDTO.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScopeNameDTO { + + @Schema(description = "ID") + private String id; + @Schema(description = "范围") + private String scope; + @Schema(description = "名称") + private String name; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/ThirdAuthConfigDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/ThirdAuthConfigDTO.java new file mode 100644 index 0000000..c51a6d7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/ThirdAuthConfigDTO.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class ThirdAuthConfigDTO { + @Schema(description = "类型") + private String type; + @Schema(description = "企业ID") + private String corpId; + @Schema(description = "应用ID") + private String agentId; + @Schema(description = "应用key") + private String appKey; + @Schema(description = "应用密钥", requiredMode = Schema.RequiredMode.REQUIRED) + private String appSecret; + @Schema(description = "扫码登录开启") + private Boolean qrcodeEnable; + @Schema(description = "同步开启") + private Boolean syncEnable; + @Schema(description = "是否验证通过") + private Boolean verify; + @Schema(description = "回调地址") + private String redirectUrl; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/TimeDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/TimeDTO.java new file mode 100644 index 0000000..a8dbb0a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/TimeDTO.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +@Data +public class TimeDTO implements Serializable { + @Schema(description = "时间值") + private Integer timeValue; + + @Schema(description = "时间单位: MINUTE/HOUR/DAY/WEEK/MONTH") + private String timeUnit; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/TransformSourceApplyDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/TransformSourceApplyDTO.java new file mode 100644 index 0000000..89383fd --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/TransformSourceApplyDTO.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.system.dto; + +import lombok.Data; + +@Data +public class TransformSourceApplyDTO { + + /** + * 实际值 + */ + private Object actualVal; + /** + * 显示值 + */ + private Object displayVal; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/UserSimple.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/UserSimple.java new file mode 100644 index 0000000..24f899f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/UserSimple.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class UserSimple { + + @Schema(description = "用户ID") + private String id; + @Schema(description = "用户名") + private String name; + @Schema(description = "头像") + private String avatar; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/VersionInfoDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/VersionInfoDTO.java new file mode 100644 index 0000000..1dc1ea6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/VersionInfoDTO.java @@ -0,0 +1,35 @@ +package cn.cordys.crm.system.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +@Data +@Schema(description = "版本信息返回对象") +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VersionInfoDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "当前系统正在运行的版本", example = "v1.0.0") + private String currentVersion; + + @Schema(description = "最新可用版本(用于提示升级)", example = "v1.1.0") + private String latestVersion; + + @Schema(description = "系统架构,例如 amd64, arm64", example = "amd64") + private String architecture; + + @Schema(description = "版权描述", example = "© 2025 YourCompany. All rights reserved.") + private String copyright; + + @Schema(description = "是否有最新版本") + private Boolean hasNewVersion; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/convert/AnnouncementContentDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/convert/AnnouncementContentDTO.java new file mode 100644 index 0000000..a9178a9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/convert/AnnouncementContentDTO.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.system.dto.convert; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class AnnouncementContentDTO { + + @Schema(description = "链接") + private String url; + + @Schema(description = "重命名链接") + private String renameUrl; + + @Schema(description = "公告内容") + private String content; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/convert/UserRoleConvert.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/convert/UserRoleConvert.java new file mode 100644 index 0000000..7b0ec75 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/convert/UserRoleConvert.java @@ -0,0 +1,11 @@ +package cn.cordys.crm.system.dto.convert; + +import cn.cordys.common.dto.OptionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class UserRoleConvert extends OptionDTO { + @Schema(description = "选项ID") + private String userId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/AttachmentField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/AttachmentField.java new file mode 100644 index 0000000..fe75399 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/AttachmentField.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@JsonTypeName(value = "ATTACHMENT") +@EqualsAndHashCode(callSuper = true) +public class AttachmentField extends BaseField { + + @Schema(description = "仅允许单文件上传") + private Boolean onlyOne; + + @Schema(description = "允许上传的文件类型,逗号分隔") + private String accept; + + @Schema(description = "单个文件大小限制, 默认100MB") + private String limitSize; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/CheckBoxField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/CheckBoxField.java new file mode 100644 index 0000000..b7df4a6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/CheckBoxField.java @@ -0,0 +1,42 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.HasOption; +import cn.cordys.crm.system.dto.field.base.LinkProp; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +@Data +@JsonTypeName(value = "CHECKBOX") +@EqualsAndHashCode(callSuper = true) +public class CheckBoxField extends BaseField implements HasOption { + + @Schema(description = "选项值") + private List options; + + @Schema(description = "选项来源", allowableValues = {"custom", "ref"}) + private String optionSource; + + @Schema(description = "自定义选项值") + private List customOptions; + + @Schema(description = "选项引用ID (optionSource=ref 时生效)") + private String refId; + + @Schema(description = "引用表单key (optionSource=ref 时生效)") + private String refFormKey; + + @Schema(description = "默认值") + private List defaultValue; + + @Schema(description = "分布方式", allowableValues = {"horizontal", "vertical"}) + private String direction; + + @Schema(description = "联动属性") + private LinkProp linkProp; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DatasourceField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DatasourceField.java new file mode 100644 index 0000000..9a80a38 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DatasourceField.java @@ -0,0 +1,44 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.crm.system.constants.FieldSourceType; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.form.base.SourceLink; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; +import java.util.Map; + +/** + * @author song-cc-rock + */ +@Data +@JsonTypeName(value = "DATA_SOURCE") +@EqualsAndHashCode(callSuper = true) +public class DatasourceField extends BaseField { + + @EnumValue(enumClass = FieldSourceType.class) + @Schema(description = "数据源类型") + private String dataSourceType; + + @Schema(description = "过滤条件") + private Map combineSearch; + + @Schema(description = "显示字段ID集合, 前端用来映射引用字段") + private List showFields; + + @Schema(description = "引用的字段集合") + private List refFields; + + @Schema(description = "联动配置(外层普通字段)") + private List linkFields; + + @Schema(description = "子表格联动配置") + private List childLinkFields; + + @Schema(description = "数据列表显示字段") + private List listDisplayFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DatasourceMultipleField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DatasourceMultipleField.java new file mode 100644 index 0000000..7602056 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DatasourceMultipleField.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.system.dto.field; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * @author song-cc-rock + */ +@Data +@JsonTypeName(value = "DATA_SOURCE_MULTIPLE") +@EqualsAndHashCode(callSuper = true) +public class DatasourceMultipleField extends DatasourceField { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DateTimeField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DateTimeField.java new file mode 100644 index 0000000..f15ac47 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DateTimeField.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@JsonTypeName(value = "DATE_TIME") +@EqualsAndHashCode(callSuper = true) +public class DateTimeField extends BaseField { + + @Schema(description = "日期类型|格式", allowableValues = {"month", "date", "datetime"}) + private String dateType; + + @Schema(description = "默认值类型", allowableValues = {"custom", "current"}) + private String dateDefaultType; + + @Schema(description = "默认值(自定义)") + private Long defaultValue; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DepartmentField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DepartmentField.java new file mode 100644 index 0000000..1b9f472 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DepartmentField.java @@ -0,0 +1,24 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.domain.Department; +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +@Data +@JsonTypeName(value = "DEPARTMENT") +@EqualsAndHashCode(callSuper = true) +public class DepartmentField extends BaseField { + @Schema(description = "默认值") + private String defaultValue; + + @Schema(description = "是否当前部门") + private Boolean hasCurrentUserDept; + + @Schema(description = "默认值初始化选项") + private List initialOptions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DepartmentMultipleField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DepartmentMultipleField.java new file mode 100644 index 0000000..a29b1b5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DepartmentMultipleField.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.domain.Department; +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.ArrayList; +import java.util.List; + +@Data +@JsonTypeName(value = "DEPARTMENT_MULTIPLE") +@EqualsAndHashCode(callSuper = true) +public class DepartmentMultipleField extends BaseField { + @Schema(description = "默认值") + private List defaultValue = new ArrayList<>(); + + @Schema(description = "是否当前部门") + private Boolean hasCurrentUserDept; + + @Schema(description = "默认值初始化选项") + private List initialOptions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DividerField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DividerField.java new file mode 100644 index 0000000..d8bdd9f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/DividerField.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@JsonTypeName(value = "DIVIDER") +@EqualsAndHashCode(callSuper = true) +public class DividerField extends BaseField { + + @Schema(description = "分隔线样式") + private String dividerClass; + @Schema(description = "分隔线颜色") + private String dividerColor; + @Schema(description = "标题颜色") + private String titleColor; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/FormulaField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/FormulaField.java new file mode 100644 index 0000000..74cc239 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/FormulaField.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 公式字段 + * @author song-cc-rock + */ +@Data +@JsonTypeName(value = "FORMULA") +@EqualsAndHashCode(callSuper = true) +public class FormulaField extends BaseField { + + @Schema(description = "公式") + private String formula; + + @Schema(description = "显示千分位") + private Boolean showThousandsSeparator; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/IndustryField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/IndustryField.java new file mode 100644 index 0000000..3026c2b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/IndustryField.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * @author song-cc-rock + */ +@Data +@JsonTypeName(value = "INDUSTRY") +@EqualsAndHashCode(callSuper = true) +public class IndustryField extends BaseField { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/InputField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/InputField.java new file mode 100644 index 0000000..95456b0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/InputField.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@JsonTypeName(value = "INPUT") +@EqualsAndHashCode(callSuper = true) +public class InputField extends BaseField { + + @Schema(description = "默认值") + private String defaultValue; + + @Schema(description = "默认值类型", allowableValues = {"custom", "formula"}) + private String defaultValueType; + + @Schema(description = "公式") + private String formula; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/InputMultipleField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/InputMultipleField.java new file mode 100644 index 0000000..bd3f4ad --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/InputMultipleField.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@JsonTypeName(value = "INPUT_MULTIPLE") +@EqualsAndHashCode(callSuper = true) +public class InputMultipleField extends BaseField { + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/InputNumberField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/InputNumberField.java new file mode 100644 index 0000000..23c3066 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/InputNumberField.java @@ -0,0 +1,83 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.DecimalFormat; + +@Data +@JsonTypeName(value = "INPUT_NUMBER") +@EqualsAndHashCode(callSuper = true) +public class InputNumberField extends BaseField { + + @Schema(description = "默认值") + private Double defaultValue; + + @Schema(description = "最小值范围") + private Integer min; + + @Schema(description = "最大值范围") + private Integer max; + + @Schema(description = "格式", allowableValues = {"percent", "number", "currency"}) + private String numberFormat; + + @Schema(description = "保留小数点位数") + private Boolean decimalPlaces; + + @Schema(description = "位数") + private int precision; + + @Schema(description = "显示千分位") + private Boolean showThousandsSeparator; + + public static String formatThousands(BigDecimal num) { + if (num == null) { + return null; + } + String plain = num.toPlainString(); + int dotIndex = plain.indexOf('.'); + String integerPart; + String decimalPart = ""; + if (dotIndex >= 0) { + integerPart = plain.substring(0, dotIndex); + decimalPart = plain.substring(dotIndex); + } else { + integerPart = plain; + } + DecimalFormat df = new DecimalFormat("#,##0"); + return df.format(new BigDecimal(integerPart)) + decimalPart; + } + + /** + * 格式化数字 + * @param num 数字 + * @param precision 小数点位数 + * @param showThousandsSeparator 千分位 + * @param percent 百分比 + * @return 格式化后的字符串 + */ + public static String formatNumber(BigDecimal num, int precision, boolean showThousandsSeparator, boolean percent) { + if (num == null) { + return null; + } + // 小数点位数 + if (precision > 0) { + num = num.setScale(precision, RoundingMode.HALF_UP); + } + // 千分位 + String formatActualVal; + if (showThousandsSeparator) { + formatActualVal = formatThousands(num); + } else { + formatActualVal = num.toPlainString(); + } + // 百分比 + return formatActualVal + (percent ? "%" : ""); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/LinkField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/LinkField.java new file mode 100644 index 0000000..c993296 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/LinkField.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.system.dto.field; + + +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@JsonTypeName(value = "LINK") +@EqualsAndHashCode(callSuper = true) +public class LinkField extends BaseField { + + @Schema(description = "链接来源") + private String linkSource; + + @Schema(description = "打开方式") + private String openMode; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/LocationField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/LocationField.java new file mode 100644 index 0000000..ebeb35f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/LocationField.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.crm.system.constants.LocationScope; +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@JsonTypeName(value = "LOCATION") +@EqualsAndHashCode(callSuper = true) +public class LocationField extends BaseField { + + @Schema(description = "地址范围") + @EnumValue(enumClass = LocationScope.class) + private String scope; + + @Schema(description = "地址类型|格式") + private String locationType; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/MemberField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/MemberField.java new file mode 100644 index 0000000..7b3faf5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/MemberField.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +@Data +@JsonTypeName(value = "MEMBER") +@EqualsAndHashCode(callSuper = true) +public class MemberField extends BaseField { + + @Schema(description = "默认值") + private String defaultValue; + + @Schema(description = "是否当前用户") + private Boolean hasCurrentUser; + + @Schema(description = "默认值初始化选项") + private List initialOptions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/MemberMultipleField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/MemberMultipleField.java new file mode 100644 index 0000000..b03aa77 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/MemberMultipleField.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.ArrayList; +import java.util.List; + +@Data +@JsonTypeName(value = "MEMBER_MULTIPLE") +@EqualsAndHashCode(callSuper = true) +public class MemberMultipleField extends BaseField { + + @Schema(description = "默认值") + private List defaultValue = new ArrayList<>(); + + @Schema(description = "是否当前用户") + private Boolean hasCurrentUser; + + @Schema(description = "默认值初始化选项") + private List initialOptions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/PhoneField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/PhoneField.java new file mode 100644 index 0000000..3c03364 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/PhoneField.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@JsonTypeName(value = "PHONE") +@EqualsAndHashCode(callSuper = true) +public class PhoneField extends BaseField { + + @Schema(description = "格式") + private String format; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/PictureField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/PictureField.java new file mode 100644 index 0000000..c4099fe --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/PictureField.java @@ -0,0 +1,24 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@JsonTypeName(value = "PICTURE") +@EqualsAndHashCode(callSuper = true) +public class PictureField extends BaseField { + + @Schema(description = "图片显示", allowableValues = {"card", "list"}) + private String pictureShowType; + @Schema(description = "图片上传数量限制") + private Boolean uploadLimitEnable; + @Schema(description = "数量") + private Long uploadLimit; + @Schema(description = "设置单个图片大小限制") + private Boolean uploadSizeLimitEnable; + @Schema(description = "单个图片大小") + private Long uploadSizeLimit; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/PriceSubField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/PriceSubField.java new file mode 100644 index 0000000..69a8b71 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/PriceSubField.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.SubField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@JsonTypeName("SUB_PRICE") +@EqualsAndHashCode(callSuper = true) +public class PriceSubField extends SubField { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/ProductSubField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/ProductSubField.java new file mode 100644 index 0000000..4f78686 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/ProductSubField.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.SubField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 产品子表字段 + * @author song-cc-rock + */ +@Data +@JsonTypeName(value = "SUB_PRODUCT") +@EqualsAndHashCode(callSuper = true) +public class ProductSubField extends SubField { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/RadioField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/RadioField.java new file mode 100644 index 0000000..6bf6c37 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/RadioField.java @@ -0,0 +1,43 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.HasOption; +import cn.cordys.crm.system.dto.field.base.LinkProp; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + + +@Data +@JsonTypeName(value = "RADIO") +@EqualsAndHashCode(callSuper = true) +public class RadioField extends BaseField implements HasOption { + + @Schema(description = "选项值") + private List options; + + @Schema(description = "自定义选项值") + private List customOptions; + + @Schema(description = "选项来源", allowableValues = {"custom", "ref"}) + private String optionSource; + + @Schema(description = "选项引用ID (optionSource=ref 时生效)") + private String refId; + + @Schema(description = "引用表单key (optionSource=ref 时生效)") + private String refFormKey; + + @Schema(description = "默认值") + private String defaultValue; + + @Schema(description = "分布方式", allowableValues = {"horizontal", "vertical"}) + private String direction; + + @Schema(description = "联动属性") + private LinkProp linkProp; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/SelectField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/SelectField.java new file mode 100644 index 0000000..2c87ef6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/SelectField.java @@ -0,0 +1,42 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.HasOption; +import cn.cordys.crm.system.dto.field.base.LinkProp; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * @author song-cc-rock + */ +@Data +@JsonTypeName(value = "SELECT") +@EqualsAndHashCode(callSuper = true) +public class SelectField extends BaseField implements HasOption { + + @Schema(description = "默认值") + private String defaultValue; + + @Schema(description = "选项值") + private List options; + + @Schema(description = "自定义选项值") + private List customOptions; + + @Schema(description = "选项来源", allowableValues = {"custom", "ref"}) + private String optionSource; + + @Schema(description = "选项引用ID (optionSource=ref 时生效)") + private String refId; + + @Schema(description = "引用表单key (optionSource=ref 时生效)") + private String refFormKey; + + @Schema(description = "联动属性") + private LinkProp linkProp; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/SelectMultipleField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/SelectMultipleField.java new file mode 100644 index 0000000..6df6520 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/SelectMultipleField.java @@ -0,0 +1,39 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.HasOption; +import cn.cordys.crm.system.dto.field.base.LinkProp; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +@Data +@JsonTypeName(value = "SELECT_MULTIPLE") +@EqualsAndHashCode(callSuper = true) +public class SelectMultipleField extends BaseField implements HasOption { + + @Schema(description = "默认值") + private List defaultValue; + + @Schema(description = "选项值") + private List options; + + @Schema(description = "自定义选项值") + private List customOptions; + + @Schema(description = "选项来源", allowableValues = {"custom", "ref"}) + private String optionSource; + + @Schema(description = "选项引用ID (optionSource=ref 时生效)") + private String refId; + + @Schema(description = "引用表单key (optionSource=ref 时生效)") + private String refFormKey; + + @Schema(description = "联动属性") + private LinkProp linkProp; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/SerialNumberField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/SerialNumberField.java new file mode 100644 index 0000000..5124605 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/SerialNumberField.java @@ -0,0 +1,40 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.List; + +@Data +@JsonTypeName(value = "SERIAL_NUMBER") +@EqualsAndHashCode(callSuper = true) +public class SerialNumberField extends BaseField { + + public static final String FORMULA = "formula"; + + @Schema(description = "流水号规则") + private List serialNumberRules; + + @Schema(description = "前缀固定字符类型", allowableValues = {"custom", "formula"}) + private String prefixType; + + @Schema(description = "公式, prefixType 为 formula") + private String formula; + + /** + * 获取流水号规则 (替换可解析的公式前缀) + * @param formulaPrefix 公式前缀 + * @return 流水号规则 + */ + public List getSerialNumberRules(String formulaPrefix) { + if (Strings.CI.equals(this.prefixType, SerialNumberField.FORMULA) && StringUtils.isNotEmpty(formulaPrefix)) { + this.serialNumberRules.set(0 ,formulaPrefix); + } + return this.serialNumberRules; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/TextAreaField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/TextAreaField.java new file mode 100644 index 0000000..58bd5cb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/TextAreaField.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.system.dto.field; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@JsonTypeName(value = "TEXTAREA") +@EqualsAndHashCode(callSuper = true) +public class TextAreaField extends BaseField { + @Schema(description = "默认值") + private String defaultValue; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/BaseField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/BaseField.java new file mode 100644 index 0000000..e10bc5d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/BaseField.java @@ -0,0 +1,238 @@ +package cn.cordys.crm.system.dto.field.base; + +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.dto.field.*; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * @author song-cc-rock + */ +@Data +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true) +@JsonSubTypes({ + @JsonSubTypes.Type(value = InputField.class, name = "INPUT"), + @JsonSubTypes.Type(value = TextAreaField.class, name = "TEXTAREA"), + @JsonSubTypes.Type(value = InputNumberField.class, name = "INPUT_NUMBER"), + @JsonSubTypes.Type(value = DateTimeField.class, name = "DATE_TIME"), + @JsonSubTypes.Type(value = RadioField.class, name = "RADIO"), + @JsonSubTypes.Type(value = CheckBoxField.class, name = "CHECKBOX"), + @JsonSubTypes.Type(value = SelectField.class, name = "SELECT"), + @JsonSubTypes.Type(value = SelectMultipleField.class, name = "SELECT_MULTIPLE"), + @JsonSubTypes.Type(value = InputMultipleField.class, name = "INPUT_MULTIPLE"), + @JsonSubTypes.Type(value = MemberField.class, name = "MEMBER"), + @JsonSubTypes.Type(value = MemberMultipleField.class, name = "MEMBER_MULTIPLE"), + @JsonSubTypes.Type(value = DepartmentField.class, name = "DEPARTMENT"), + @JsonSubTypes.Type(value = DepartmentMultipleField.class, name = "DEPARTMENT_MULTIPLE"), + @JsonSubTypes.Type(value = DividerField.class, name = "DIVIDER"), + @JsonSubTypes.Type(value = PictureField.class, name = "PICTURE"), + @JsonSubTypes.Type(value = LocationField.class, name = "LOCATION"), + @JsonSubTypes.Type(value = PhoneField.class, name = "PHONE"), + @JsonSubTypes.Type(value = DatasourceField.class, name = "DATA_SOURCE"), + @JsonSubTypes.Type(value = DatasourceMultipleField.class, name = "DATA_SOURCE_MULTIPLE"), + @JsonSubTypes.Type(value = SerialNumberField.class, name = "SERIAL_NUMBER"), + @JsonSubTypes.Type(value = AttachmentField.class, name = "ATTACHMENT"), + @JsonSubTypes.Type(value = LinkField.class, name = "LINK"), + @JsonSubTypes.Type(value = IndustryField.class, name = "INDUSTRY"), + @JsonSubTypes.Type(value = ProductSubField.class, name = "SUB_PRODUCT"), + @JsonSubTypes.Type(value = PriceSubField.class, name = "SUB_PRICE"), + @JsonSubTypes.Type(value = FormulaField.class, name = "FORMULA"), +}) +public abstract class BaseField { + + @Schema(description = "ID") + private String id; + + @Schema(description = "名称") + private String name; + + @Schema(description = "字段内置Key") + private String internalKey; + + @Schema(description = "排序") + private Long pos; + + @Schema(description = "类型") + private String type; + + @Schema(description = "是否移动端") + private Boolean mobile; + + @Schema(description = "是否展示标题") + private Boolean showLabel; + + @Schema(description = "占位文本") + private String placeholder; + + @Schema(description = "描述") + private String description; + + @Schema(description = "是否可读") + private Boolean readable; + + @Schema(description = "是否可编辑") + private Boolean editable; + + @Schema(description = "字段宽度") + private Float fieldWidth; + + @Schema(description = "校验规则详情") + private List rules = new ArrayList<>(); + + @Schema(description = "显隐规则") + private List showControlRules; + + @Schema(description = "业务模块字段(定义在主表中,有特定业务含义)") + private String businessKey; + + @Schema(description = "禁止修改的参数") + private Set disabledProps; + + /** + * 引用字段特有属性 + */ + @Schema(description = "引用字段关联的数据源ID") + private String resourceFieldId; + + /** + * 子表格字段特有属性 + */ + @Schema(description = "该字段所属子表格ID, 仅子表格字段使用, 用来区分该字段是外层字段还是子表格字段") + private String subTableFieldId; + + /** + * 是否系统字段 (后端) + */ + private boolean isSys; + + @JsonIgnore + public static boolean isBlob(String type) { + return Strings.CS.equalsAny(type, FieldType.TEXTAREA.name(), FieldType.INPUT_MULTIPLE.name(), + FieldType.MEMBER_MULTIPLE.name(), FieldType.DEPARTMENT_MULTIPLE.name(), FieldType.SELECT_MULTIPLE.name(), FieldType.CHECKBOX.name(), + FieldType.DATA_SOURCE_MULTIPLE.name(), FieldType.ATTACHMENT.name(), FieldType.LINK.name()); + } + + @JsonIgnore + public boolean isBlob() { + return isBlob(this.type); + } + + @JsonIgnore + public boolean isAttachment() { + return Strings.CS.equalsAny(type, FieldType.PICTURE.name(), FieldType.ATTACHMENT.name()); + } + + @JsonIgnore + public boolean isSerialNumber() { + return Strings.CS.equals(type, FieldType.SERIAL_NUMBER.name()); + } + + @JsonIgnore + public boolean includeFormula() { + if (Strings.CS.equals(type, FieldType.INPUT.name())) { + return StringUtils.isNotEmpty(((InputField) this).getFormula()); + } + if (Strings.CS.equals(type, FieldType.FORMULA.name())) { + return StringUtils.isNotEmpty(((FormulaField) this).getFormula()); + } + return false; + } + + @JsonIgnore + public boolean isSubField() { + return Strings.CS.equalsAny(type, FieldType.SUB_PRICE.name(), FieldType.SUB_PRODUCT.name()); + } + + @JsonIgnore + public boolean needRepeatCheck() { + return Strings.CS.equalsAny(type, FieldType.INPUT.name(), FieldType.PHONE.name()) + && rules.stream().anyMatch(rule -> Strings.CS.equals(rule.getKey(), "unique")); + } + + @JsonIgnore + public boolean needInitialOptions() { + return Strings.CS.equalsAny(type, FieldType.MEMBER.name(), FieldType.DEPARTMENT.name()); + } + + @JsonIgnore + public boolean hasSingleOptions() { + return Strings.CS.equalsAny(type, FieldType.RADIO.name(), FieldType.CHECKBOX.name(), FieldType.SELECT.name(), + FieldType.DATA_SOURCE.name(), FieldType.MEMBER.name(), FieldType.DEPARTMENT.name()); + } + + @JsonIgnore + public boolean hasOptions() { + return hasSingleOptions() || Strings.CS.equalsAny(type, FieldType.SELECT_MULTIPLE.name(), + FieldType.MEMBER_MULTIPLE.name(), FieldType.DEPARTMENT_MULTIPLE.name(), + FieldType.DATA_SOURCE_MULTIPLE.name()); + } + + @JsonIgnore + public boolean canImport() { + // 序列号、附件、图片、分割线, 计算 不支持导入. + return !Strings.CS.equalsAny(type, FieldType.SERIAL_NUMBER.name()) && !Strings.CS.equalsAny(type, FieldType.ATTACHMENT.name()) + && !Strings.CS.equalsAny(type, FieldType.PICTURE.name()) && !Strings.CS.equalsAny(type, FieldType.DIVIDER.name()) + && !Strings.CS.equals(type, FieldType.FORMULA.name()) && readable; + } + + @JsonIgnore + public boolean canExport() { + // 序列号、附件、图片、分割线, 计算 不支持导入. + return !Strings.CS.equalsAny(type, FieldType.SERIAL_NUMBER.name()) && !Strings.CS.equalsAny(type, FieldType.ATTACHMENT.name()) + && !Strings.CS.equalsAny(type, FieldType.PICTURE.name()) && !Strings.CS.equalsAny(type, FieldType.DIVIDER.name()) && readable; + } + + @JsonIgnore + public boolean canDisplay() { + // 公式、附件、图片、分割线, 子表格, 不可见字段等这些, 不支持在子列表等场景展示. + return !Strings.CS.equalsAny(type, FieldType.ATTACHMENT.name()) + && !Strings.CS.equalsAny(type, FieldType.SUB_PRICE.name()) && !Strings.CS.equalsAny(type, FieldType.SUB_PRODUCT.name()) + && !Strings.CS.equalsAny(type, FieldType.PICTURE.name()) && !Strings.CS.equalsAny(type, FieldType.DIVIDER.name()) && readable; + } + + @JsonIgnore + public boolean needRequireCheck() { + return rules.stream().anyMatch(rule -> Strings.CS.equals(rule.getKey(), "required")); + } + + @JsonIgnore + public boolean multiple() { + return Strings.CS.equalsAny(type, FieldType.CHECKBOX.name(), FieldType.SELECT_MULTIPLE.name(), + FieldType.INPUT_MULTIPLE.name(), FieldType.MEMBER_MULTIPLE.name(), FieldType.DEPARTMENT_MULTIPLE.name(), FieldType.DATA_SOURCE_MULTIPLE.name(), FieldType.ATTACHMENT.name()); + } + + @JsonIgnore + public boolean needComment() { + return Strings.CS.equalsAny(type, FieldType.RADIO.name(), FieldType.CHECKBOX.name(), FieldType.SELECT.name(), FieldType.SELECT_MULTIPLE.name(), + FieldType.LOCATION.name(), FieldType.INPUT_NUMBER.name(), FieldType.DATE_TIME.name(), FieldType.INDUSTRY.name()); + } + + @JsonIgnore + public boolean isLocation() { + return Strings.CS.equals(type, FieldType.LOCATION.name()); + } + + @JsonIgnore + public boolean isIndustry() { + return Strings.CS.equals(type, FieldType.INDUSTRY.name()); + } + + @JsonIgnore + public String idOrBusinessKey() { + return hasBusinessKey() ? this.getBusinessKey() : this.getId(); + } + + @JsonIgnore + public boolean hasBusinessKey() { + return StringUtils.isNotBlank(this.getBusinessKey()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/ControlRuleProp.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/ControlRuleProp.java new file mode 100644 index 0000000..d2132db --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/ControlRuleProp.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.system.dto.field.base; + +import lombok.Data; + +import java.util.List; + +@Data +public class ControlRuleProp { + + /** + * 选项值ID + */ + private String value; + /** + * 字段显示ID集合 + */ + private List fieldIds; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/HasOption.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/HasOption.java new file mode 100644 index 0000000..a85ef89 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/HasOption.java @@ -0,0 +1,54 @@ +package cn.cordys.crm.system.dto.field.base; + +import java.util.List; + +/** + * 选项字段通用接口 (通用属性) + * @author song-cc-rock + */ +public interface HasOption { + + /** + * 获取选项集合 + * + * @return 选项集合 + */ + List getOptions(); + + /** + * 设置选项 + * @param options 选项 + */ + void setOptions(List options); + + /** + * 获取选项来源 + * @return 选项来源 + */ + String getOptionSource(); + + /** + * 设置选项来源 + * @param optionSource 选项来源 + */ + void setOptionSource(String optionSource); + + /** + * 设置自定义选项 + * @param customOptions 自定义选项 + */ + void setCustomOptions(List customOptions); + + /** + * 获取自定义选项集合 + * + * @return 选项集合 + */ + List getCustomOptions(); + + /** + * 获取选项引用ID + * @return 选项引用ID + */ + String getRefId(); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/LinkProp.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/LinkProp.java new file mode 100644 index 0000000..82e3ae8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/LinkProp.java @@ -0,0 +1,35 @@ +package cn.cordys.crm.system.dto.field.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +public class LinkProp implements Serializable { + + @NotEmpty + @Schema(description = "联动字段", requiredMode = Schema.RequiredMode.REQUIRED) + private String targetField; + @Schema(description = "联动选项配置") + private List linkOptions; + + @Data + static class LinkOption { + + @NotBlank + @Schema(description = "当前字段选项", requiredMode = Schema.RequiredMode.REQUIRED) + private Object current; + + @NotEmpty + @Schema(description = "联动方式", requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"AUTO", "HIDDEN"}) + private String method; + + @NotBlank + @Schema(description = "联动字段选项", requiredMode = Schema.RequiredMode.REQUIRED) + private Object target; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/OptionProp.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/OptionProp.java new file mode 100644 index 0000000..2fbe8d7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/OptionProp.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.system.dto.field.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class OptionProp { + + @Schema(description = "值") + private String value; + @Schema(description = "文本") + private String label; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/RuleProp.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/RuleProp.java new file mode 100644 index 0000000..30eaa84 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/RuleProp.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.system.dto.field.base; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RuleProp { + + private String key; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/SimpleField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/SimpleField.java new file mode 100644 index 0000000..d18165b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/SimpleField.java @@ -0,0 +1,43 @@ +package cn.cordys.crm.system.dto.field.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * Mcp表单字段描述信息 + */ +@Data +public class SimpleField { + + @Schema(description = "ID") + private String id; + + @Schema(description = "名称") + private String name; + + @Schema(description = "类型") + private String type; + + @Schema(description = "业务字段名") + private String businessKey; + + @Schema(description = "选项枚举值") + private List options; + + @Schema(description = "是否必填") + private boolean required; + + @Schema(description = "数据源类型") + private String dataSourceType; + + @Schema(description = "地址类型|格式") + private String locationType; + + @Schema(description = "日期类型|格式", allowableValues = {"month", "date", "datetime"}) + private String dateType; + + @Schema(description = "显隐规则") + private List showControlRules; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/SubField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/SubField.java new file mode 100644 index 0000000..27dbdd9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/field/base/SubField.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.system.dto.field.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * 通用子表格字段(兼容子表格) + * @author song-cc-rock + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class SubField extends BaseField { + + @Schema(description = "固定宽度") + private Integer fixedColumn; + + @Schema(description = "表格子字段") + private List subFields; + + @Schema(description = "汇总列") + private List sumColumns; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/FormLinkFill.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/FormLinkFill.java new file mode 100644 index 0000000..5f00203 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/FormLinkFill.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.system.dto.form; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FormLinkFill { + + /** + * 主表实体 + */ + private T entity; + /** + * 自定义字段属性值 + */ + private List fields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/FormProp.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/FormProp.java new file mode 100644 index 0000000..a6779f4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/FormProp.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.system.dto.form; + +import cn.cordys.crm.system.dto.form.base.BtnContentProp; +import cn.cordys.crm.system.dto.form.base.LinkScenario; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * 表单属性 + */ +@Data +public class FormProp { + + @Schema(description = "表单视图") + private String viewSize; + @Schema(description = "布局") + private int layout; + @Schema(description = "标题位置") + private String labelPos; + @Schema(description = "输入框宽度") + private String inputWidth; + @Schema(description = "操作按钮位置") + private String optBtnPos; + @Schema(description = "操作按钮内容") + private List optBtnContent; + @Schema(description = "联动配置") + private Map> linkProp; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/IndustryDict.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/IndustryDict.java new file mode 100644 index 0000000..df6dc13 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/IndustryDict.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.system.dto.form; + +import lombok.Data; + +import java.util.List; + +/** + * @author song-cc-rock + */ +@Data +public class IndustryDict { + + private String label; + private String value; + private List children; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/base/BtnContentProp.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/base/BtnContentProp.java new file mode 100644 index 0000000..3544acd --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/base/BtnContentProp.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.system.dto.form.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class BtnContentProp { + + @Schema(description = "文本") + private String text; + @Schema(description = "是否开启") + private Boolean enable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/base/LinkField.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/base/LinkField.java new file mode 100644 index 0000000..24fcc8a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/base/LinkField.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.system.dto.form.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +@Data +public class LinkField { + + @NotEmpty + @Schema(description = "当前字段ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String current; + @NotEmpty + @Schema(description = "联动字段ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String link; + @Schema(description = "是否启用联动") + private boolean enable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/base/LinkScenario.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/base/LinkScenario.java new file mode 100644 index 0000000..3229613 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/base/LinkScenario.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.system.dto.form.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 联动场景 + * @author song-cc-rock + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LinkScenario { + + @NotEmpty + @Schema(description = "场景业务KEY", requiredMode = Schema.RequiredMode.REQUIRED) + private String key; + @Schema(description = "联动字段集合") + private List linkFields; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/base/SourceLink.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/base/SourceLink.java new file mode 100644 index 0000000..401d453 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/form/base/SourceLink.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.system.dto.form.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +/** + * @author song-cc-rock + */ +@Data +public class SourceLink { + + @NotEmpty + @Schema(description = "当前字段ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String current; + + @NotEmpty + @Schema(description = "联动字段ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String link; + + @NotEmpty + @Schema(description = "联动方式", requiredMode = Schema.RequiredMode.REQUIRED, defaultValue = "fill", allowableValues = {"fill"}) + private String method; + + @Schema(description = "是否启用联动配置", defaultValue = "true") + private boolean enable; + + @Schema(description = "子表格联动配置(仅为子表格时需要配置)") + private List childLinks; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/log/AnnouncementLogDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/log/AnnouncementLogDTO.java new file mode 100644 index 0000000..bd4fdbc --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/log/AnnouncementLogDTO.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.system.dto.log; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class AnnouncementLogDTO { + + @Schema(description = "公告标题") + private String subject; + + @Schema(description = "公告内容") + private String content; + + @Schema(description = "开始时间") + private String startTime; + + @Schema(description = "结束时间") + private String endTime; + + @Schema(description = "链接") + private String url; + + @Schema(description = "重命名链接") + private String renameUrl; + + @Schema(description = "接收者") + private List receiver; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/log/DepartmentSetCommanderLog.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/log/DepartmentSetCommanderLog.java new file mode 100644 index 0000000..aca621b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/log/DepartmentSetCommanderLog.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.system.dto.log; + +import lombok.Builder; +import lombok.Data; + +/** + * @Author: jianxing + * @CreateTime: 2025-01-22 14:55 + */ +@Data +@Builder +public class DepartmentSetCommanderLog { + /** + * 部门负责人 + */ + private String commander; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/log/LicenseLogDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/log/LicenseLogDTO.java new file mode 100644 index 0000000..91bc52f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/log/LicenseLogDTO.java @@ -0,0 +1,9 @@ +package cn.cordys.crm.system.dto.log; + +import cn.cordys.common.domain.BaseModel; +import lombok.Data; + +@Data +public class LicenseLogDTO extends BaseModel { + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/log/MessageTaskLogDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/log/MessageTaskLogDTO.java new file mode 100644 index 0000000..617a540 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/log/MessageTaskLogDTO.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.system.dto.log; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class MessageTaskLogDTO { + + @Schema(description = "通知事件类型") + private String event; + + @Schema(description = "邮件发送开关") + private String emailEnable; + + @Schema(description = "系统发送启用") + private String sysEnable; + + @Schema(description = "企业微信发送启用") + private String weComEnable; + + @Schema(description = "钉钉发送启用") + private String dingTalkEnable; + + @Schema(description = "飞书发送启用") + private String larkEnable; + + @Schema(description = "是否通知负责人") + private String ownerEnable; + + @Schema(description = "负责人层级") + private int ownerLevel; + + @Schema(description = "是否通知角色") + private String roleEnable; + + @Schema(description = "通知人员ids,包含特殊值,负责人OWNER/创建人CREATE_USER") + private List userIdNames; + + @Schema(description = "通知角色ids") + private List roleIdNames; + + @Schema(description = "时间") + private List times; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/log/RoleLogDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/log/RoleLogDTO.java new file mode 100644 index 0000000..a2c66a6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/log/RoleLogDTO.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.system.dto.log; + +import cn.cordys.crm.system.domain.Role; +import lombok.Data; + +import java.util.List; + +@Data +public class RoleLogDTO extends Role { + + /** + * 指定部门列表 + */ + private List deptIds; + + /** + * 权限列表 + */ + private List permissions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/regioncode/RegionCode.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/regioncode/RegionCode.java new file mode 100644 index 0000000..8fed6ca --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/regioncode/RegionCode.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.system.dto.regioncode; + +import lombok.Data; + +import java.util.List; + +@Data +public class RegionCode { + + private String code; + private String name; + private List children; + +} + diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/AnnouncementPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/AnnouncementPageRequest.java new file mode 100644 index 0000000..3e7ef56 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/AnnouncementPageRequest.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class AnnouncementPageRequest extends BasePageRequest { + @Size(min = 1, max = 32, message = "{announcement.organizationId.length_range}", groups = {Created.class, Updated.class}) + @NotBlank(message = "{announcement.organizationId.not_blank}", groups = {Created.class, Updated.class}) + private String organizationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/AnnouncementRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/AnnouncementRequest.java new file mode 100644 index 0000000..f69838e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/AnnouncementRequest.java @@ -0,0 +1,54 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class AnnouncementRequest { + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{user.id.not_blank}", groups = {Updated.class}) + @Size(min = 1, max = 32, message = "{user.id.length_range}", groups = {Created.class, Updated.class}) + private String id; + + @Size(min = 1, max = 255, message = "{announcement.subject.length_range}", groups = {Created.class, Updated.class}) + @NotBlank(message = "{announcement.subject.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "公告标题", requiredMode = Schema.RequiredMode.REQUIRED) + private String subject; + + @NotBlank(message = "{announcement.content.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "公告内容", requiredMode = Schema.RequiredMode.REQUIRED) + private String content; + + @NotBlank(message = "{announcement.startTime.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "开始时间", requiredMode = Schema.RequiredMode.REQUIRED) + private Long startTime; + + @NotBlank(message = "{announcement.endTime.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "结束时间", requiredMode = Schema.RequiredMode.REQUIRED) + private Long endTime; + + @Schema(description = "链接") + private String url; + + @Schema(description = "重命名链接") + private String renameUrl; + + @Size(min = 1, max = 32, message = "{announcement.organizationId.length_range}", groups = {Created.class, Updated.class}) + @NotBlank(message = "{announcement.organizationId.not_blank}", groups = {Created.class, Updated.class}) + private String organizationId; + + @Schema(description = "部门ID") + private List deptIds; + + @Schema(description = "用户ID") + private List userIds; + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/AuthSourceRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/AuthSourceRequest.java new file mode 100644 index 0000000..0901665 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/AuthSourceRequest.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class AuthSourceRequest extends BasePageRequest { + + @Schema(description = "认证设置id") + private String configId; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/BatchPoolReasonRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/BatchPoolReasonRequest.java new file mode 100644 index 0000000..cfcabb9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/BatchPoolReasonRequest.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Data +public class BatchPoolReasonRequest { + + @NotNull + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + private List ids; + + @Schema(description = "原因") + private String reasonId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/CapacityAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/CapacityAddRequest.java new file mode 100644 index 0000000..a9fa6c4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/CapacityAddRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.crm.system.dto.FilterConditionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +public class CapacityAddRequest { + + @NotNull + @Schema(description = "范围ID集合") + private List scopeIds; + @Schema(description = "容量") + private Integer capacity; + @Schema(description = "过滤条件集合") + private List filters; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/CapacityUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/CapacityUpdateRequest.java new file mode 100644 index 0000000..8df7545 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/CapacityUpdateRequest.java @@ -0,0 +1,24 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.crm.system.dto.FilterConditionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Data +public class CapacityUpdateRequest { + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + private String id; + @NotNull + @Schema(description = "范围ID集合") + private List scopeIds; + @Schema(description = "容量") + private Integer capacity; + @Schema(description = "过滤条件集合") + private List filters; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/CustomRoleUserRelateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/CustomRoleUserRelateRequest.java new file mode 100644 index 0000000..088e365 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/CustomRoleUserRelateRequest.java @@ -0,0 +1,28 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + * @date 2025-01-13 17:33:23 + */ +@Data +public class CustomRoleUserRelateRequest { + @NotBlank + @Schema(description = "角色ID") + private String customFormRole; + + @Schema(description = "部门ID") + private List deptIds; + + @Schema(description = "角色ID") + private List roleIds; + + @Schema(description = "用户ID") + private List userIds; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DatasourceRefQueryRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DatasourceRefQueryRequest.java new file mode 100644 index 0000000..5cc88f8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DatasourceRefQueryRequest.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +/** + * 数据源引用请求对象 + * @author song-cc-rock + */ +@Data +public class DatasourceRefQueryRequest { + + @NotNull + @Schema(description = "数据源ID集合") + private List sourceIds; + @NotEmpty + @Schema(description = "数据源类型") + private String dataSourceType; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DepartmentAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DepartmentAddRequest.java new file mode 100644 index 0000000..9c9575c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DepartmentAddRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class DepartmentAddRequest { + + @Size(min = 1, max = 255, message = "{department.name.length_range}", groups = {Created.class, Updated.class}) + @NotBlank(message = "{department.name.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + + @Schema(description = "父级id") + @NotBlank(message = "{department.parent.id.not_blank}") + private String parentId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DepartmentCommanderRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DepartmentCommanderRequest.java new file mode 100644 index 0000000..f580b9a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DepartmentCommanderRequest.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class DepartmentCommanderRequest { + + @NotBlank(message = "{department.id.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "部门id", requiredMode = Schema.RequiredMode.REQUIRED) + private String departmentId; + + @NotBlank(message = "{department.commander.id.not_blank}") + @Schema(description = "责任人id", requiredMode = Schema.RequiredMode.REQUIRED) + private String commanderId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DepartmentRenameRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DepartmentRenameRequest.java new file mode 100644 index 0000000..35fa48d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DepartmentRenameRequest.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class DepartmentRenameRequest { + + @NotBlank(message = "{department.id.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Size(min = 1, max = 255, message = "{department.name.length_range}", groups = {Created.class, Updated.class}) + @NotBlank(message = "{department.name.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DictAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DictAddRequest.java new file mode 100644 index 0000000..d2db219 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DictAddRequest.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.system.dto.request; + + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.crm.system.constants.DictModule; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +@Data +public class DictAddRequest { + + @NotEmpty + @Schema(description = "字典值", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @NotEmpty + @Schema(description = "字典模块", requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"OPPORTUNITY_FAIL_RS", "CUSTOMER_POOL_RS", "CLUE_POOL_RS"}) + @EnumValue(enumClass = DictModule.class) + private String module; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DictSortRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DictSortRequest.java new file mode 100644 index 0000000..b4e2259 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DictSortRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class DictSortRequest { + + @NotEmpty + @Schema(description = "字典ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String dragDictId; + + @NotNull + @Schema(description = "排序前", requiredMode = Schema.RequiredMode.REQUIRED) + private Long start; + + @NotNull + @Schema(description = "排序后", requiredMode = Schema.RequiredMode.REQUIRED) + private Long end; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DictSwitchRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DictSwitchRequest.java new file mode 100644 index 0000000..fd6f46b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DictSwitchRequest.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.crm.system.constants.DictModule; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class DictSwitchRequest { + + @NotEmpty + @EnumValue(enumClass = DictModule.class) + @Schema(description = "字典模块", requiredMode = Schema.RequiredMode.REQUIRED) + private String module; + + @NotNull + @Schema(description = "开启关闭", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean enable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DictUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DictUpdateRequest.java new file mode 100644 index 0000000..ce18c39 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/DictUpdateRequest.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +@Data +public class DictUpdateRequest { + + @NotEmpty + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @NotEmpty + @Schema(description = "字典值", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ExportTaskCenterQueryRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ExportTaskCenterQueryRequest.java new file mode 100644 index 0000000..900b6a1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ExportTaskCenterQueryRequest.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * @author song-cc-rock + */ +@Data +public class ExportTaskCenterQueryRequest { + + @Schema(description = "下载名称") + private String keyword; + @Schema(description = "导出类型") + private String exportType; + @Schema(description = "导出状态") + private String exportStatus; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/FieldRepeatCheckRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/FieldRepeatCheckRequest.java new file mode 100644 index 0000000..9089156 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/FieldRepeatCheckRequest.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 字段重复校验参数 + * + * @author song-cc-rock + */ +@Data +public class FieldRepeatCheckRequest { + + @NotBlank + @Schema(description = "字段ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @NotBlank + @Schema(description = "值", requiredMode = Schema.RequiredMode.REQUIRED) + private String value; + + @NotBlank + @Schema(description = "表单Key", requiredMode = Schema.RequiredMode.REQUIRED) + private String formKey; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/FieldResolveRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/FieldResolveRequest.java new file mode 100644 index 0000000..1dc77a1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/FieldResolveRequest.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class FieldResolveRequest { + + @NotBlank + @Schema(description = "字段业务来源", requiredMode = Schema.RequiredMode.REQUIRED) + private String sourceType; + @NotEmpty + @Schema(description = "查询关键字,名称或ID", requiredMode = Schema.RequiredMode.REQUIRED) + private List keywords; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/LocaleLanguageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/LocaleLanguageRequest.java new file mode 100644 index 0000000..ad0f56e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/LocaleLanguageRequest.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +@Data +public class LocaleLanguageRequest { + + @Schema(description = "国际化", requiredMode = Schema.RequiredMode.REQUIRED) + @Pattern(regexp = "(zh-CN)|(en-US)", message = "locale格式不正确") + @NotEmpty + private String language; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/LoginLogRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/LoginLogRequest.java new file mode 100644 index 0000000..03ed1ca --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/LoginLogRequest.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class LoginLogRequest extends BasePageRequest { + + @Schema(description = "操作人") + private String operator; + + @Schema(description = "开始时间") + @NotNull(message = "{start_time_is_null}") + private Long startTime; + + @Schema(description = "结束时间") + @NotNull(message = "{end_time_is_null}") + private Long endTime; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/MessageTaskBatchRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/MessageTaskBatchRequest.java new file mode 100644 index 0000000..107bdc1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/MessageTaskBatchRequest.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.system.dto.request; + + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class MessageTaskBatchRequest { + + @Schema(description = "邮件启用") + private Boolean emailEnable; + + @Schema(description = "系统启用") + private Boolean sysEnable; + + @Schema(description = "企业微信启用") + private Boolean weComEnable; + + @Schema(description = "钉钉启用") + private Boolean dingTalkEnable; + + @Schema(description = "飞书启用") + private Boolean larkEnable; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/MessageTaskConfigRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/MessageTaskConfigRequest.java new file mode 100644 index 0000000..4eedfb1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/MessageTaskConfigRequest.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class MessageTaskConfigRequest { + @Schema(description = "消息配置模块: CUSTOMER/CLUE/BUSINESS/CONTRACT", requiredMode = Schema.RequiredMode.REQUIRED) + private String module; + @Schema(description = "消息配置事件", requiredMode = Schema.RequiredMode.REQUIRED) + private String event; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/MessageTaskRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/MessageTaskRequest.java new file mode 100644 index 0000000..835c084 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/MessageTaskRequest.java @@ -0,0 +1,42 @@ +package cn.cordys.crm.system.dto.request; + + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import cn.cordys.crm.system.dto.MessageTaskConfigDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class MessageTaskRequest { + + @Schema(description = "id") + public String id; + + @Schema(description = "消息配置模块: CUSTOMER/CLUE/BUSINESS", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{message_task.module.not_blank}", groups = {Created.class, Updated.class}) + public String module; + + @Schema(description = "消息配置事件", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{message_task.event.not_blank}", groups = {Created.class, Updated.class}) + public String event; + + @Schema(description = "邮件启用") + private boolean emailEnable; + + @Schema(description = "系统启用") + private boolean sysEnable; + + @Schema(description = "企业微信启用") + private boolean weComEnable; + + @Schema(description = "钉钉启用") + private boolean dingTalkEnable; + + @Schema(description = "飞书启用") + private boolean larkEnable; + + @Schema(description = "消息配置") + private MessageTaskConfigDTO config; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ModuleFormSaveRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ModuleFormSaveRequest.java new file mode 100644 index 0000000..bc93c19 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ModuleFormSaveRequest.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.form.FormProp; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ModuleFormSaveRequest { + + @Size(max = 50) + @NotBlank + @Schema(description = "表单KEY", requiredMode = Schema.RequiredMode.REQUIRED) + private String formKey; + + @Schema(description = "保存字段集合") + private List fields; + + @NotNull + @Schema(description = "表单属性") + private FormProp formProp; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ModuleRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ModuleRequest.java new file mode 100644 index 0000000..7eec691 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ModuleRequest.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class ModuleRequest { + + @Size(min = 1, max = 32, message = "{announcement.organizationId.length_range}", groups = {Created.class, Updated.class}) + @NotBlank(message = "{announcement.organizationId.not_blank}", groups = {Created.class, Updated.class}) + @Schema(description = "组织ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String organizationId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ModuleSortRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ModuleSortRequest.java new file mode 100644 index 0000000..d24f897 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ModuleSortRequest.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class ModuleSortRequest { + + @Schema(description = "排序前", requiredMode = Schema.RequiredMode.REQUIRED) + private Long start; + @Schema(description = "排序后", requiredMode = Schema.RequiredMode.REQUIRED) + private Long end; + @Schema(description = "拖拽模块ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String dragModuleId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ModuleSourceDataRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ModuleSourceDataRequest.java new file mode 100644 index 0000000..d405b58 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ModuleSourceDataRequest.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class ModuleSourceDataRequest extends BasePageRequest { + + @Schema(description = "表单Key") + private String formKey; + @Schema(description = "字段ID") + private String fieldId; + @Schema(description = "数据来源类型", allowableValues = {"CUSTOMER", "CONTACT", "BUSINESS", "PRODUCT"}) + private String sourceType; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/NodeMoveRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/NodeMoveRequest.java new file mode 100644 index 0000000..5a53860 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/NodeMoveRequest.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.crm.system.constants.MoveTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; +import org.apache.commons.lang3.Strings; + +@Data +public class NodeMoveRequest { + @Schema(description = "被拖拽的节点", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "{node.not_blank}") + private String dragNodeId; + + @Schema(description = "放入的节点", requiredMode = Schema.RequiredMode.REQUIRED) + private String dropNodeId; + + @Schema(description = "放入的位置(取值:-1,,1。 -1:dropNodeId节点之前。 1:dropNodeId节点后)", requiredMode = Schema.RequiredMode.REQUIRED) + private int dropPosition; + + + public void setAndConvertDropPosition(String position, boolean isSortDesc) { + if (Strings.CS.equals(MoveTypeEnum.BEFORE.name(), position)) { + this.dropPosition = isSortDesc ? 1 : -1; + } else { + this.dropPosition = isSortDesc ? -1 : 1; + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/NotificationRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/NotificationRequest.java new file mode 100644 index 0000000..f7527e0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/NotificationRequest.java @@ -0,0 +1,40 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class NotificationRequest extends BasePageRequest { + + @Schema(description = "ID") + private Long id; + + @Schema(description = "通知类型") + private String type; + + @Schema(description = "接收人") + private String receiver; + + @Schema(description = "标题") + private String subject; + + @Schema(description = "状态") + private String status; + + @Schema(description = "资源类型") + private String resourceType; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "结束时间") + private Long endTime; + + @Schema(description = "操作人") + private String operator; + + @Schema(description = "操作") + private String operation; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/OperationLogRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/OperationLogRequest.java new file mode 100644 index 0000000..c1b9181 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/OperationLogRequest.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class OperationLogRequest extends BasePageRequest { + + @Schema(description = "操作人") + private String operator; + + @Schema(description = "开始时间") + @NotNull(message = "{start_time_is_null}") + private Long startTime; + + @Schema(description = "结束时间") + @NotNull(message = "{end_time_is_null}") + private Long endTime; + + @Schema(description = "操作类型") + private String type; + + @Schema(description = "操作对象") + private String module; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/OrganizationConfigRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/OrganizationConfigRequest.java new file mode 100644 index 0000000..edf69d6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/OrganizationConfigRequest.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class OrganizationConfigRequest { + + @Schema(description = "详情ID") + private String detailId; + + @Schema(description = "组织ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "组织ID不能为空", groups = {Created.class, Updated.class}) + private String organizationId; + + @Schema(description = "类型", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "类型不能为空", groups = {Created.class, Updated.class}) + private String type; + + @Schema(description = "详情类型", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "详情类型不能为空", groups = {Created.class, Updated.class}) + private String detailType; + + @Schema(description = "内容", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "内容不能为空", groups = {Created.class, Updated.class}) + private String content; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PermissionUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PermissionUpdateRequest.java new file mode 100644 index 0000000..af5d688 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PermissionUpdateRequest.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @Author: jianxing + * @CreateTime: 2025-01-23 16:26 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PermissionUpdateRequest { + @NotBlank + @Schema(description = "权限ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + @Schema(description = "是否启用该权限", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean enable = false; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PersonalInfoRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PersonalInfoRequest.java new file mode 100644 index 0000000..45428d0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PersonalInfoRequest.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class PersonalInfoRequest { + + @Size(max = 11) + @Schema(description = "手机号") + @NotBlank + private String phone; + + @Pattern(regexp = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$", message = "{email.format_error}") + @Schema(description = "邮箱") + @NotBlank + private String email; + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PersonalPasswordRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PersonalPasswordRequest.java new file mode 100644 index 0000000..9c49dc6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PersonalPasswordRequest.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class PersonalPasswordRequest { + @Schema(description = "新密码") + @NotBlank + private String password; + + @Schema(description = "原密码") + @NotBlank + private String originPassword; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PoolBatchAssignRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PoolBatchAssignRequest.java new file mode 100644 index 0000000..8fe55e5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PoolBatchAssignRequest.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class PoolBatchAssignRequest extends PoolBatchRequest { + + @NotBlank + @Schema(description = "分配用户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String assignUserId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PoolBatchPickRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PoolBatchPickRequest.java new file mode 100644 index 0000000..71112b1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PoolBatchPickRequest.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class PoolBatchPickRequest extends PoolBatchRequest { + + @NotBlank + @Schema(description = "公海ID||线索池ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String poolId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PoolBatchRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PoolBatchRequest.java new file mode 100644 index 0000000..60a9976 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PoolBatchRequest.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Data +public class PoolBatchRequest { + + @NotNull + @Schema(description = "批量ID集合") + private List batchIds; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PoolReasonRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PoolReasonRequest.java new file mode 100644 index 0000000..2a440cb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/PoolReasonRequest.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +@Data +public class PoolReasonRequest { + + @NotEmpty + @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "原因") + private String reasonId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RepeatCustomerDetailPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RepeatCustomerDetailPageRequest.java new file mode 100644 index 0000000..827311f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RepeatCustomerDetailPageRequest.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class RepeatCustomerDetailPageRequest extends RepeatCustomerPageRequest { + + @Schema(description = "客户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RepeatCustomerPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RepeatCustomerPageRequest.java new file mode 100644 index 0000000..088d93a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RepeatCustomerPageRequest.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class RepeatCustomerPageRequest extends BasePageRequest { + + @Schema(description = "客户名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ResourceBatchEditRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ResourceBatchEditRequest.java new file mode 100644 index 0000000..212f3ca --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/ResourceBatchEditRequest.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + */ +@Data +public class ResourceBatchEditRequest { + + @NotEmpty + @Schema(description = "ids", requiredMode = Schema.RequiredMode.REQUIRED) + private List ids; + + @NotBlank + @Schema(description = "字段ID或字段key") + private String fieldId; + + @Schema(description = "字段值") + private Object fieldValue; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RoleAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RoleAddRequest.java new file mode 100644 index 0000000..fd5cade --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RoleAddRequest.java @@ -0,0 +1,42 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.common.constants.RoleDataScope; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + + +/** + * 角色 + * + * @author jianxing + * @date 2025-01-03 09:54:59 + */ +@Data +public class RoleAddRequest { + @Size(max = 255) + @NotBlank + @Schema(description = "角色名称") + private String name; + + @Size(max = 30) + @Schema(description = "数据范围(全部数据权限/指定部门权限/本部门数据权限/本部门及以下数据权限/仅本人数据)") + @EnumValue(enumClass = RoleDataScope.class) + private String dataScope; + + @Schema(description = "指定部门权限时,部门的ID") + private List deptIds; + + @Schema(description = "菜单下的权限列表", requiredMode = Schema.RequiredMode.REQUIRED) + @Valid + private List permissions; + + @Size(max = 1000) + @Schema(description = "描述") + private String description; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RoleUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RoleUpdateRequest.java new file mode 100644 index 0000000..f5a6394 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RoleUpdateRequest.java @@ -0,0 +1,45 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.constants.EnumValue; +import cn.cordys.common.constants.RoleDataScope; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + * @date 2025-01-03 10:48:10 + */ +@Data +public class RoleUpdateRequest { + + @NotBlank + @Size(max = 32) + @Schema(description = "id") + private String id; + + @Size(max = 255) + @Schema(description = "角色名称") + private String name; + + @Size(max = 30) + @Schema(description = "数据范围(全部数据权限/指定部门权限/本部门数据权限/本部门及以下数据权限/仅本人数据)") + @EnumValue(enumClass = RoleDataScope.class) + private String dataScope; + + @Schema(description = "指定部门权限时,部门的ID") + private List deptIds; + + @Schema(description = "菜单下的权限列表") + @Valid + private List permissions; + + @Size(max = 1000) + @Schema(description = "描述") + private String description; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RoleUserPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RoleUserPageRequest.java new file mode 100644 index 0000000..282f47d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RoleUserPageRequest.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + + +/** + * @author jianxing + * @date 2025-01-13 17:33:23 + */ +@Data +public class RoleUserPageRequest extends BasePageRequest { + @NotBlank + @Schema(description = "角色ID") + private String roleId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RoleUserRelateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RoleUserRelateRequest.java new file mode 100644 index 0000000..dc286ae --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/RoleUserRelateRequest.java @@ -0,0 +1,28 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + * @date 2025-01-13 17:33:23 + */ +@Data +public class RoleUserRelateRequest { + @NotBlank + @Schema(description = "角色ID") + private String roleId; + + @Schema(description = "部门ID") + private List deptIds; + + @Schema(description = "角色ID") + private List roleIds; + + @Schema(description = "用户ID") + private List userIds; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/SendEmailDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/SendEmailDTO.java new file mode 100644 index 0000000..b8b7932 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/SendEmailDTO.java @@ -0,0 +1,9 @@ +package cn.cordys.crm.system.dto.request; + +import lombok.Data; + +@Data +public class SendEmailDTO { + + private String email; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UploadTransferRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UploadTransferRequest.java new file mode 100644 index 0000000..6a285aa --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UploadTransferRequest.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; + +/** + * @author song-cc-rock + */ +@Data +@AllArgsConstructor +public class UploadTransferRequest { + + @Schema(description = "组织ID") + private String organizationId; + + @Schema(description = "转存文件来源ID") + private String resourceId; + + @Schema(description = "操作人") + private String operatorUserId; + + @Schema(description = "转存文件临时ID") + private List tempFileIds; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserAddRequest.java new file mode 100644 index 0000000..60c469e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserAddRequest.java @@ -0,0 +1,65 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class UserAddRequest { + + @Size(max = 255) + @Schema(description = "姓名") + @NotBlank + private String name; + + @Size(max = 11) + @Schema(description = "手机号") + @NotBlank + private String phone; + + @Schema(description = "性别(false-男/true-女)") + @NotNull + private Boolean gender; + + @Pattern(regexp = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$", message = "{email.format_error}") + @Schema(description = "邮箱") + @NotBlank + private String email; + + @Schema(description = "部门id") + @NotBlank + private String departmentId; + + @Schema(description = "工号") + private String employeeId; + + @Schema(description = "员工类型") + private String employeeType; + + @Schema(description = "直属上级") + private String supervisorId; + + @Schema(description = "职位") + private String position; + + @Schema(description = "工作城市") + private String workCity; + + @Schema(description = "角色") + private List roleIds; + + @Schema(description = "用户组") + private List userGroupIds; + + @Schema(description = "是否启用") + private Boolean enable; + + @Schema(description = "入职时间") + private Long onboardingDate; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserBatchEditRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserBatchEditRequest.java new file mode 100644 index 0000000..c9761e2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserBatchEditRequest.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.system.dto.request; + +import lombok.Data; + +@Data +public class UserBatchEditRequest extends UserBatchRequest { + + private String departmentId; + + private String supervisorId; + + private String workCity; + + private String onboardingDate; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserBatchEnableRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserBatchEnableRequest.java new file mode 100644 index 0000000..6d1394a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserBatchEnableRequest.java @@ -0,0 +1,11 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class UserBatchEnableRequest extends UserBatchRequest { + + @Schema(description = "禁用/启用", requiredMode = Schema.RequiredMode.REQUIRED) + boolean enable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserBatchRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserBatchRequest.java new file mode 100644 index 0000000..eba7d4c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserBatchRequest.java @@ -0,0 +1,14 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class UserBatchRequest { + + @Schema(description = "ID集合") + private List ids; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserKeyUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserKeyUpdateRequest.java new file mode 100644 index 0000000..d06a56a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserKeyUpdateRequest.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class UserKeyUpdateRequest { + + @Schema(description = "user_key ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + private String id; + @Schema(description = "是否永久有效", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean forever; + @Schema(description = "到期时间") + private Long expireTime; + @Schema(description = "描述") + private String description; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserPageRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserPageRequest.java new file mode 100644 index 0000000..fe5eceb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserPageRequest.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.system.dto.request; + +import cn.cordys.common.dto.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.List; + + +@Data +public class UserPageRequest extends BasePageRequest { + + @Schema(description = "部门id") + private List<@NotBlank String> departmentIds; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserUpdateName.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserUpdateName.java new file mode 100644 index 0000000..58e3c79 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserUpdateName.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class UserUpdateName { + + @Schema(description = "用户id", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + private String userId; + + @Size(max = 255) + @Schema(description = "姓名") + @NotBlank + private String name; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserUpdateRequest.java new file mode 100644 index 0000000..6bed0fe --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserUpdateRequest.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class UserUpdateRequest extends UserAddRequest { + + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + private String id; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserViewAddRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserViewAddRequest.java new file mode 100644 index 0000000..abcdd9a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserViewAddRequest.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.system.dto.request; + + +import cn.cordys.common.dto.condition.CombineSearch; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class UserViewAddRequest extends CombineSearch { + + @Schema(description = "视图名称") + @NotBlank + private String name; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserViewUpdateRequest.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserViewUpdateRequest.java new file mode 100644 index 0000000..6a89ed7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/request/UserViewUpdateRequest.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.system.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class UserViewUpdateRequest extends UserViewAddRequest { + + @Schema(description = "id") + @NotBlank + private String id; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/AnnouncementDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/AnnouncementDTO.java new file mode 100644 index 0000000..6668f41 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/AnnouncementDTO.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.crm.system.domain.Announcement; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class AnnouncementDTO extends Announcement { + + @Schema(description = "公告内容") + private String contentText; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "操作人") + private String updateUserName; + + @Schema(description = "部门对应关系") + private List deptIdName; + + @Schema(description = "用户对应关系") + private List userIdName; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/BatchAffectReasonResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/BatchAffectReasonResponse.java new file mode 100644 index 0000000..efee356 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/BatchAffectReasonResponse.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.system.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class BatchAffectReasonResponse { + + @Schema(description = "成功数量") + private Integer success; + + @Schema(description = "失败数量") + private Integer fail; + + @Schema(description = "跳过数量") + private Integer skip; + + @Schema(description = "失败原因") + private String errorMessages; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/BatchAffectResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/BatchAffectResponse.java new file mode 100644 index 0000000..d9b8303 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/BatchAffectResponse.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.system.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class BatchAffectResponse { + + @Schema(description = "成功条数") + private Integer success; + @Schema(description = "失败条数") + private Integer fail; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/BatchAffectSkipResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/BatchAffectSkipResponse.java new file mode 100644 index 0000000..0f3a54a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/BatchAffectSkipResponse.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.system.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class BatchAffectSkipResponse { + + @Schema(description = "成功条数") + private Integer success; + @Schema(description = "失败条数") + private Integer fail; + @Schema(description = "跳过数量") + private Integer skip; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/EmailDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/EmailDTO.java new file mode 100644 index 0000000..e14c542 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/EmailDTO.java @@ -0,0 +1,34 @@ +package cn.cordys.crm.system.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class EmailDTO { + //SMTP 主机 + @Schema(description = "SMTP 主机") + @SafeHost(message = "{ip_address.not_allowed}") + private String host; + //SMTP 端口 + @Schema(description = "SMTP 端口") + private String port; + //SMTP账号 + @Schema(description = "SMTP账号") + private String account; + //SMTP密码 + @Schema(description = "SMTP密码") + private String password; + //指定发件人 + @Schema(description = "指定发件人") + private String from; + //指定收件人 + @Schema(description = "指定收件人") + private String recipient; + //ssl开关 + @Schema(description = "ssl开关") + private String ssl; + //tsl开关 + @Schema(description = "tsl开关") + private String tsl; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/FieldRepeatCheckResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/FieldRepeatCheckResponse.java new file mode 100644 index 0000000..0a7f139 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/FieldRepeatCheckResponse.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.system.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class FieldRepeatCheckResponse { + + @Schema(description = "是否重复") + private boolean repeat; + @Schema(description = "重复名称") + private String name; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/FormPropLogDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/FormPropLogDTO.java new file mode 100644 index 0000000..e88954c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/FormPropLogDTO.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.crm.system.dto.form.base.BtnContentProp; +import cn.cordys.crm.system.dto.form.base.LinkField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * 表单日志属性 + */ +@Data +public class FormPropLogDTO { + + @Schema(description = "表单视图") + private String viewSize; + @Schema(description = "布局") + private int layout; + @Schema(description = "标题位置") + private String labelPos; + @Schema(description = "输入框宽度") + private String inputWidth; + @Schema(description = "操作按钮位置") + private String optBtnPos; + @Schema(description = "操作按钮内容") + private List optBtnContent; + @Schema(description = "联动配置(组合KEY: 业务KEY+场景字段KEY)") + private Map> linkProp; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/ImportResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/ImportResponse.java new file mode 100644 index 0000000..85d6742 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/ImportResponse.java @@ -0,0 +1,25 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.excel.domain.ExcelErrData; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +@Data +@Builder +public class ImportResponse implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "成功数量") + private int successCount; + @Schema(description = "失败数量") + private int failCount; + @Schema(description = "报错信息") + private List errorMessages; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/LoginLogListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/LoginLogListResponse.java new file mode 100644 index 0000000..cf842af --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/LoginLogListResponse.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.crm.system.domain.LoginLog; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class LoginLogListResponse extends LoginLog { + + @Schema(description = "操作人名称") + private String operatorName; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/MessageTaskDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/MessageTaskDTO.java new file mode 100644 index 0000000..d3d67ce --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/MessageTaskDTO.java @@ -0,0 +1,26 @@ +package cn.cordys.crm.system.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = false) +public class MessageTaskDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "消息配置模块") + public String module; + + @Schema(description = "消息配置模块名称") + public String moduleName; + + @Schema(description = "消息配置功能展开内容") + public List messageTaskDetailDTOList; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/MessageTaskDetailDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/MessageTaskDetailDTO.java new file mode 100644 index 0000000..1218f5d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/MessageTaskDetailDTO.java @@ -0,0 +1,39 @@ +package cn.cordys.crm.system.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; + +@Data +@EqualsAndHashCode(callSuper = false) +public class MessageTaskDetailDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "消息配置事件") + public String event; + + @Schema(description = "消息配置事件名称") + public String eventName; + + @Schema(description = "邮件启用") + private Boolean emailEnable; + + @Schema(description = "系统启用") + private Boolean sysEnable; + + @Schema(description = "企业微信启用") + private Boolean weComEnable; + + @Schema(description = "钉钉启用") + private Boolean dingTalkEnable; + + @Schema(description = "飞书启用") + private Boolean larkEnable; + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/ModuleDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/ModuleDTO.java new file mode 100644 index 0000000..fd52830 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/ModuleDTO.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.crm.system.domain.Module; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class ModuleDTO extends Module { + + @Schema(description = "国际化名称") + private String translateName; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/ModuleFormConfigDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/ModuleFormConfigDTO.java new file mode 100644 index 0000000..54e4ae2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/ModuleFormConfigDTO.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.form.FormProp; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class ModuleFormConfigDTO { + + @Schema(description = "字段集合及其属性") + private List fields; + + @Schema(description = "表单属性") + private FormProp formProp; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/ModuleFormConfigLogDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/ModuleFormConfigLogDTO.java new file mode 100644 index 0000000..c8ac44d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/ModuleFormConfigLogDTO.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.crm.system.dto.field.base.BaseField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class ModuleFormConfigLogDTO { + + @Schema(description = "表单名称") + private String name; + + @Schema(description = "字段集合及其属性") + private List fields; + + @Schema(description = "表单属性") + private FormPropLogDTO formProp; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/NotificationDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/NotificationDTO.java new file mode 100644 index 0000000..7369cc0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/NotificationDTO.java @@ -0,0 +1,9 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.crm.system.domain.Notification; +import lombok.Data; + +@Data +public class NotificationDTO extends Notification { + private String contentText; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/OperationLogDetailResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/OperationLogDetailResponse.java new file mode 100644 index 0000000..a372a8d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/OperationLogDetailResponse.java @@ -0,0 +1,39 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.common.dto.JsonDifferenceDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +@Data +public class OperationLogDetailResponse implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "id") + private String id; + + @Schema(description = "操作人") + private String operator; + + @Schema(description = "操作人名称") + private String operatorName; + + @Schema(description = "操作时间") + private Long createTime; + + @Schema(description = "操作对象") + private String module; + + @Schema(description = "操作类型") + private String type; + + @Schema(description = "资源名称") + private String resourceName; + + @Schema(description = "字段差异") + private List diffs = List.of(); +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/OperationLogResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/OperationLogResponse.java new file mode 100644 index 0000000..d9bb694 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/OperationLogResponse.java @@ -0,0 +1,38 @@ +package cn.cordys.crm.system.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +@Data +public class OperationLogResponse implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "id") + private String id; + + @Schema(description = "操作人") + private String operator; + + @Schema(description = "操作人名称") + private String operatorName; + + @Schema(description = "操作时间") + private Long createTime; + + @Schema(description = "操作对象") + private String module; + + @Schema(description = "操作类型") + private String type; + + @Schema(description = "资源名称") + private String resourceName; + + @Schema(description = "日志描述") + private String detail; + +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/OptionScopeDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/OptionScopeDTO.java new file mode 100644 index 0000000..3ead2b3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/OptionScopeDTO.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.common.dto.OptionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class OptionScopeDTO extends OptionDTO { + + @Schema(description = "类型") + private String scope; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/RepeatCustomerResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/RepeatCustomerResponse.java new file mode 100644 index 0000000..e60b665 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/RepeatCustomerResponse.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.crm.clue.dto.response.ClueListResponse; +import cn.cordys.crm.search.response.advanced.AdvancedCustomerResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class RepeatCustomerResponse { + + @Schema(description = "客户重复数据") + private List customerData; + + @Schema(description = "线索重复数据") + private List clueList; + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/RoleGetResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/RoleGetResponse.java new file mode 100644 index 0000000..8a217e5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/RoleGetResponse.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.common.permission.PermissionDefinitionItem; +import cn.cordys.crm.system.domain.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * @author jianxing + * @date 2025-01-10 18:35:02 + */ +@Data +public class RoleGetResponse extends Role { + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; + + @Schema(description = "指定部门权限时,部门的ID") + private List deptIds; + + @Schema(description = "权限配置") + private List permissions; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/RoleListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/RoleListResponse.java new file mode 100644 index 0000000..fe77951 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/RoleListResponse.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.crm.system.domain.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +/** + * @author jianxing + * @date 2025-01-09 16:46:27 + */ +@Data +public class RoleListResponse extends Role { + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "更新人名称") + private String updateUserName; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/RoleUserListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/RoleUserListResponse.java new file mode 100644 index 0000000..ad0a389 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/RoleUserListResponse.java @@ -0,0 +1,45 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.crm.system.dto.convert.UserRoleConvert; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + + +/** + * @author jianxing + * @date 2025-01-13 17:33:23 + */ +@Data +public class RoleUserListResponse { + @Schema(description = "ID") + private String id; + + @Schema(description = "用户ID") + private String userId; + + @Schema(description = "用户名") + private String userName; + + @Schema(description = "头像") + private String avatar; + + @Schema(description = "是否启用") + private Boolean enable; + + @Schema(description = "部门id") + private String departmentId; + + @Schema(description = "部门名称") + private String departmentName; + + @Schema(description = "职位") + private String position; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "角色列表") + private List roles; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/RoleUserOptionResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/RoleUserOptionResponse.java new file mode 100644 index 0000000..d572717 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/RoleUserOptionResponse.java @@ -0,0 +1,23 @@ +package cn.cordys.crm.system.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * @author jianxing + */ +@Data +public class RoleUserOptionResponse { + + @Schema(description = "用户ID") + private String id; + + @Schema(description = "用户名称") + private String name; + + @Schema(description = "头像") + private String avatar; + + @Schema(description = "是否启用") + private Boolean enabled = true; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/SafeHost.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/SafeHost.java new file mode 100644 index 0000000..24e84db --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/SafeHost.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.system.dto.response; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = SafeHostValidator.class) +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface SafeHost { + String message() default "不允许使用内网或保留地址"; + Class[] groups() default {}; + Class[] payload() default {}; +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/SafeHostValidator.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/SafeHostValidator.java new file mode 100644 index 0000000..6a62a6c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/SafeHostValidator.java @@ -0,0 +1,74 @@ +package cn.cordys.crm.system.dto.response; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.List; + +public class SafeHostValidator implements ConstraintValidator { + + private static final List BLOCKED_CIDRS = Arrays.asList( + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "127.0.0.0/8", + "169.254.0.0/16", + "0.0.0.0/8", + "100.64.0.0/10", + "224.0.0.0/4", + "::1/128", + "fe80::/10", + "fc00::/7" + ); + + @Override + public boolean isValid(String host, ConstraintValidatorContext context) { + if (host == null || host.isEmpty()) { + return true; // 由 @NotNull 控制 + } + try { + InetAddress addr = InetAddress.getByName(host); + return !isBlocked(addr); + } catch (UnknownHostException e) { + // 无法解析的域名一律放行?建议拒绝,因为可能被用来绕过(可改为 return false) + return false; + } + } + + private boolean isBlocked(InetAddress addr) { + for (String cidr : BLOCKED_CIDRS) { + if (matchesCIDR(addr, cidr)) { + return true; + } + } + return false; + } + + private boolean matchesCIDR(InetAddress addr, String cidr) { + try { + String[] parts = cidr.split("/"); + InetAddress network = InetAddress.getByName(parts[0]); + int prefix = Integer.parseInt(parts[1]); + byte[] addrBytes = addr.getAddress(); + byte[] networkBytes = network.getAddress(); + if (addrBytes.length != networkBytes.length) return false; + + int fullBytes = prefix / 8; + int remainingBits = prefix % 8; + + for (int i = 0; i < fullBytes; i++) { + if (addrBytes[i] != networkBytes[i]) return false; + } + if (remainingBits > 0 && fullBytes < addrBytes.length) { + int mask = (0xFF << (8 - remainingBits)) & 0xFF; + return (addrBytes[fullBytes] & mask) == (networkBytes[fullBytes] & mask); + } + return true; + } catch (Exception e) { + return false; + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserImportDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserImportDTO.java new file mode 100644 index 0000000..78e290e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserImportDTO.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.system.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +@Data +public class UserImportDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "用户id") + private String userId; + @Schema(description = "部门id") + private String departmentId; + @Schema(description = "姓名") + private String name; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserImportResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserImportResponse.java new file mode 100644 index 0000000..6d95d8c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserImportResponse.java @@ -0,0 +1,27 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.excel.domain.ExcelErrData; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * @author wx + */ +@Data +public class UserImportResponse implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "成功数量") + private int successCount; + @Schema(description = "失败数量") + private int failCount; + @Schema(description = "报错信息") + private List errorMessages; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserPageResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserPageResponse.java new file mode 100644 index 0000000..0fb72d9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserPageResponse.java @@ -0,0 +1,90 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.crm.system.dto.convert.UserRoleConvert; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class UserPageResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "用户ID") + private String userId; + + @Schema(description = "用户名") + private String userName; + + @Schema(description = "是否启用") + private Boolean enable; + + @Schema(description = "性别(0-男/1-女)") + private Boolean gender; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "邮箱") + private String email; + + @Schema(description = "部门id") + private String departmentId; + + @Schema(description = "部门名称") + private String departmentName; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "直属上级") + private String supervisorId; + + @Schema(description = "直属上级名称") + private String supervisorName; + + @Schema(description = "角色列表") + private List roles; + + @Schema(description = "工号") + private String employeeId; + + @Schema(description = "职位") + private String position; + + @Schema(description = "员工类型") + private String employeeType; + + @Schema(description = "工作城市") + private String workCity; + + @Schema(description = "用户组") + private List userGroups; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "创建人名称") + private String createUserName; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "修改人名称") + private String updateUserName; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "是否是负责人") + private Boolean commander; + + @Schema(description = "入职时间") + private Long onboardingDate; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserResponse.java new file mode 100644 index 0000000..605635d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserResponse.java @@ -0,0 +1,65 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.crm.system.dto.convert.UserRoleConvert; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class UserResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "用户ID") + private String userId; + + @Schema(description = "用户名") + private String userName; + + @Schema(description = "是否启用") + private Boolean enable; + + @Schema(description = "性别(0-男/1-女)") + private Boolean gender; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "邮箱") + private String email; + + @Schema(description = "部门id") + private String departmentId; + + @Schema(description = "部门名称") + private String departmentName; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "直属上级") + private String supervisorId; + + @Schema(description = "直属名称") + private String supervisorName; + + @Schema(description = "角色列表") + private List roles; + + @Schema(description = "工号") + private String employeeId; + + @Schema(description = "职位") + private String position; + + @Schema(description = "员工类型") + private String employeeType; + + @Schema(description = "工作城市") + private String workCity; + + @Schema(description = "入职时间") + private Long onboardingDate; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserViewListResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserViewListResponse.java new file mode 100644 index 0000000..77e4532 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserViewListResponse.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.system.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class UserViewListResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "视图名称") + private String name; + + @Schema(description = "是否固定") + private Boolean fixed; + + @Schema(description = "状态") + private Boolean enable; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserViewResponse.java b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserViewResponse.java new file mode 100644 index 0000000..8482096 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/dto/response/UserViewResponse.java @@ -0,0 +1,40 @@ +package cn.cordys.crm.system.dto.response; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.condition.FilterCondition; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class UserViewResponse { + + @Schema(description = "ID") + private String id; + + @Schema(description = "用户ID") + private String userId; + + @Schema(description = "视图名称") + private String name; + + @Schema(description = "资源类型(客户/线索/商机)") + private String resourceType; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "排序") + private Long pos; + + @Schema(description = "匹配模式(AND/OR)") + private String searchMode; + + @Schema(description = "筛选条件") + private List conditions; + + @Schema(description = "用于回显的optionMap") + private Map> optionMap; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/CustomImportAfterDoConsumer.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/CustomImportAfterDoConsumer.java new file mode 100644 index 0000000..f6ec9a5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/CustomImportAfterDoConsumer.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.system.excel; + +import java.util.List; + +/** + * 业务导入后置处理函数 + * @author song-cc-rock + */ +@FunctionalInterface +public interface CustomImportAfterDoConsumer { + + /** + * 处理 + * + * @param dataList 主表数据集合 + * @param fieldList 自定义字段数据集合 + * @param fieldBlobList 自定义Blob字段数据集合 + */ + void accept(List dataList, List fieldList, List fieldBlobList); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/annotation/NotRequired.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/annotation/NotRequired.java new file mode 100644 index 0000000..e3c1789 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/annotation/NotRequired.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.system.excel.annotation; + +import java.lang.annotation.*; + +/** + * @author wx + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface NotRequired { +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/constants/UserImportFiled.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/constants/UserImportFiled.java new file mode 100644 index 0000000..f1e94ed --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/constants/UserImportFiled.java @@ -0,0 +1,50 @@ +package cn.cordys.crm.system.excel.constants; + + +import cn.cordys.crm.system.excel.domain.UserExcelData; +import lombok.Getter; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; + +/** + * @author wx + */ + +public enum UserImportFiled { + + EMPLOYEE_ID("employeeId", "工号", "Employee id", UserExcelData::getEmployeeId), + NAME("name", "姓名", "Name", UserExcelData::getName), + GENDER("gender", "性别", "Gender", UserExcelData::getGender), + DEPARTMENT("department", "部门", "Department", UserExcelData::getDepartment), + POSITION("position", "职位", "Position", UserExcelData::getPosition), + PHONE("phone", "手机号", "Phone", UserExcelData::getPhone), + EMAIL("email", "邮箱", "Email", UserExcelData::getEmail), + SUPERVISOR("supervisor", "直属上级", "Supervisor", UserExcelData::getSupervisor), + WORK_CITY("workCity", "工作城市", "Work City", UserExcelData::getWorkCity), + EMPLOYEE_TYPE("employeeType", "员工类型", "Employee type", UserExcelData::getEmployeeType); + + @Getter + private final Map filedLangMap; + private final Function parseFunc; + @Getter + private final String value; + + UserImportFiled(String value, String zn, String us, Function parseFunc) { + this.filedLangMap = new HashMap<>(); + filedLangMap.put(Locale.SIMPLIFIED_CHINESE, zn); + filedLangMap.put(Locale.US, us); + this.value = value; + this.parseFunc = parseFunc; + } + + public String parseExcelDataValue(UserExcelData excelData) { + return parseFunc.apply(excelData); + } + + public boolean containsHead(String head) { + return filedLangMap.containsValue(head); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/domain/MergeResult.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/domain/MergeResult.java new file mode 100644 index 0000000..7ef63f2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/domain/MergeResult.java @@ -0,0 +1,33 @@ +package cn.cordys.crm.system.excel.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 合并结果 + * @author song-cc-rock + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MergeResult { + + /** + * 数据列表 + */ + private List> dataList; + /** + * 合并的行区域 + */ + private List mergeRegions; + + /** + * 实际合并处理的行数 + */ + private int handleCount; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/domain/UserExcelData.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/domain/UserExcelData.java new file mode 100644 index 0000000..fea1fc8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/domain/UserExcelData.java @@ -0,0 +1,83 @@ +package cn.cordys.crm.system.excel.domain; + +import cn.cordys.crm.system.excel.constants.UserImportFiled; +import cn.idev.excel.annotation.ExcelIgnore; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +/** + * @author wx + */ +@Getter +@Setter +public class UserExcelData { + /** + * 工号 + */ + @ExcelIgnore + private String employeeId; + /** + * 姓名 + */ + @ExcelIgnore + private String name; + /** + * 性别 + */ + @ExcelIgnore + private String gender; + /** + * 部门 + */ + @ExcelIgnore + private String department; + /** + * 职位 + */ + @ExcelIgnore + private String position; + /** + * 手机号 + */ + @ExcelIgnore + private String phone; + /** + * 邮箱 + */ + @ExcelIgnore + private String email; + /** + * 直属上级 + */ + @ExcelIgnore + private String supervisor; + /** + * 工作城市 + */ + @ExcelIgnore + private String workCity; + /** + * 员工类型 + */ + @ExcelIgnore + private String employeeType; + + + public List> getHead() { + return new ArrayList<>(); + } + + public List> getHead(Locale lang) { + List> heads = new ArrayList<>(); + UserImportFiled[] fields = UserImportFiled.values(); + for (UserImportFiled field : fields) { + heads.add(Collections.singletonList(field.getFiledLangMap().get(lang))); + } + return heads; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/domain/UserExcelDataCn.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/domain/UserExcelDataCn.java new file mode 100644 index 0000000..41f078a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/domain/UserExcelDataCn.java @@ -0,0 +1,67 @@ +package cn.cordys.crm.system.excel.domain; + +import cn.cordys.crm.system.excel.annotation.NotRequired; +import cn.idev.excel.annotation.ExcelProperty; +import cn.idev.excel.annotation.write.style.ColumnWidth; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import org.hibernate.validator.constraints.Length; + +import java.util.List; +import java.util.Locale; + +/** + * @author wx + */ +@Data +@ColumnWidth(15) +public class UserExcelDataCn extends UserExcelData { + + @ColumnWidth(50) + @ExcelProperty("工号") + @NotRequired + private String employeeId; + + @NotBlank(message = "{cannot_be_null}") + @Length(max = 255) + @ExcelProperty("姓名") + private String name; + + @ColumnWidth(50) + @ExcelProperty("性别") + private String gender; + + @ExcelProperty("部门") + @ColumnWidth(50) + private String department; + + + @ColumnWidth(50) + @ExcelProperty("职位") + private String position; + + @NotBlank(message = "{cannot_be_null}") + @ColumnWidth(50) + @Length(max = 255) + @ExcelProperty("手机号") + private String phone; + + @NotBlank(message = "{cannot_be_null}") + @ColumnWidth(50) + @ExcelProperty("邮箱") + private String email; + + @ColumnWidth(50) + @ExcelProperty("直属上级") + private String supervisor; + + @ColumnWidth(50) + @ExcelProperty("员工类型") + private String employeeType; + + + @Override + public List> getHead() { + return super.getHead(Locale.SIMPLIFIED_CHINESE); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/domain/UserExcelDataFactory.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/domain/UserExcelDataFactory.java new file mode 100644 index 0000000..5dcda27 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/domain/UserExcelDataFactory.java @@ -0,0 +1,30 @@ +package cn.cordys.crm.system.excel.domain; + +import cn.cordys.excel.domain.ExcelDataFactory; +import org.springframework.context.i18n.LocaleContextHolder; + +import java.util.Locale; + +/** + * @author wx + */ +public class UserExcelDataFactory implements ExcelDataFactory { + + @Override + public Class getExcelDataByLocal() { + Locale locale = LocaleContextHolder.getLocale(); + if (Locale.US.toString().equalsIgnoreCase(locale.toString())) { + return UserExcelDataUs.class; + } + return UserExcelDataCn.class; + } + + public UserExcelData getUserExcelDataLocal() { + Locale locale = LocaleContextHolder.getLocale(); + if (Locale.US.toString().equalsIgnoreCase(locale.toString())) { + return new UserExcelDataUs(); + } + return new UserExcelDataCn(); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/domain/UserExcelDataUs.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/domain/UserExcelDataUs.java new file mode 100644 index 0000000..e1a057c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/domain/UserExcelDataUs.java @@ -0,0 +1,66 @@ +package cn.cordys.crm.system.excel.domain; + +import cn.cordys.crm.system.excel.annotation.NotRequired; +import cn.idev.excel.annotation.ExcelProperty; +import cn.idev.excel.annotation.write.style.ColumnWidth; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import org.hibernate.validator.constraints.Length; + +import java.util.List; +import java.util.Locale; + +/** + * @author wx + */ +@Data +@ColumnWidth(15) +public class UserExcelDataUs extends UserExcelData { + + @ColumnWidth(50) + @ExcelProperty("Employee id") + @NotRequired + private String employeeId; + + @NotBlank(message = "{cannot_be_null}") + @Length(max = 255) + @ExcelProperty("Name") + private String name; + + @ColumnWidth(50) + @ExcelProperty("Gender") + private String gender; + + @ExcelProperty("Department") + @ColumnWidth(30) + private String department; + + + @ColumnWidth(50) + @ExcelProperty("Position") + private String position; + + @NotBlank(message = "{cannot_be_null}") + @ColumnWidth(50) + @Length(max = 255) + @ExcelProperty("Phone") + private String phone; + + @NotBlank(message = "{cannot_be_null}") + @ColumnWidth(50) + @ExcelProperty("Email") + private String email; + + @ColumnWidth(50) + @ExcelProperty("Supervisor") + private String supervisor; + + @ColumnWidth(50) + @ExcelProperty("Employee type") + private String employeeType; + + @Override + public List> getHead() { + return super.getHead(Locale.US); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/handler/CustomHeadColWidthStyleStrategy.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/handler/CustomHeadColWidthStyleStrategy.java new file mode 100644 index 0000000..9da473e --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/handler/CustomHeadColWidthStyleStrategy.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.system.excel.handler; + +import cn.idev.excel.metadata.Head; +import cn.idev.excel.write.style.column.AbstractHeadColumnWidthStyleStrategy; + +/** + * 自定义表头列宽策略 + * @author song-cc-rock + */ +public class CustomHeadColWidthStyleStrategy extends AbstractHeadColumnWidthStyleStrategy { + + @Override + protected Integer columnWidth(Head head, Integer columnIndex) { + String headName = head.getHeadNameList().getFirst(); + int length = getTextWidth(headName); + return Math.min(length * 2 - 1, 50); + } + + /** + * 处理表头宽度 + * @param text 文本 + * @return 宽度 + */ + private int getTextWidth(String text) { + int width = 0; + for (char c : text.toCharArray()) { + width += (c >= 0x4e00 && c <= 0x9fa5) ? 2 : 1; + } + return width; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/handler/CustomTemplateWriteHandler.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/handler/CustomTemplateWriteHandler.java new file mode 100644 index 0000000..2d33f71 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/handler/CustomTemplateWriteHandler.java @@ -0,0 +1,266 @@ +package cn.cordys.crm.system.excel.handler; + +import cn.cordys.common.resolver.field.DateTimeResolver; +import cn.cordys.common.resolver.field.LocationResolver; +import cn.cordys.common.resolver.field.NumberResolver; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.constants.SheetKey; +import cn.cordys.crm.system.dto.field.DateTimeField; +import cn.cordys.crm.system.dto.field.InputNumberField; +import cn.cordys.crm.system.dto.field.LocationField; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.HasOption; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import cn.cordys.crm.system.dto.field.base.SubField; +import cn.idev.excel.metadata.Head; +import cn.idev.excel.metadata.data.WriteCellData; +import cn.idev.excel.util.BooleanUtils; +import cn.idev.excel.write.handler.CellWriteHandler; +import cn.idev.excel.write.handler.RowWriteHandler; +import cn.idev.excel.write.handler.SheetWriteHandler; +import cn.idev.excel.write.handler.context.RowWriteHandlerContext; +import cn.idev.excel.write.metadata.holder.WriteSheetHolder; +import cn.idev.excel.write.metadata.holder.WriteTableHolder; +import cn.idev.excel.write.metadata.holder.WriteWorkbookHolder; +import cn.idev.excel.write.metadata.style.WriteCellStyle; +import cn.idev.excel.write.metadata.style.WriteFont; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.ss.util.CellRangeAddressList; +import org.apache.poi.xssf.usermodel.XSSFClientAnchor; +import org.apache.poi.xssf.usermodel.XSSFRichTextString; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 自定义模板处理器 + * @author song-cc-rock + */ +public class CustomTemplateWriteHandler implements RowWriteHandler, SheetWriteHandler, CellWriteHandler { + + private final Map fieldIndexMap = new HashMap<>(); + private final List requires = new ArrayList<>(); + private final List uniques = new ArrayList<>(); + private final List multiples = new ArrayList<>(); + private final Map> validationOptionMap = new HashMap<>(); + private final int totalColumns; + private Sheet mainSheet; + private Drawing drawingPatriarch; + /** + * 多级表格需要记录偏移位置 + */ + private final List downOffSet = new ArrayList<>(); + private final List centerCells = new ArrayList<>(); + + public static final int MAX_DROPDOWN_LENGTH = 255; + + public CustomTemplateWriteHandler(List fields) { + int index = 0; + List importFields = fields.stream().filter(f -> StringUtils.isEmpty(f.getResourceFieldId()) && f.canImport()).toList(); + for (BaseField field : importFields) { + if (field instanceof SubField subField && CollectionUtils.isNotEmpty(subField.getSubFields())) { + for (BaseField f : subField.getSubFields()) { + if (StringUtils.isEmpty(f.getResourceFieldId()) && f.canImport()) { + downOffSet.add(index); + setExtra(f, index++); + } + } + centerCells.add(subField.getName()); + continue; + } + setExtra(field, index++); + } + totalColumns = index; + } + + private void setExtra(BaseField field, int index) { + fieldIndexMap.put(field, index); + if (field.needRequireCheck()) { + requires.add(field.getName()); + } + if (field.needRepeatCheck()) { + uniques.add(field.getName()); + } + if (field.multiple()) { + multiples.add(field.getName()); + } + if (field instanceof HasOption optionField && CollectionUtils.isNotEmpty(optionField.getOptions())) { + // set options for data validation + validationOptionMap.put(index, optionField.getOptions().stream().map(OptionProp::getLabel).toList()); + } + } + + @Override + public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) { + Sheet sheet = writeSheetHolder.getSheet(); + if (Strings.CS.equals(sheet.getSheetName(), Translator.get(SheetKey.COMMENT))) { + Row row1 = sheet.createRow(0); + row1.setHeightInPoints(80); + Cell cell1 = row1.createCell(0); + cell1.setCellValue(Translator.get("sheet.instruction")); + sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, totalColumns - 1)); + CellStyle style = writeWorkbookHolder.getWorkbook().createCellStyle(); + style.setWrapText(true); + Font font = writeWorkbookHolder.getWorkbook().createFont(); + font.setFontHeightInPoints((short) 12); + style.setFont(font); + cell1.setCellStyle(style); + } + if (Strings.CS.equals(sheet.getSheetName(), Translator.get(SheetKey.DATA))) { + // set data validation + DataValidationHelper dataValidationHelper = sheet.getDataValidationHelper(); + validationOptionMap.forEach((k, v) -> { + List simpleOps = limitCellDropdown(v); + DataValidationConstraint dvc = dataValidationHelper.createExplicitListConstraint(simpleOps.toArray(String[]::new)); + DataValidation dataValidation = dataValidationHelper.createValidation(dvc, new CellRangeAddressList(1, 1048575, k, k)); + sheet.addValidationData(dataValidation); + }); + } + } + + @Override + public void afterRowDispose(RowWriteHandlerContext context) { + Sheet sheet = context.getWriteSheetHolder().getSheet(); + int maxHeadRow = context.getWriteSheetHolder().getExcelWriteHeadProperty().getHeadRowNumber(); + if (BooleanUtils.isTrue(context.getHead()) && context.getRow().getRowNum() == maxHeadRow - 1 + && Strings.CS.equals(sheet.getSheetName(), Translator.get(SheetKey.DATA))) { + mainSheet = sheet; + drawingPatriarch = sheet.createDrawingPatriarch(); + fieldIndexMap.forEach((k, v) -> { + if (k.needComment()) { + setComment(v, buildComment(k)); + } + }); + } + } + + @Override + public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, + List> cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { + // 设置样式 + WriteCellStyle headStyle = new WriteCellStyle(); + WriteFont font = new WriteFont(); + if (isHead) { + if (requires.contains(cell.getStringCellValue())) { + font.setColor(IndexedColors.RED.getIndex()); + } + if (uniques.contains(cell.getStringCellValue())) { + font.setBold(true); + } + if (multiples.contains(cell.getStringCellValue())) { + font.setUnderline(Font.U_SINGLE); + } + if (centerCells.contains(cell.getStringCellValue())) { + headStyle.setHorizontalAlignment(HorizontalAlignment.CENTER); + headStyle.setVerticalAlignment(VerticalAlignment.CENTER); + } + } + font.setFontHeightInPoints((short) 12); + headStyle.setWriteFont(font); + if (CollectionUtils.isNotEmpty(cellDataList)) { + WriteCellData writeCellData = cellDataList.getFirst(); + writeCellData.setWriteCellStyle(headStyle); + } + } + + private void setComment(Integer index, String text) { + if (index == null) { + return; + } + Comment comment = drawingPatriarch.createCellComment(new XSSFClientAnchor(0, 0, 0, 0, index, downOffSet.contains(index) ? 1 : 0, index + 3, 1)); + comment.setString(new XSSFRichTextString(text)); + mainSheet.getRow(0).getCell(0).setCellComment(comment); + } + + private String buildComment(BaseField field) { + return switch (field.getType()) { + case "INPUT_NUMBER" -> getNumberComment(field); + case "DATE_TIME" -> getDateTimeComment(field); + case "LOCATION" -> getLocationComment(field); + case "INDUSTRY" -> getIndustryComment(); + default -> getOptionComment(field); + }; + } + + private String getNumberComment(BaseField field) { + StringBuilder sb = new StringBuilder(); + InputNumberField numberField = (InputNumberField) field; + if (Strings.CS.equals(numberField.getNumberFormat(), NumberResolver.PERCENT_FORMAT)) { + sb.append(Translator.get("format.preview")).append(": 99.9999%, ").append(Translator.get("keep.decimal.places")); + } else { + sb.append(Translator.get("format.preview")).append(": 9999999999.9, ").append(Translator.get("keep.decimal.places")); + } + return sb.toString(); + } + + private String getDateTimeComment(BaseField field) { + StringBuilder sb = new StringBuilder(); + DateTimeField dateTimeField = (DateTimeField) field; + if (Strings.CS.equals(dateTimeField.getDateType(), DateTimeResolver.DATETIME)) { + sb.append(Translator.get("format.preview")).append(": 2025/8/6 20:23:59"); + } else if (Strings.CS.equals(dateTimeField.getDateType(), DateTimeResolver.DATE)) { + sb.append(Translator.get("format.preview")).append(": 2025/8/6"); + } else { + sb.append(Translator.get("format.preview")).append(": 2025/8"); + } + return sb.toString(); + } + + private String getLocationComment(BaseField field) { + StringBuilder sb = new StringBuilder(); + sb.append(Translator.get("format.preview")).append(": "); + LocationField locationField = (LocationField) field; + if (!Strings.CS.equals(locationField.getScope(), LocationResolver.CN)) { + sb.append(Translator.get("location.c")); + if (!Strings.CS.equals(locationField.getLocationType(), LocationResolver.C)) { + sb.append("-"); + } + } + if (Strings.CS.equals(locationField.getLocationType(), LocationResolver.P)) { + sb.append(Translator.get("location.p")); + } else if (Strings.CS.equals(locationField.getLocationType(), LocationResolver.PC)) { + sb.append(Translator.get("location.pc")); + } else if (Strings.CS.equals(locationField.getLocationType(), LocationResolver.PCD)) { + sb.append(Translator.get("location.pcd")); + } else if (Strings.CS.equals(locationField.getLocationType(), LocationResolver.PCD_D)){ + sb.append(Translator.get("location.pcd.detail")); + } + return sb.toString(); + } + + private String getIndustryComment() { + return Translator.get("format.preview") + ": " + Translator.get("industry.tips"); + } + + private String getOptionComment(BaseField field) { + if (field instanceof HasOption) { + List options = ((HasOption) field).getOptions(); + return Translator.get("option") + ": " + options.stream().map(OptionProp::getLabel).toList(); + } + return null; + } + + private static List limitCellDropdown(List options) { + List limitOps = new ArrayList<>(); + int currentLength = 0; + for (String option : options) { + if (option == null || option.isEmpty()) { + continue; + } + int optionLength = option.length(); + int extra = CollectionUtils.isEmpty(limitOps) ? optionLength : optionLength + 1; + if (currentLength + extra > MAX_DROPDOWN_LENGTH) { + break; + } + limitOps.add(option); + currentLength += extra; + } + return limitOps; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/handler/SummaryMergeHandler.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/handler/SummaryMergeHandler.java new file mode 100644 index 0000000..2c6477b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/handler/SummaryMergeHandler.java @@ -0,0 +1,87 @@ +package cn.cordys.crm.system.excel.handler; + +import cn.cordys.crm.system.dto.field.InputNumberField; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.util.CellRangeAddress; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 动态合并单元格策略(汇总) + * @author song-cc-rock + */ +public record SummaryMergeHandler(List mergeRegions, List mergeColumns, List summaryCols, int offset) { + + public void merge(Sheet sheet) { + if (CollectionUtils.isEmpty(mergeRegions) || CollectionUtils.isEmpty(mergeColumns)) { + return; + } + + // 合并行区域内的汇总列的值 + for (int[] region : mergeRegions) { + int start = region[0] + offset; + int end = region[1] + offset; + for (Integer colIndex : summaryCols) { + BigDecimal total = BigDecimal.ZERO; + boolean showThousandsSeparator = false; + boolean hasPercent = false; + int decimalPlaces = 0; + for (int r = start; r <= end; r++) { + Cell cell = sheet.getRow(r).getCell(colIndex); + if (cell != null) { + String val = switch (cell.getCellType()) { + case STRING -> cell.getStringCellValue(); + case NUMERIC -> String.valueOf(cell.getNumericCellValue()); + default -> null; + }; + if (StringUtils.isEmpty(val)) { + continue; + } + // 记住汇总列格式 + if (val.contains(",") && !showThousandsSeparator) { + showThousandsSeparator = true; + } + if (val.contains("%") && !hasPercent) { + hasPercent = true; + } + val = val.replace(",", StringUtils.EMPTY).replace("%", StringUtils.EMPTY); + if (NumberUtils.isParsable(val)) { + if (decimalPlaces == 0 && val.contains(".")) { + decimalPlaces = val.length() - val.indexOf(".") - 1; + } + total = total.add(new BigDecimal(val)); + } + } + } + // 将汇总值写入合并后的单元格, 空值不展示 + if (total.compareTo(BigDecimal.ZERO) != 0) { + Cell sumCell = sheet.getRow(start).getCell(colIndex); + sumCell.setCellValue(InputNumberField.formatNumber(total, decimalPlaces, showThousandsSeparator, hasPercent)); + Workbook wb = sheet.getWorkbook(); + CellStyle style = wb.createCellStyle(); + style.setAlignment(HorizontalAlignment.RIGHT); + style.setVerticalAlignment(VerticalAlignment.CENTER); + sumCell.setCellStyle(style); + } + + // 只保留第一行, 清空其他行汇总列的单元格值 (不同值合并展示有误) + for (int r = start + 1; r <= end; r++) { + sheet.getRow(r).getCell(colIndex).setCellValue(""); + } + } + } + + // 合并单元格 + for (int[] region : mergeRegions) { + int start = region[0] + offset; + int end = region[1] + offset; + for (Integer colIndex : mergeColumns) { + sheet.addMergedRegionUnsafe(new CellRangeAddress(start, end, colIndex, colIndex)); + } + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/handler/UserTemplateWriteHandler.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/handler/UserTemplateWriteHandler.java new file mode 100644 index 0000000..0f0b934 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/handler/UserTemplateWriteHandler.java @@ -0,0 +1,105 @@ +package cn.cordys.crm.system.excel.handler; + +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.excel.constants.UserImportFiled; +import cn.idev.excel.util.BooleanUtils; +import cn.idev.excel.write.handler.RowWriteHandler; +import cn.idev.excel.write.handler.SheetWriteHandler; +import cn.idev.excel.write.handler.context.RowWriteHandlerContext; +import cn.idev.excel.write.metadata.holder.WriteSheetHolder; +import cn.idev.excel.write.metadata.holder.WriteWorkbookHolder; +import cn.idev.excel.write.metadata.style.WriteCellStyle; +import cn.idev.excel.write.style.HorizontalCellStyleStrategy; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.usermodel.XSSFClientAnchor; +import org.apache.poi.xssf.usermodel.XSSFRichTextString; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + + +/** + * @author wx + */ +public class UserTemplateWriteHandler implements RowWriteHandler, SheetWriteHandler { + + private final Map fieldMap = new HashMap<>(); + private Sheet sheet; + private Drawing drawingPatriarch; + private int totalColumns; // 记录总列数 + + public UserTemplateWriteHandler(List> headList) { + initIndex(headList); + } + + public static HorizontalCellStyleStrategy getHorizontalWrapStrategy() { + WriteCellStyle contentWriteCellStyle = new WriteCellStyle(); + // 设置自动换行 + contentWriteCellStyle.setWrapped(true); + return new HorizontalCellStyleStrategy(null, contentWriteCellStyle); + } + + private void initIndex(List> headList) { + int index = 0; + for (List list : headList) { + for (String head : list) { + this.fieldMap.put(head, index); + index++; + } + } + this.totalColumns = index; // 初始化总列数 + } + + @Override + public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) { + sheet = writeSheetHolder.getSheet(); + + // 创建第二行并合并单元格 + Row row1 = sheet.createRow(1); + Cell cell1 = row1.createCell(0); + cell1.setCellValue(""" + 第二行说明文字: + 1、不能再Excel中对成员信息类别进行增加、修改、删除 + 2、上下级部门间用‘/’隔开,且从最上级部门开始,例如"XX公司/XX事业部/研发部\""""); + sheet.addMergedRegion(new CellRangeAddress(1, 1, 0, totalColumns - 1)); + } + + @Override + public void afterRowDispose(RowWriteHandlerContext context) { + if (BooleanUtils.isTrue(context.getHead())) { + sheet = context.getWriteSheetHolder().getSheet(); + drawingPatriarch = sheet.createDrawingPatriarch(); + + for (Map.Entry entry : fieldMap.entrySet()) { + //默认字段 + if (UserImportFiled.NAME.containsHead(entry.getKey())) { + setComment(fieldMap.get(entry.getKey()), Translator.get("required")); + } + if (UserImportFiled.DEPARTMENT.containsHead(entry.getKey())) { + setComment(fieldMap.get(entry.getKey()), Translator.get("department_comment")); + } + if (UserImportFiled.PHONE.containsHead(entry.getKey())) { + setComment(fieldMap.get(entry.getKey()), Translator.get("required")); + } + if (UserImportFiled.EMAIL.containsHead(entry.getKey())) { + setComment(fieldMap.get(entry.getKey()), Translator.get("required")); + } + if (UserImportFiled.EMPLOYEE_TYPE.containsHead(entry.getKey())) { + setComment(fieldMap.get(entry.getKey()), Translator.get("employee_type_comment")); + } + } + } + + } + + private void setComment(Integer index, String text) { + if (index == null) { + return; + } + Comment comment = drawingPatriarch.createCellComment(new XSSFClientAnchor(0, 0, 0, 0, index, 0, index + 3, 1)); + comment.setString(new XSSFRichTextString(text)); + sheet.getRow(0).getCell(0).setCellComment(comment); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/listener/CustomFieldCheckEventListener.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/listener/CustomFieldCheckEventListener.java new file mode 100644 index 0000000..e9592ea --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/listener/CustomFieldCheckEventListener.java @@ -0,0 +1,307 @@ +package cn.cordys.crm.system.excel.listener; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.mapper.CommonMapper; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.SubField; +import cn.cordys.excel.domain.ExcelErrData; +import cn.idev.excel.context.AnalysisContext; +import cn.idev.excel.event.AnalysisEventListener; +import cn.idev.excel.metadata.CellExtra; +import lombok.Getter; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * @author song-cc-rock + */ +public class CustomFieldCheckEventListener extends AnalysisEventListener> { + + /** + * 表头字段集合 + */ + protected final Map fieldMap = new HashMap<>(); + /** + * 源数据表 + */ + private final String sourceTable; + private final String fieldTable; + protected final String currentOrg; + /** + * 必填校验 + */ + private final List requires = new ArrayList<>(); + /** + * 唯一校验&&数据库属性值缓存&&Excel列值缓存 + */ + private final Map uniques = new HashMap<>(); + private final Map> uniqueCheckSet = new ConcurrentHashMap<>(); + private final Map> excelValueCache = new ConcurrentHashMap<>(); + private final CommonMapper commonMapper; + /** + * 长度校验 + */ + private final Map fieldLenLimit = new HashMap<>(); + /** + * 错误, 成功信息 + */ + @Getter + protected Integer success = 0; + @Getter + protected List errList = new ArrayList<>(); + /** + * 表头字段集合 && 业务字段集合映射 + */ + protected Map headMap; + protected Map businessFieldMap; + /** + * 错误行号集合 + */ + @Getter + protected List errRows = new ArrayList<>(); + /** + * 子字段引用映射(子字段名称 -> 子表格字段ID) + */ + protected final Map refSubMap = new HashMap<>(); + /** + * 合并单元格信息 + */ + protected final Map> mergeCellMap; + /** + * 是否至少有一行数据 && 表头行数 + */ + protected boolean atLeastOne = false; + protected int maxHeadRow; + protected final Map> mergeRowDataMap; + + public CustomFieldCheckEventListener(List fields, String sourceTable, String fieldTable, String currentOrg) { + this(fields, sourceTable, fieldTable, currentOrg, null, null); + } + + public CustomFieldCheckEventListener(List fields, String sourceTable, String fieldTable, String currentOrg, + Map> mergeCellMap, Map> mergeRowDataMap) { + for (BaseField field : fields) { + if (isInvalidField(field)) { + continue; + } + if (field instanceof SubField subField && CollectionUtils.isNotEmpty(subField.getSubFields())) { + for (BaseField f : subField.getSubFields()) { + if (isInvalidField(f)) { + continue; + } + this.fieldMap.put(f.getName(), f); + refSubMap.put(f.getName(), subField.getId()); + setCheckLimit(f); + } + continue; + } + this.fieldMap.put(field.getName(), field); + setCheckLimit(field); + } + this.sourceTable = sourceTable; + this.currentOrg = currentOrg; + this.commonMapper = CommonBeanFactory.getBean(CommonMapper.class); + this.fieldTable = fieldTable; + this.mergeCellMap = mergeCellMap; + this.mergeRowDataMap = mergeRowDataMap; + } + + @Override + public void invokeHeadMap(Map headMap, AnalysisContext context) { + maxHeadRow = context.readWorkbookHolder().getHeadRowNumber(); + if (context.readRowHolder().getRowIndex() != maxHeadRow - 1) { + return; + } + if (headMap == null) { + throw new GenericException(Translator.get("user_import_table_header_missing")); + } + String errHead = checkIllegalHead(headMap); + if (StringUtils.isNotEmpty(errHead)) { + throw new GenericException(Translator.getWithArgs("illegal_header", errHead)); + } + this.headMap = headMap; + this.businessFieldMap = Arrays.stream(BusinessModuleField.values()). + collect(Collectors.toMap(BusinessModuleField::getKey, Function.identity())); + cacheUniqueSet(); + } + + @Override + public void invoke(Map data, AnalysisContext context) { + if (data == null) { + return; + } + atLeastOne = true; + Integer rowIndex = context.readRowHolder().getRowIndex(); + validateRowData(rowIndex, data); + } + + @Override + public void doAfterAllAnalysed(AnalysisContext analysisContext) { + if (!atLeastOne) { + throw new GenericException(Translator.get("import.data.cannot_be_null")); + } + } + + /** + * 缓存一些比对值 + */ + private void cacheUniqueSet() { + if (!uniques.isEmpty()) { + uniques.values().forEach(field -> { + if (businessFieldMap.containsKey(field.getInternalKey()) && !refSubMap.containsKey(field.getName())) { + // 子表格字段不走业务唯一性校验 + BusinessModuleField businessModuleField = businessFieldMap.get(field.getInternalKey()); + String fieldName = businessModuleField.getBusinessKey(); + List valList = commonMapper.getCheckValList(sourceTable, fieldName, currentOrg); + uniqueCheckSet.put(field.getName(), new HashSet<>(valList.stream().distinct().toList())); + } else { + List valList = commonMapper.getCheckFieldValList(sourceTable, fieldTable, field.getId(), currentOrg); + uniqueCheckSet.put(field.getName(), new HashSet<>(valList)); + } + }); + } + } + + /** + * 校验行数据 + * + * @param rowIndex 行索引 + * @param rowData 行数据 + */ + private void validateRowData(Integer rowIndex, Map rowData) { + StringBuilder errText = new StringBuilder(); + headMap.forEach((k, v) -> { + if (!isValidateCell(rowIndex, k)) { + return; + } + if (requires.contains(v) && StringUtils.isEmpty(rowData.get(k))) { + errText.append(v).append(Translator.get("cannot_be_null")).append(";"); + } + if (uniques.containsKey(v) && !checkFieldValUnique(rowData.get(k), uniques.get(v))) { + errText.append(v).append(Translator.get("cell.not.unique")).append(";"); + } + if (fieldLenLimit.containsKey(v) && StringUtils.isNotEmpty(rowData.get(k)) && + rowData.get(k).length() > fieldLenLimit.get(v)) { + errText.append(v).append(Translator.getWithArgs("over.length", fieldLenLimit.get(v))).append(";"); + } + }); + if (StringUtils.isNotEmpty(errText)) { + ExcelErrData excelErrData = new ExcelErrData(rowIndex, + Translator.getWithArgs("row.error.tip", rowIndex + 1).concat(" " + errText)); + //错误信息 + errList.add(excelErrData); + errRows.add(rowIndex); + } else if (!errRows.contains(rowIndex)) { + success++; + } + } + + /** + * 判断单元格是否需要校验 + * + * @param rowIndex 行序号 + * @param colIndex 列序号 + * + * @return 是否需要校验 + */ + private boolean isValidateCell(int rowIndex, int colIndex) { + if (mergeCellMap == null) { + return true; + } + List cellExtras = mergeCellMap.get(rowIndex); + if (cellExtras != null) { + for (CellExtra extra : cellExtras) { + // 属于合并列的区域内 + if (colIndex >= extra.getFirstColumnIndex() && colIndex <= extra.getLastColumnIndex()) { + // 合并第一行也需校验 + return rowIndex == extra.getFirstRowIndex(); + } + } + } + // 不属于合并单元格,直接校验 + return true; + } + + /** + * 检查字段值唯一 + * + * @param val 值 + * @param field 字段 + * + * @return 是否唯一 + */ + private boolean checkFieldValUnique(String val, BaseField field) { + if (StringUtils.isEmpty(val)) { + return true; + } + // Excel 唯一性校验 + excelValueCache.putIfAbsent(field.getId(), ConcurrentHashMap.newKeySet()); + Set valueSet = excelValueCache.get(field.getId()); + if (!valueSet.add(val)) { + return false; + } + // 数据库唯一性校验 + Set uniqueCheck = uniqueCheckSet.get(field.getName()); + return !uniqueCheck.contains(val); + } + + /** + * 表头是否非法 + * + * @param headMap 表头集合 + * + * @return 是否非法 + */ + private String checkIllegalHead(Map headMap) { + for (BaseField field : fieldMap.values()) { + if (!field.canImport() || Strings.CS.equals(field.getType(), FieldType.TEXTAREA.name())) { + continue; + } + if (!headMap.containsValue(field.getName())) { + return field.getName(); + } + } + return null; + } + + /** + * 设置校验信息 + * + * @param field 自定义字段 + */ + private void setCheckLimit(BaseField field) { + if (field.needRequireCheck()) { + requires.add(field.getName()); + } + if (field.needRepeatCheck()) { + uniques.put(field.getName(), field); + } + if (Strings.CS.equalsAny(field.getType(), FieldType.MEMBER.name(), FieldType.DEPARTMENT.name(), FieldType.DATA_SOURCE.name())) { + fieldLenLimit.put(field.getName(), 32); + } + if (Strings.CS.equalsAny(field.getType(), FieldType.INPUT.name(), FieldType.INPUT_NUMBER.name(), FieldType.DATE_TIME.name(), FieldType.RADIO.name(), + FieldType.SELECT.name(), FieldType.PHONE.name(), FieldType.LOCATION.name(), FieldType.INDUSTRY.name())) { + fieldLenLimit.put(field.getName(), 255); + } + if (Strings.CS.equals(field.getType(), FieldType.TEXTAREA.name())) { + fieldLenLimit.put(field.getName(), 3000); + } + } + + private boolean isInvalidField(BaseField field) { + if (StringUtils.isNotEmpty(field.getResourceFieldId())) { + return true; + } + return !field.canImport() && !field.isSerialNumber(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/listener/CustomFieldImportEventListener.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/listener/CustomFieldImportEventListener.java new file mode 100644 index 0000000..b3a54d9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/listener/CustomFieldImportEventListener.java @@ -0,0 +1,369 @@ +package cn.cordys.crm.system.excel.listener; + +import cn.cordys.common.domain.BaseResourceSubField; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.common.resolver.field.ModuleFieldResolverFactory; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.uid.SerialNumGenerator; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.dto.field.SerialNumberField; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.excel.CustomImportAfterDoConsumer; +import cn.cordys.mybatis.EntityTableMapper; +import cn.idev.excel.context.AnalysisContext; +import cn.idev.excel.metadata.CellExtra; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.lang.reflect.Method; +import java.util.*; + +/** + * 自定义字段导入处理器 + * + * @param 业务实体 + * + * @author song-cc-rock + */ +@Slf4j +public class CustomFieldImportEventListener extends CustomFieldCheckEventListener { + + /** + * 主表数据 + */ + @Getter + private final List dataList; + /** + * 自定义字段集合&Blob字段集合 + */ + private final List fields; + private final List blobFields; + /** + * 批次限制 + */ + private final int batchSize; + /** + * 业务实体 + */ + private final Class entityClass; + /** + * method cache + */ + private final Map methodCache = new HashMap<>(); + /** + * 操作人 + */ + private final String operator; + /** + * 后置处理函数(入库) + */ + private final CustomImportAfterDoConsumer consumer; + /** + * 序列化字段及生成器 + */ + private BaseField serialField; + private final SerialNumGenerator serialNumGenerator; + /** + * 成功条数 + */ + @Getter + private int successCount; + /** + * 临时合并实体 + */ + private T mergedTmpEntity; + private int subRowId; + + public CustomFieldImportEventListener(List fields, Class clazz, String currentOrg, String operator, String fieldTable, + CustomImportAfterDoConsumer consumer, int batchSize, + Map> mergeCellMap, Map> mergeRowDataMap) { + super(fields, EntityTableMapper.generateTableName(clazz), fieldTable, currentOrg, mergeCellMap, mergeRowDataMap); + this.entityClass = clazz; + this.operator = operator; + this.serialNumGenerator = CommonBeanFactory.getBean(SerialNumGenerator.class); + this.consumer = consumer; + this.batchSize = batchSize > 0 ? batchSize : 2000; + // 初始化大小,扩容有开销 + this.dataList = new ArrayList<>(batchSize); + this.fields = new ArrayList<>(batchSize); + this.blobFields = new ArrayList<>(batchSize); + // 缓存方法, 频繁反射有开销 + cacheSetterMethods(); + } + + @Override + public void invokeHeadMap(Map headMap, AnalysisContext context) { + super.invokeHeadMap(headMap, context); + Optional anySerial = this.fieldMap.values().stream().filter(BaseField::isSerialNumber).findAny(); + anySerial.ifPresent(field -> serialField = field); + } + + @Override + public void invoke(Map data, AnalysisContext analysisContext) { + super.invoke(data, analysisContext); + Integer rowIndex = analysisContext.readRowHolder().getRowIndex(); + if (this.errRows.contains(rowIndex)) { + // 有错误跳过该行 + return; + } + // build entity by row-data + buildEntityFromRow(rowIndex, data); + if (dataList.size() >= batchSize || fields.size() >= batchSize || blobFields.size() > batchSize) { + batchProcessData(); + } + } + + @Override + public void doAfterAllAnalysed(AnalysisContext analysisContext) { + if (CollectionUtils.isNotEmpty(this.dataList) || CollectionUtils.isNotEmpty(this.fields) || CollectionUtils.isNotEmpty(this.blobFields)) { + batchProcessData(); + } + log.info("数据导入完成, 总行数: {}", successCount); + } + + /** + * 批量入库操作 + */ + private void batchProcessData() { + try { + // 执行入库 + consumer.accept(this.dataList, this.fields, this.blobFields); + } catch (Exception e) { + // 入库异常,不影响后续批次 + log.error("批量插入异常: {}", e.getCause().getMessage()); + throw new GenericException(e.getCause()); + } finally { + // 批次插入成功, 统计&&清理 + successCount += this.dataList.size(); + this.dataList.clear(); + this.fields.clear(); + this.blobFields.clear(); + } + } + + /** + * 构建行列数据 => 实体 + * + * @param rowIndex 行序号 + * @param rowData 行数据 + */ + private void buildEntityFromRow(Integer rowIndex, Map rowData) { + try { + if (isNormalRow(rowIndex) || isMergeFirstRow(rowIndex)) { + // 非合并行才创建实体 + String rowKey = IDGenerator.nextStr(); + mergedTmpEntity = entityClass.getDeclaredConstructor().newInstance(); + setInternal(mergedTmpEntity, rowKey); + subRowId = 1; + } else { + subRowId++; + } + if (mergedTmpEntity == null) { + return; + } + Optional id = Optional.ofNullable(getResourceId(mergedTmpEntity)); + if (id.isEmpty()) { + return; + } + String bizId = IDGenerator.nextStr(); + headMap.forEach((k, v) -> { + BaseField field = fieldMap.get(v); + if (field == null || field.isSerialNumber()) { + return; + } + Object val = convertValue(rowData.get(k), field); + if (val == null) { + return; + } + if (!refSubMap.containsKey(field.getName()) && !isNormalRow(rowIndex) && !isMergeFirstRow(rowIndex)) { + // 除合并的首行外, 其余合并行非子表字段都跳过 + return; + } + if (businessFieldMap.containsKey(field.getInternalKey()) && !refSubMap.containsKey(field.getName())) { + try { + setPropertyValue(mergedTmpEntity, businessFieldMap.get(field.getInternalKey()).getBusinessKey(), val); + } catch (Exception e) { + log.error("导入错误, 无法设置字段值. {}", e.getMessage()); + throw new GenericException(e); + } + } else { + BaseResourceSubField resourceField = new BaseResourceSubField(); + resourceField.setId(IDGenerator.nextStr()); + resourceField.setResourceId(id.get().toString()); + resourceField.setFieldId(field.idOrBusinessKey()); + resourceField.setFieldValue(val); + if (refSubMap.containsKey(field.getName())) { + resourceField.setRefSubId(refSubMap.get(field.getName())); + resourceField.setRowId(String.valueOf(subRowId)); + resourceField.setBizId(bizId); + } + if (field.isBlob()) { + if (val instanceof List valList) { + resourceField.setFieldValue(JSON.toJSONString(valList)); + } + blobFields.add(resourceField); + } else { + fields.add(resourceField); + } + } + }); + if (isNormalRow(rowIndex) || isMergedLastRow(rowIndex)) { + if (serialField != null) { + BaseResourceSubField serialResource = new BaseResourceSubField(); + serialResource.setId(IDGenerator.nextStr()); + serialResource.setResourceId(id.get().toString()); + serialResource.setFieldId(serialField.idOrBusinessKey()); + String serialNo = serialNumGenerator.generateByRules(((SerialNumberField) serialField).getSerialNumberRules(), + currentOrg, entityClass.getSimpleName().toLowerCase()); + serialResource.setFieldValue(serialNo); + if (refSubMap.containsKey(serialField.getName())) { + serialResource.setRefSubId(refSubMap.get(serialField.getName())); + serialResource.setRowId(String.valueOf(subRowId)); + serialResource.setBizId(bizId); + } + fields.add(serialResource); + } + // 合并最后行, 添加并清除实体 + dataList.add(mergedTmpEntity); + mergedTmpEntity = null; + } + } catch (Exception e) { + log.error("导入错误, 原因: {}", e.getMessage()); + throw new GenericException(Translator.getWithArgs("import.error", rowIndex + 1).concat(" " + e.getMessage())); + } + } + + /** + * 自定义字段文本转换 + * + * @param text 文本 + * @param field 字段 + * + * @return 值 + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private Object convertValue(String text, BaseField field) { + if (StringUtils.isEmpty(text)) { + return null; + } + try { + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(field.getType()); + return customFieldResolver.textToValue(field, text); + } catch (Exception e) { + log.error("解析字段[{}]错误, [{}]不能被转换; 原因: {}", field.getName(), text, e.getMessage(), e); + } + return null; + } + + /** + * 缓存entity setter + */ + private void cacheSetterMethods() { + for (Method method : entityClass.getMethods()) { + if (method.getName().startsWith("set") && method.getParameterCount() == 1) { + String fieldName = method.getName().substring(3); + String property = Character.toLowerCase(fieldName.charAt(0)) + fieldName.substring(1); + methodCache.put("set:" + property, method); + } else if (method.getParameterCount() == 0 && Strings.CS.equals(method.getName(), "getId")) { + methodCache.put("get:id", method); + } + } + } + + /** + * 判断是否合并首行 + * + * @param rowIndex 行号 + * + * @return 是否为合并首行 + */ + private boolean isMergeFirstRow(int rowIndex) { + if (mergeCellMap == null) { + return false; + } + List cellExtras = mergeCellMap.get(rowIndex); + return cellExtras != null && cellExtras.stream().anyMatch(extra -> rowIndex == extra.getFirstRowIndex() && extra.getLastRowIndex() > rowIndex); + } + + /** + * 判断当前行是否为合并尾行 + * + * @param rowIndex 行号 + * + * @return 是否为合并尾行 + */ + private boolean isMergedLastRow(int rowIndex) { + if (mergeCellMap == null) { + return false; + } + List cellExtras = mergeCellMap.get(rowIndex); + return cellExtras != null && cellExtras.stream().anyMatch(extra -> rowIndex == extra.getLastRowIndex() && extra.getLastRowIndex() > extra.getFirstRowIndex()); + } + + /** + * 非合并行 + * + * @param rowIndex 行号 + * + * @return 是否为正常行 + */ + private boolean isNormalRow(int rowIndex) { + return mergeCellMap == null || !mergeCellMap.containsKey(rowIndex); + } + + /** + * 设置entity内部字段 + * + * @param instance 实例对象 + * @param rowKey 唯一Key + * + * @throws Exception 异常 + */ + private void setInternal(T instance, String rowKey) throws Exception { + methodCache.get("set:id").invoke(instance, rowKey); + methodCache.get("set:createUser").invoke(instance, operator); + methodCache.get("set:createTime").invoke(instance, System.currentTimeMillis()); + methodCache.get("set:updateUser").invoke(instance, operator); + methodCache.get("set:updateTime").invoke(instance, System.currentTimeMillis()); + methodCache.get("set:organizationId").invoke(instance, currentOrg); + } + + /** + * 设置属性值 + * + * @param instance 实例对象 + * @param fieldName 字段名 + * @param value 值 + * + * @throws Exception 异常 + */ + private void setPropertyValue(T instance, String fieldName, Object value) throws Exception { + Method setter = methodCache.get("set:" + fieldName); + if (setter != null) { + setter.invoke(instance, value); + } + } + + /** + * 获取资源ID + * + * @param instance 实例对象 + * + * @return 资源ID + * + * @throws Exception 异常信息 + */ + private Object getResourceId(T instance) throws Exception { + Method getter = methodCache.get("get:id"); + if (getter != null) { + return getter.invoke(instance); + } + return null; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/listener/CustomFieldMergeCellEventListener.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/listener/CustomFieldMergeCellEventListener.java new file mode 100644 index 0000000..f0c9d02 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/listener/CustomFieldMergeCellEventListener.java @@ -0,0 +1,73 @@ +package cn.cordys.crm.system.excel.listener; + +import cn.idev.excel.context.AnalysisContext; +import cn.idev.excel.enums.CellExtraTypeEnum; +import cn.idev.excel.event.AnalysisEventListener; +import cn.idev.excel.metadata.CellExtra; +import lombok.Getter; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 预收集合并单元格信息 + * @author song-cc-rock + */ +public class CustomFieldMergeCellEventListener extends AnalysisEventListener> { + + /** + * 头行号 + */ + private int maxHeadRow; + + /** + * 合并单元格信息 (按行号分组, 方便查询) + */ + @Getter + private final Map> mergeCellMap = new HashMap<>(); + @Getter + private final Map> mergeRowDataMap = new HashMap<>(); + + @Override + public void invoke(Map data, AnalysisContext context) { + int rowIndex = context.readRowHolder().getRowIndex(); + mergeRowDataMap.put(rowIndex, new HashMap<>(data)); + } + + @Override + public void invokeHeadMap(Map headMap, AnalysisContext context) { + maxHeadRow = context.readWorkbookHolder().getHeadRowNumber(); + } + + @Override + public void extra(CellExtra extra, AnalysisContext context) { + if (extra.getType() == CellExtraTypeEnum.MERGE && extra.getRowIndex() >= maxHeadRow) { + for (int row = extra.getFirstRowIndex(); row <= extra.getLastRowIndex(); row++) { + mergeCellMap.computeIfAbsent(row, k -> new ArrayList<>()).add(extra); + } + + int firstRow = extra.getFirstRowIndex(); + int lastRow = extra.getLastRowIndex(); + int colIndex = extra.getFirstColumnIndex(); + + Map firstRowData = mergeRowDataMap.get(firstRow); + + if (firstRowData == null) { + return; + } + + String val = firstRowData.get(colIndex); + for (int r = firstRow; r <= lastRow; r++) { + mergeRowDataMap.computeIfAbsent(r, k -> new HashMap<>(8)) + .put(colIndex, val); + } + } + } + + @Override + public void doAfterAllAnalysed(AnalysisContext analysisContext) { + + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/listener/UserCheckEventListener.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/listener/UserCheckEventListener.java new file mode 100644 index 0000000..45bca20 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/listener/UserCheckEventListener.java @@ -0,0 +1,264 @@ +package cn.cordys.crm.system.excel.listener; + +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.excel.domain.UserExcelData; +import cn.cordys.crm.system.excel.domain.UserExcelDataFactory; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.crm.system.service.OrganizationUserService; +import cn.cordys.excel.domain.ExcelErrData; +import cn.cordys.excel.utils.ExcelValidateHelper; +import cn.idev.excel.annotation.ExcelProperty; +import cn.idev.excel.context.AnalysisContext; +import cn.idev.excel.event.AnalysisEventListener; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.lang.reflect.Field; +import java.util.*; +import java.util.function.BiConsumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +public class UserCheckEventListener extends AnalysisEventListener> { + + protected static final int NAME_LENGTH = 255; + protected static final int PHONE_LENGTH = 20; + private static final String ERROR_MSG_SEPARATOR = ";"; + private static final String EMAIL_REGEX = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$"; + private static final String PHONE_REGEX = "^1[0-9]\\d{9}$"; + private static final Pattern EMAIL_PATTERN = Pattern.compile(EMAIL_REGEX); + private static final Pattern PHONE_PATTERN = Pattern.compile(PHONE_REGEX); + private static final Map> FIELD_SETTERS = new HashMap<>(); + + static { + FIELD_SETTERS.put("employeeId", UserExcelData::setEmployeeId); + FIELD_SETTERS.put("name", UserExcelData::setName); + FIELD_SETTERS.put("gender", UserExcelData::setGender); + FIELD_SETTERS.put("department", UserExcelData::setDepartment); + FIELD_SETTERS.put("position", UserExcelData::setPosition); + FIELD_SETTERS.put("phone", UserExcelData::setPhone); + FIELD_SETTERS.put("email", UserExcelData::setEmail); + FIELD_SETTERS.put("supervisor", UserExcelData::setSupervisor); + FIELD_SETTERS.put("workCity", UserExcelData::setWorkCity); + FIELD_SETTERS.put("employeeType", UserExcelData::setEmployeeType); + } + + @Getter + protected final List list = new ArrayList<>(); + @Getter + protected final List errList = new ArrayList<>(); + private final Class excelDataClass; + private final Map excelHeadToFieldNameDic = new HashMap<>(); + private final List departmentTree; + private final OrganizationUserService organizationUserService; + private Map headMap; + + public UserCheckEventListener(Class clazz, String orgId) { + this.excelDataClass = clazz; + DepartmentService departmentService = CommonBeanFactory.getBean(DepartmentService.class); + this.organizationUserService = CommonBeanFactory.getBean(OrganizationUserService.class); + this.departmentTree = Objects.requireNonNull(departmentService).getTree(orgId); + } + + @Override + public void invokeHeadMap(Map headMap, AnalysisContext context) { + this.headMap = headMap; + try { + genExcelHeadToFieldNameDicAndGetNotRequiredFields(); + } catch (NoSuchFieldException e) { + log.error(e.getMessage(), e); + } + formatHeadMap(); + super.invokeHeadMap(headMap, context); + } + + @Override + public void invoke(Map data, AnalysisContext analysisContext) { + if (headMap == null) { + throw new GenericException(Translator.get("user_import_table_header_missing")); + } + Integer rowIndex = analysisContext.readRowHolder().getRowIndex(); + if (rowIndex >= 3) { + UserExcelData userExcelData = parseDataToModel(data); + buildUpdateOrErrorList(rowIndex, userExcelData); + } + } + + @Override + public void doAfterAllAnalysed(AnalysisContext analysisContext) { + // no-op + } + + private void buildUpdateOrErrorList(Integer rowIndex, UserExcelData userExcelData) { + StringBuilder errMsg; + try { + errMsg = new StringBuilder(ExcelValidateHelper.validateEntity(userExcelData)); + if (errMsg.isEmpty()) { + validate(userExcelData, errMsg); + } + } catch (NoSuchFieldException e) { + errMsg = new StringBuilder(Translator.get("parse_data_error")); + log.error(e.getMessage(), e); + } + + if (!errMsg.isEmpty()) { + ExcelErrData excelErrData = new ExcelErrData(rowIndex, + Translator.get("number") + .concat(StringUtils.SPACE) + .concat(String.valueOf(rowIndex + 1)).concat(StringUtils.SPACE) + .concat(Translator.get("row")) + .concat(Translator.get("error")) + .concat(":") + .concat(errMsg.toString())); + errList.add(excelErrData); + } else { + list.add(userExcelData); + } + } + + public void validate(UserExcelData data, StringBuilder errMsg) { + validateName(data, errMsg); + validatePhone(data, errMsg); + validateEmail(data, errMsg); + validateDepartment(data, errMsg); + validateLength(data, errMsg); + } + + private void validateLength(UserExcelData data, StringBuilder errMsg) { + String employeeId = data.getEmployeeId(); + if (StringUtils.isNotBlank(employeeId) && employeeId.length() > 255) { + errMsg.append(Translator.get("employee_length")).append(ERROR_MSG_SEPARATOR); + } + + String position = data.getPosition(); + if (StringUtils.isNotBlank(position) && position.length() > 255) { + errMsg.append(Translator.get("position_length")).append(ERROR_MSG_SEPARATOR); + } + } + + private void validateDepartment(UserExcelData data, StringBuilder errMsg) { + String departments = data.getDepartment(); + if (StringUtils.isNotEmpty(departments) && CollectionUtils.isNotEmpty(departmentTree)) { + String topDepartment = departments.split("/")[0]; + BaseTreeNode root = departmentTree.getFirst(); + if (root == null || !Strings.CI.equals(root.getName(), topDepartment)) { + errMsg.append(Translator.get("top_department_not_exist")).append(ERROR_MSG_SEPARATOR); + } + } + } + + private void validateEmail(UserExcelData data, StringBuilder errMsg) { + String email = data.getEmail(); + if (email == null) { + return; // 基础校验已覆盖空值,这里仅防御 + } + Matcher matcher = EMAIL_PATTERN.matcher(email); + if (!matcher.matches()) { + errMsg.append(Translator.get("email_format_error")).append(ERROR_MSG_SEPARATOR); + } + if (organizationUserService.checkEmail(email)) { + errMsg.append(Translator.get("email.exist")).append(ERROR_MSG_SEPARATOR); + } + if (CollectionUtils.isNotEmpty(list)) { + for (UserExcelData userExcelData : list) { + if (Strings.CI.equals(userExcelData.getEmail(), email)) { + errMsg.append(Translator.get("email.repeat")) + .append(email) + .append(ERROR_MSG_SEPARATOR); + break; + } + } + } + } + + private void validatePhone(UserExcelData data, StringBuilder errMsg) { + String phone = data.getPhone(); + if (phone == null) { + return; // 基础校验已覆盖空值,这里仅防御 + } + if (phone.length() > PHONE_LENGTH) { + errMsg.append(Translator.get("phone_length")).append(ERROR_MSG_SEPARATOR); + } + if (organizationUserService.checkPhone(phone)) { + errMsg.append(Translator.get("phone.exist")).append(ERROR_MSG_SEPARATOR); + } + if (!PHONE_PATTERN.matcher(phone).matches()) { + errMsg.append(Translator.get("import_phone_validate")).append(ERROR_MSG_SEPARATOR); + } + if (CollectionUtils.isNotEmpty(list)) { + for (UserExcelData userExcelData : list) { + if (Strings.CI.equals(userExcelData.getPhone(), phone)) { + errMsg.append(Translator.get("phone.repeat")) + .append(phone) + .append(ERROR_MSG_SEPARATOR); + break; + } + } + } + } + + private void validateName(UserExcelData data, StringBuilder errMsg) { + String name = data.getName(); + if (name != null && name.length() > NAME_LENGTH) { + errMsg.append(Translator.get("name_length")).append(ERROR_MSG_SEPARATOR); + } + } + + private UserExcelData parseDataToModel(Map row) { + UserExcelData data = new UserExcelDataFactory().getUserExcelDataLocal(); + for (Map.Entry headEntry : headMap.entrySet()) { + String field = headEntry.getValue(); + if (StringUtils.isBlank(field)) { + continue; + } + String raw = row.get(headEntry.getKey()); + String value = Objects.toString(raw, StringUtils.EMPTY); + + BiConsumer setter = FIELD_SETTERS.get(field); + if (setter != null) { + setter.accept(data, value); + } + } + return data; + } + + /** + * 获取注解里 ExcelProperty 的 value,并构建头-字段映射字典 + */ + public void genExcelHeadToFieldNameDicAndGetNotRequiredFields() throws NoSuchFieldException { + Field[] fields = excelDataClass.getDeclaredFields(); + for (Field f : fields) { + Field field = excelDataClass.getDeclaredField(f.getName()); + field.setAccessible(true); + ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class); + if (excelProperty == null) { + continue; + } + StringBuilder headValue = new StringBuilder(); + for (String v : excelProperty.value()) { + headValue.append(v); + } + String head = headValue.toString(); + excelHeadToFieldNameDic.put(head, field.getName()); + } + } + + private void formatHeadMap() { + if (headMap == null || headMap.isEmpty()) { + return; + } + for (Map.Entry entry : headMap.entrySet()) { + String name = entry.getValue(); + if (excelHeadToFieldNameDic.containsKey(name)) { + entry.setValue(excelHeadToFieldNameDic.get(name)); + } + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/excel/listener/UserImportEventListener.java b/backend/crm/src/main/java/cn/cordys/crm/system/excel/listener/UserImportEventListener.java new file mode 100644 index 0000000..f798205 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/excel/listener/UserImportEventListener.java @@ -0,0 +1,312 @@ +package cn.cordys.crm.system.excel.listener; + +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.excel.domain.UserExcelData; +import cn.cordys.crm.system.excel.domain.UserExcelDataFactory; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.crm.system.service.OrganizationUserService; +import cn.cordys.excel.domain.ExcelErrData; +import cn.cordys.excel.utils.ExcelValidateHelper; +import cn.idev.excel.annotation.ExcelProperty; +import cn.idev.excel.context.AnalysisContext; +import cn.idev.excel.event.AnalysisEventListener; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.lang.reflect.Field; +import java.util.*; +import java.util.function.BiConsumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +public class UserImportEventListener extends AnalysisEventListener> { + + protected static final int NAME_LENGTH = 255; + protected static final int PHONE_LENGTH = 20; + protected static final int BATCH_COUNT = 1000; + private static final String ERROR_MSG_SEPARATOR = ";"; + private static final String EMAIL_REGEX = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$"; + private static final String PHONE_REGEX = "^1[0-9]\\d{9}$"; + private static final Pattern EMAIL_PATTERN = Pattern.compile(EMAIL_REGEX); + private static final Pattern PHONE_PATTERN = Pattern.compile(PHONE_REGEX); + // 字段赋值映射,简化 parseDataToModel + private static final Map> FIELD_SETTERS = new HashMap<>(); + + static { + FIELD_SETTERS.put("employeeId", UserExcelData::setEmployeeId); + FIELD_SETTERS.put("name", UserExcelData::setName); + FIELD_SETTERS.put("gender", UserExcelData::setGender); + FIELD_SETTERS.put("department", UserExcelData::setDepartment); + FIELD_SETTERS.put("position", UserExcelData::setPosition); + FIELD_SETTERS.put("phone", UserExcelData::setPhone); + FIELD_SETTERS.put("email", UserExcelData::setEmail); + FIELD_SETTERS.put("supervisor", UserExcelData::setSupervisor); + FIELD_SETTERS.put("workCity", UserExcelData::setWorkCity); + FIELD_SETTERS.put("employeeType", UserExcelData::setEmployeeType); + } + + private final Class excelDataClass; + private final Map excelHeadToFieldNameDic = new HashMap<>(); + private final String operatorId; + private final String orgId; + private final OrganizationUserService organizationUserService; + private final List departmentTree; + private final Map departmentMap = new HashMap<>(); + @Getter + protected List list = new ArrayList<>(); + @Getter + protected List errList = new ArrayList<>(); + private Map headMap; + @Getter + private int successCount = 0; + + public UserImportEventListener(Class clazz, String operatorId, String orgId) { + this.excelDataClass = clazz; + this.organizationUserService = CommonBeanFactory.getBean(OrganizationUserService.class); + this.departmentTree = Objects.requireNonNull(CommonBeanFactory.getBean(DepartmentService.class)).getTree(orgId); + this.operatorId = operatorId; + this.orgId = orgId; + } + + @Override + public void invokeHeadMap(Map headMap, AnalysisContext context) { + this.headMap = headMap; + try { + genExcelHeadToFieldNameDicAndGetNotRequiredFields(); + } catch (NoSuchFieldException e) { + log.error(e.getMessage(), e); + } + formatHeadMap(); + super.invokeHeadMap(headMap, context); + } + + @Override + public void invoke(Map data, AnalysisContext analysisContext) { + if (headMap == null) { + throw new GenericException(Translator.get("user_import_table_header_missing")); + } + Integer rowIndex = analysisContext.readRowHolder().getRowIndex(); + if (rowIndex >= 3) { + UserExcelData userExcelData = parseDataToModel(data); + buildUpdateOrErrorList(rowIndex, userExcelData); + if (list.size() > BATCH_COUNT) { + saveData(); + this.successCount += list.size(); + list.clear(); + } + } + } + + @Override + public void doAfterAllAnalysed(AnalysisContext analysisContext) { + saveData(); + this.successCount += list.size(); + list.clear(); + } + + private void saveData() { + if (CollectionUtils.isNotEmpty(list)) { + organizationUserService.saveImportData(list, departmentTree, departmentMap, operatorId, orgId); + } + } + + private void buildUpdateOrErrorList(Integer rowIndex, UserExcelData userExcelData) { + StringBuilder errMsg; + try { + String baseErr = ExcelValidateHelper.validateEntity(userExcelData); + errMsg = new StringBuilder(baseErr); + if (StringUtils.isEmpty(errMsg)) { + validate(userExcelData, errMsg); + } + } catch (NoSuchFieldException e) { + errMsg = new StringBuilder(Translator.get("parse_data_error")); + log.error(e.getMessage(), e); + } + + if (StringUtils.isNotEmpty(errMsg)) { + ExcelErrData excelErrData = new ExcelErrData(rowIndex, + Translator.get("number") + .concat(StringUtils.SPACE) + .concat(String.valueOf(rowIndex + 1)).concat(StringUtils.SPACE) + .concat(Translator.get("row")) + .concat(Translator.get("error")) + .concat(":") + .concat(errMsg.toString())); + errList.add(excelErrData); + } else { + list.add(userExcelData); + } + } + + public void validate(UserExcelData data, StringBuilder errMsg) { + validateName(data, errMsg); + validatePhone(data, errMsg); + validateEmail(data, errMsg); + validateDepartment(data, errMsg); + validateLength(data, errMsg); + handleGender(data); + handleEmployeeType(data); + } + + private void handleEmployeeType(UserExcelData data) { + if (StringUtils.isNotEmpty(data.getEmployeeType())) { + if (Strings.CI.equals(data.getEmployeeType(), Translator.get("formal"))) { + data.setEmployeeType("formal"); + } else if (Strings.CI.equals(data.getEmployeeType(), Translator.get("internship"))) { + data.setEmployeeType("internship"); + } else if (Strings.CI.equals(data.getEmployeeType(), Translator.get("outsourcing"))) { + data.setEmployeeType("outsourcing"); + } else { + data.setEmployeeType(null); + } + } + } + + private void handleGender(UserExcelData data) { + if (StringUtils.isNotEmpty(data.getGender())) { + if (data.getGender().equals(Translator.get("man"))) { + data.setGender("false"); + } else if (data.getGender().equals(Translator.get("woman"))) { + data.setGender("true"); + } else { + data.setGender(null); + } + } + } + + private void validateLength(UserExcelData data, StringBuilder errMsg) { + String employeeId = data.getEmployeeId(); + if (StringUtils.isNotBlank(employeeId) && employeeId.length() > NAME_LENGTH) { + errMsg.append(Translator.get("employee_length")).append(ERROR_MSG_SEPARATOR); + } + + String position = data.getPosition(); + if (StringUtils.isNotBlank(position) && position.length() > NAME_LENGTH) { + errMsg.append(Translator.get("position_length")).append(ERROR_MSG_SEPARATOR); + } + } + + private void validateDepartment(UserExcelData data, StringBuilder errMsg) { + String departments = data.getDepartment(); + if (StringUtils.isNotEmpty(departments)) { + String topDepartment = departments.split("/")[0]; + if (!Strings.CI.equals(departmentTree.getFirst().getName(), topDepartment)) { + errMsg.append(Translator.get("top_department_not_exist")).append(ERROR_MSG_SEPARATOR); + } + } else { + data.setDepartment(departmentTree.getFirst().getName()); + } + } + + private void validateEmail(UserExcelData data, StringBuilder errMsg) { + String email = data.getEmail(); + if (StringUtils.isBlank(email)) { + return; // 交给 Bean 校验,不在此重复报错 + } + + Matcher matcher = EMAIL_PATTERN.matcher(email); + if (!matcher.matches()) { + errMsg.append(Translator.get("email_format_error")).append(ERROR_MSG_SEPARATOR); + } + if (organizationUserService.checkEmail(email)) { + errMsg.append(Translator.get("email.exist")).append(ERROR_MSG_SEPARATOR); + } + + if (CollectionUtils.isNotEmpty(list)) { + for (UserExcelData userExcelData : list) { + if (Strings.CI.equals(userExcelData.getEmail(), email)) { + errMsg.append(Translator.get("email.repeat")) + .append(email) + .append(ERROR_MSG_SEPARATOR); + break; + } + } + } + } + + private void validatePhone(UserExcelData data, StringBuilder errMsg) { + String phone = data.getPhone(); + if (StringUtils.isBlank(phone)) { + return; // 交给 Bean 校验,不在此重复报错 + } + + if (phone.length() > PHONE_LENGTH) { + errMsg.append(Translator.get("phone_length")).append(ERROR_MSG_SEPARATOR); + } + if (organizationUserService.checkPhone(phone)) { + errMsg.append(Translator.get("phone.exist")).append(ERROR_MSG_SEPARATOR); + } + + if (!PHONE_PATTERN.matcher(phone).matches()) { + errMsg.append(Translator.get("import_phone_validate")).append(ERROR_MSG_SEPARATOR); + } + + if (CollectionUtils.isNotEmpty(list)) { + for (UserExcelData userExcelData : list) { + if (Strings.CI.equals(userExcelData.getPhone(), phone)) { + errMsg.append(Translator.get("phone.repeat")) + .append(phone) + .append(ERROR_MSG_SEPARATOR); + break; + } + } + } + } + + private void validateName(UserExcelData data, StringBuilder errMsg) { + String name = data.getName(); + if (StringUtils.isNotEmpty(name) && name.length() > NAME_LENGTH) { + errMsg.append(Translator.get("name_length")).append(ERROR_MSG_SEPARATOR); + } + } + + private UserExcelData parseDataToModel(Map row) { + UserExcelData data = new UserExcelDataFactory().getUserExcelDataLocal(); + for (Map.Entry headEntry : headMap.entrySet()) { + Integer index = headEntry.getKey(); + String field = headEntry.getValue(); + if (StringUtils.isBlank(field)) { + continue; + } + String value = StringUtils.defaultString(row.get(index)); + String mappedField = excelHeadToFieldNameDic.getOrDefault(field, field); + + BiConsumer setter = FIELD_SETTERS.get(mappedField); + if (setter != null) { + setter.accept(data, value); + } + } + return data; + } + + // 获取注解里 ExcelProperty 的值映射 + public void genExcelHeadToFieldNameDicAndGetNotRequiredFields() throws NoSuchFieldException { + Field[] fields = excelDataClass.getDeclaredFields(); + for (Field item : fields) { + Field field = excelDataClass.getDeclaredField(item.getName()); + field.setAccessible(true); + ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class); + if (excelProperty != null) { + String value = String.join("", excelProperty.value()); + excelHeadToFieldNameDic.put(value, field.getName()); + } + } + } + + private void formatHeadMap() { + for (Map.Entry entry : headMap.entrySet()) { + String name = entry.getValue(); + String mapped = excelHeadToFieldNameDic.get(name); + if (mapped != null) { + entry.setValue(mapped); + } + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/job/NoticeExpireJob.java b/backend/crm/src/main/java/cn/cordys/crm/system/job/NoticeExpireJob.java new file mode 100644 index 0000000..1db4036 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/job/NoticeExpireJob.java @@ -0,0 +1,405 @@ +package cn.cordys.crm.system.job; + +import cn.cordys.common.util.JSON; +import cn.cordys.crm.contract.domain.Contract; +import cn.cordys.crm.contract.domain.ContractPaymentPlan; +import cn.cordys.crm.contract.mapper.ExtContractMapper; +import cn.cordys.crm.contract.mapper.ExtContractPaymentPlanMapper; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.opportunity.domain.OpportunityQuotation; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityQuotationMapper; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.domain.MessageTask; +import cn.cordys.crm.system.domain.MessageTaskConfig; +import cn.cordys.crm.system.dto.MessageTaskConfigDTO; +import cn.cordys.crm.system.dto.TimeDTO; +import cn.cordys.crm.system.mapper.ExtMessageTaskConfigMapper; +import cn.cordys.crm.system.mapper.ExtMessageTaskMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationMapper; +import cn.cordys.crm.system.notice.CommonNoticeSendService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.quartz.anno.QuartzScheduled; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +/** + * 商机报价单/合同/回款计划到期及即将到期提醒监听器 + *

+ * 该监听器每天8点执行,检查并提醒到期的业务对象。 + *

+ */ +@Component +@Slf4j +public class NoticeExpireJob { + + @Resource + private ExtMessageTaskMapper extMessageTaskMapper; + @Resource + private ExtMessageTaskConfigMapper extMessageTaskConfigMapper; + @Resource + private ExtOpportunityQuotationMapper extOpportunityQuotationMapper; + @Resource + private CommonNoticeSendService commonNoticeSendService; + @Resource + private BaseMapper opportunityBaseMapper; + @Resource + private BaseMapper customerBaseMapper; + @Resource + private BaseMapper contractBaseMapper; + @Resource + private ExtOrganizationMapper extOrganizationMapper; + @Resource + private ExtContractPaymentPlanMapper extContractPaymentPlanMapper; + @Resource + private ExtContractMapper extContractMapper; + + private static final String TIME_UNIT_DAY = "DAY"; + private static final String MODULE_CONTRACT = NotificationConstants.Module.CONTRACT; + private static final String MODULE_OPPORTUNITY = NotificationConstants.Module.OPPORTUNITY; + + /** + * 定时检查到期或即将到期的消息通知任务 + * 每天8点执行一次 + */ + @QuartzScheduled(cron = "0 0 8 * * ?") + public void onEvent() { + try { + // 商机报价单 + processQuotationExpiring(); + processQuotationExpired(); + + // 回款计划 + processPaymentPlanExpiring(); + processPaymentPlanExpired(); + + // 合同 + processContractExpiring(); + processContractExpired(); + } catch (Exception e) { + log.error("消息通知提醒异常: ", e); + } + } + + private void processQuotationExpiring() { + processExpiringRemind( + MODULE_OPPORTUNITY, + NotificationConstants.Event.BUSINESS_QUOTATION_EXPIRING, + this::getOpportunityQuotationList, + this::buildOpportunityNoticeContext + ); + } + + private void processQuotationExpired() { + processExpiredRemind( + MODULE_OPPORTUNITY, + NotificationConstants.Event.BUSINESS_QUOTATION_EXPIRED, + this::getOpportunityQuotationList, + this::buildOpportunityNoticeContext + ); + } + + private NoticeContext buildOpportunityNoticeContext(OpportunityQuotation quotation) { + Opportunity opportunity = opportunityBaseMapper.selectByPrimaryKey(quotation.getOpportunityId()); + if (opportunity == null) { + log.warn("商机报价单 {} 关联的商机不存在", quotation.getId()); + return null; + } + Customer customer = customerBaseMapper.selectByPrimaryKey(opportunity.getCustomerId()); + if (customer == null) { + log.warn("商机报价单 {} 关联的客户不存在", quotation.getId()); + return null; + } + return NoticeContext.builder() + .customerName(customer.getName()) + .createUser(quotation.getCreateUser()) + .owner(quotation.getCreateUser()) + .resourceId(quotation.getId()) + .orgId(quotation.getOrganizationId()) // 关键修复:设置组织ID + .build(); + } + + private NoticeContext buildPaymentPlanNoticeContext(ContractPaymentPlan paymentPlan) { + Contract contract = contractBaseMapper.selectByPrimaryKey(paymentPlan.getContractId()); + if (contract == null) { + log.warn("回款计划 {} 关联的合同不存在", paymentPlan.getId()); + return null; + } + Customer customer = customerBaseMapper.selectByPrimaryKey(contract.getCustomerId()); + if (customer == null) { + log.warn("回款计划 {} 关联的客户不存在", paymentPlan.getId()); + return null; + } + return NoticeContext.builder() + .customerName(customer.getName()) + .createUser(paymentPlan.getCreateUser()) + .owner(paymentPlan.getOwner()) + .resourceId(paymentPlan.getId()) + .orgId(paymentPlan.getOrganizationId()) // 设置组织ID + .build(); + } + + private NoticeContext buildContractNoticeContext(Contract contract) { + Customer customer = customerBaseMapper.selectByPrimaryKey(contract.getCustomerId()); + if (customer == null) { + log.warn("合同 {} 关联的客户不存在", contract.getId()); + return null; + } + return NoticeContext.builder() + .customerName(customer.getName()) + .createUser(contract.getCreateUser()) + .owner(contract.getOwner()) + .resourceId(contract.getId()) + .orgId(contract.getOrganizationId()) // 设置组织ID + .build(); + } + + private void processPaymentPlanExpiring() { + processExpiringRemind( + MODULE_CONTRACT, + NotificationConstants.Event.CONTRACT_PAYMENT_EXPIRING, + this::getContractPaymentPlanList, + this::buildPaymentPlanNoticeContext + ); + } + + private void processPaymentPlanExpired() { + processExpiredRemind( + MODULE_CONTRACT, + NotificationConstants.Event.CONTRACT_PAYMENT_EXPIRED, + this::getContractPaymentPlanList, + this::buildPaymentPlanNoticeContext + ); + } + + private void processContractExpiring() { + processExpiringRemind( + MODULE_CONTRACT, + NotificationConstants.Event.CONTRACT_EXPIRING, + this::getContractList, + this::buildContractNoticeContext + ); + } + + private void processContractExpired() { + processExpiredRemind( + MODULE_CONTRACT, + NotificationConstants.Event.CONTRACT_EXPIRED, + this::getContractList, + this::buildContractNoticeContext + ); + } + + /** + * 处理到期提醒(当天到期) + */ + private void processExpiredRemind( + String module, + String event, + TriFunction> dataFetcher, + Function contextBuilder + ) { + log.info("开始处理 {} 到期提醒", event); + Set organizationIds = extOrganizationMapper.selectAllOrganizationIds(); + + for (String orgId : organizationIds) { + try { + // 1. 检查开关 + MessageTask messageTask = extMessageTaskMapper.getMessageByModuleAndEvent(module, event, orgId); + if (isNotificationEnabled(messageTask)) { + log.debug("组织 {} 的 {} 提醒未开启", orgId, event); + continue; + } + + // 2. 获取配置 + MessageTaskConfig config = extMessageTaskConfigMapper.getConfigByModuleAndEvent(module, event, orgId); + if (config == null || config.getValue() == null) { + log.debug("组织 {} 的 {} 提醒配置不存在", orgId, event); + continue; + } + MessageTaskConfigDTO configDTO = JSON.parseObject(config.getValue(), MessageTaskConfigDTO.class); + + // 3. 查询当天到期的数据 + long todayZero = TimeUtils.getZeroHourTimestamp(0); + long yesterdayZero = TimeUtils.getZeroHourTimestamp(-1); + List dataList = dataFetcher.apply(orgId, todayZero, yesterdayZero); + + if (CollectionUtils.isEmpty(dataList)) { + log.debug("组织 {} 无 {} 到期数据", orgId, event); + continue; + } + + // 4. 发送通知 + for (T item : dataList) { + NoticeContext ctx = contextBuilder.apply(item); + if (ctx != null) { + sendNotice(configDTO, module, ctx, event, null); + } + } + log.info("组织 {} 的 {} 到期提醒处理完成,共 {} 条", orgId, event, dataList.size()); + } catch (Exception e) { + log.error("处理组织 {} 的 {} 到期提醒失败", orgId, event, e); + } + } + } + + /** + * 处理即将到期提醒(按配置的天数提前提醒) + */ + private void processExpiringRemind( + String module, + String event, + TriFunction> dataFetcher, + Function contextBuilder + ) { + log.info("开始处理 {} 即将到期提醒", event); + Set organizationIds = extOrganizationMapper.selectAllOrganizationIds(); + + for (String orgId : organizationIds) { + try { + // 1. 检查开关 + MessageTask messageTask = extMessageTaskMapper.getMessageByModuleAndEvent(module, event, orgId); + if (isNotificationEnabled(messageTask)) { + log.debug("组织 {} 的 {} 提醒未开启", orgId, event); + continue; + } + + // 2. 获取配置 + MessageTaskConfig config = extMessageTaskConfigMapper.getConfigByModuleAndEvent(module, event, orgId); + if (config == null || config.getValue() == null) { + log.debug("组织 {} 的 {} 提醒配置不存在", orgId, event); + continue; + } + MessageTaskConfigDTO configDTO = JSON.parseObject(config.getValue(), MessageTaskConfigDTO.class); + if (CollectionUtils.isEmpty(configDTO.getTimeList())) { + log.debug("组织 {} 的 {} 时间配置为空", orgId, event); + continue; + } + + // 3. 遍历每个提前天数配置 + for (TimeDTO timeDTO : configDTO.getTimeList()) { + if (!TIME_UNIT_DAY.equals(timeDTO.getTimeUnit())) { + continue; + } + int days = timeDTO.getTimeValue(); + long startTime = TimeUtils.getZeroHourTimestamp(days + 1); + long endTime = TimeUtils.getZeroHourTimestamp(days); + + List dataList = dataFetcher.apply(orgId, startTime, endTime); + if (CollectionUtils.isEmpty(dataList)) { + log.debug("组织 {} 提前 {} 天无 {} 数据", orgId, days, event); + continue; + } + + for (T item : dataList) { + NoticeContext ctx = contextBuilder.apply(item); + if (ctx != null) { + sendNotice(configDTO, module, ctx, event, days); + } + } + } + log.info("组织 {} 的 {} 即将到期提醒处理完成", orgId, event); + } catch (Exception e) { + log.error("处理组织 {} 的 {} 即将到期提醒失败", orgId, event, e); + } + } + } + + /** + * 判断通知渠道是否至少开启一种 + */ + private boolean isNotificationEnabled(MessageTask task) { + if (task == null) { + return true; + } + return !Boolean.TRUE.equals(task.getDingTalkEnable()) + && !Boolean.TRUE.equals(task.getEmailEnable()) + && !Boolean.TRUE.equals(task.getSysEnable()) + && !Boolean.TRUE.equals(task.getWeComEnable()) + && !Boolean.TRUE.equals(task.getLarkEnable()); + } + + /** + * 发送通知 + */ + private void sendNotice(MessageTaskConfigDTO configDTO, String model, + NoticeContext ctx, String event, Integer days) { + List receiveUserIds = commonNoticeSendService.getNoticeReceiveUserIds( + configDTO, ctx.getCreateUser(), ctx.getOwner(), ctx.getOrgId()); + + Map paramMap = new HashMap<>(); + paramMap.put("customerName", ctx.getCustomerName()); + paramMap.put("name", ctx.getCustomerName()); + if (days != null) { + paramMap.put("expireDays", days); + } + if (ctx.getResourceId() != null) { + paramMap.put("resourceId", ctx.getResourceId()); + } + + commonNoticeSendService.sendNotice(model, event, paramMap, + ctx.getUserId(), ctx.getOrgId(), receiveUserIds, false); + } + + private List getContractList(String orgId, long timestamp, long timestampOld) { + return extContractMapper.selectByTimestamp(orgId, timestampOld, timestamp); + } + + private List getOpportunityQuotationList(String orgId, long timestamp, long timestampOld) { + return extOpportunityQuotationMapper.getQuotationByTimestamp(timestamp, timestampOld, orgId); + } + + private List getContractPaymentPlanList(String orgId, long timestamp, long timestampOld) { + return extContractPaymentPlanMapper.selectByTimestamp(timestamp, timestampOld, orgId); + } + + /** + * 时间计算工具 + */ + private static final class TimeUtils { + private static final ZoneId ZONE = ZoneId.systemDefault(); + + static long getZeroHourTimestamp(int daysOffset) { + return LocalDate.now().plusDays(daysOffset) + .atStartOfDay(ZONE) + .toInstant() + .toEpochMilli(); + } + } + + /** + * 函数式接口:三个参数,一个返回值 + */ + @FunctionalInterface + private interface TriFunction { + R apply(T t, U u, V v); + } + + /** + * 通知上下文数据对象 + */ + @lombok.Builder + @lombok.Getter + private static class NoticeContext { + private String customerName; + private String createUser; + private String owner; + private String resourceId; + private String userId; + private String orgId; + + public String getUserId() { + return userId != null ? userId : createUser; + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/job/NotifyOnJob.java b/backend/crm/src/main/java/cn/cordys/crm/system/job/NotifyOnJob.java new file mode 100644 index 0000000..0d565a7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/job/NotifyOnJob.java @@ -0,0 +1,77 @@ +package cn.cordys.crm.system.job; + +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.dto.response.AnnouncementDTO; +import cn.cordys.crm.system.mapper.ExtAnnouncementMapper; +import cn.cordys.crm.system.service.AnnouncementService; +import cn.cordys.quartz.anno.QuartzScheduled; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; + +@Component +@Slf4j +public class NotifyOnJob { + private static final String ANNOUNCE_PREFIX = "announce_content:"; // Redis 存储信息前缀 + @Resource + private ExtAnnouncementMapper extAnnouncementMapper; + @Resource + private AnnouncementService announcementService; + @Resource + private StringRedisTemplate stringRedisTemplate; + + @QuartzScheduled(cron = "0 0/5 * * * ?") + public void onEvent() { + try { + this.addNotification(); + } catch (Exception e) { + log.error("公告通知异常: {}", e.getMessage()); + } + } + + /** + * 将到期发布的公告转成通知 + */ + public void addNotification() { + LocalDateTime dateTime = LocalDateTime.now(); + long timestamp = dateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + this.doAddNotification(timestamp); + } + + private void doAddNotification(long timestamp) { + //查询所有在这个时间内生效的公告 + List announcements = extAnnouncementMapper.selectInEffectUnConvertData(timestamp); + if (CollectionUtils.isEmpty(announcements)) { + return; + } + log.info("公告通知数量: {}", announcements.size()); + //将公告根据接收人生成相关的通知 + List ids = new ArrayList<>(); + for (AnnouncementDTO announcementDTO : announcements) { + List userIds = JSON.parseArray(new String(announcementDTO.getReceiver()), String.class); + announcementService.convertNotification("admin", announcementDTO, userIds); + ids.add(announcementDTO.getId()); + } + extAnnouncementMapper.updateNotice(ids, true, announcements.getFirst().getOrganizationId()); + //删除已过期公告的推送 + LocalDateTime dateTime = LocalDateTime.now(); + long expiredStamp = dateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + LocalDateTime startTime = LocalDateTime.now().minusDays(1L); + long startStamp = startTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + + List expiredIds = extAnnouncementMapper.selectFixTimeExpiredIds(startStamp, expiredStamp); + if (CollectionUtils.isNotEmpty(expiredIds)) { + for (String expiredId : expiredIds) { + stringRedisTemplate.delete(ANNOUNCE_PREFIX + expiredId); + } + } + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/job/SessionJob.java b/backend/crm/src/main/java/cn/cordys/crm/system/job/SessionJob.java new file mode 100644 index 0000000..08b8e26 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/job/SessionJob.java @@ -0,0 +1,90 @@ +package cn.cordys.crm.system.job; + +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.service.SystemService; +import cn.cordys.quartz.anno.QuartzScheduled; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.reflect.MethodUtils; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.session.data.redis.RedisIndexedSessionRepository; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.Map; + +@Component +@Slf4j +public class SessionJob { + + @Resource + private StringRedisTemplate stringRedisTemplate; + + @Resource + private RedisIndexedSessionRepository redisIndexedSessionRepository; + + @Resource + private SystemService systemService; + + /** + * 定时清理没有绑定用户的会话。 + *

+ * 该方法每晚 0 点 2 分执行,扫描 Redis 中的会话数据,删除没有绑定用户信息的会话。 + * 此外,还会处理一些特殊情况,如 Redisson 设置了过期时间为 -1 时,手动设置过期时间。 + *

+ * + *

+ * 该方法使用 {@link QuartzScheduled} 注解定时执行,并使用 {@link ScanOptions} 扫描 Redis 中的会话。 + *

+ *

+ * spring.session.timeout=30d + * server.servlet.session.timeout=30d + */ + @QuartzScheduled(cron = "0 2 0 * * ?") + public void cleanSession() { + Map userCount = new HashMap<>(); + ScanOptions options = ScanOptions.scanOptions().match("spring:session:sessions:*").count(1000).build(); + + try (Cursor scan = stringRedisTemplate.scan(options)) { + while (scan.hasNext()) { + String key = scan.next(); + if (key.contains("spring:session:sessions:expires:")) { + continue; + } + + String sessionId = key.substring(key.lastIndexOf(":") + 1); + Boolean exists = stringRedisTemplate.opsForHash().hasKey(key, "sessionAttr:user"); + + // 删除没有绑定用户的会话 + if (!exists) { + redisIndexedSessionRepository.deleteById(sessionId); + } else { + // 获取用户信息并检查会话过期时间 + Object user = redisIndexedSessionRepository.getSessionRedisOperations().opsForHash().get(key, "sessionAttr:user"); + Long expire = redisIndexedSessionRepository.getSessionRedisOperations().getExpire(key); + + assert user != null; + String userId = (String) MethodUtils.invokeMethod(user, "getId"); + userCount.merge(userId, 1L, Long::sum); + + // 记录日志并检查会话的过期时间 + log.info("{} : {} 过期时间: {}", key, userId, expire); + + // 如果过期时间为 -1,则手动设置过期时间为 30 秒 + if (expire != null && expire == -1) { + redisIndexedSessionRepository.getSessionRedisOperations().expire(key, Duration.of(30, ChronoUnit.SECONDS)); + } + } + } + // 清理缓存 + systemService.clearFormCache(); + log.info("用户会话统计: {}", JSON.toJSONString(userCount)); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/job/TaskCleanupJob.java b/backend/crm/src/main/java/cn/cordys/crm/system/job/TaskCleanupJob.java new file mode 100644 index 0000000..460c5fe --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/job/TaskCleanupJob.java @@ -0,0 +1,39 @@ +package cn.cordys.crm.system.job; + +import cn.cordys.crm.system.job.listener.ExecuteEvent; +import cn.cordys.quartz.anno.QuartzScheduled; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class TaskCleanupJob { + + private final ApplicationEventPublisher publisher; + + @Autowired + public TaskCleanupJob(ApplicationEventPublisher publisher) { + this.publisher = publisher; + } + + /** + * 系统定时任务执行 + *

+ * 该方法每晚 3 点执行,清理过期的任务和释放资源。 + *

+ */ + @QuartzScheduled(cron = "0 0 3 * * ?") + public void execute() { + runAll(); + } + + + public void runAll() { + log.info("开始执行所有清理任务"); + publisher.publishEvent(new ExecuteEvent(this)); + log.info("所有清理任务执行完成"); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/CleanExportResourceListener.java b/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/CleanExportResourceListener.java new file mode 100644 index 0000000..9123345 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/CleanExportResourceListener.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.system.job.listener; + +import cn.cordys.crm.system.service.ExportTaskCenterService; +import jakarta.annotation.Resource; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +/** + * @author song-cc-rock + */ +@Component +public class CleanExportResourceListener implements ApplicationListener { + + @Resource + private ExportTaskCenterService exportResourceService; + + + @Override + public void onApplicationEvent(ExecuteEvent event) { + exportResourceService.clean(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/CleanTempResourceListener.java b/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/CleanTempResourceListener.java new file mode 100644 index 0000000..c19b946 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/CleanTempResourceListener.java @@ -0,0 +1,42 @@ +package cn.cordys.crm.system.job.listener; + +import cn.cordys.crm.system.service.FileCommonService; +import cn.cordys.file.engine.DefaultRepositoryDir; +import cn.cordys.file.engine.FileRequest; +import cn.cordys.file.engine.StorageType; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +/** + * 临时资源清理监听器 + * 负责监听执行事件并清理系统临时目录中的资源文件 + */ +@Component +@Slf4j +public class CleanTempResourceListener implements ApplicationListener { + + @Resource + private FileCommonService fileCommonService; + + @Override + public void onApplicationEvent(ExecuteEvent event) { + clean(); + } + + /** + * 清理临时图片资源 + * 删除系统临时目录中的所有资源文件 + */ + private void clean() { + log.info("开始清理临时目录资源"); + try { + FileRequest request = new FileRequest(DefaultRepositoryDir.getTmpDir(), StorageType.LOCAL.name(), null); + fileCommonService.cleanTempResource(request); + log.info("临时目录资源清理完成"); + } catch (Exception e) { + log.error("临时目录资源清理异常", e); + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/CluePoolRecycleListener.java b/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/CluePoolRecycleListener.java new file mode 100644 index 0000000..20be042 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/CluePoolRecycleListener.java @@ -0,0 +1,174 @@ +package cn.cordys.crm.system.job.listener; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.crm.clue.domain.Clue; +import cn.cordys.crm.clue.domain.CluePool; +import cn.cordys.crm.clue.domain.CluePoolRecycleRule; +import cn.cordys.crm.clue.mapper.ExtClueMapper; +import cn.cordys.crm.clue.service.ClueOwnerHistoryService; +import cn.cordys.crm.clue.service.CluePoolService; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.notice.CommonNoticeSendService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 线索池回收监听器 + * 负责监听执行事件并将符合条件的线索回收到线索池 + */ +@Component +@Slf4j +public class CluePoolRecycleListener implements ApplicationListener { + + @Resource + private BaseMapper clueMapper; + @Resource + private BaseMapper cluePoolMapper; + @Resource + private ExtClueMapper extClueMapper; + @Resource + private BaseMapper cluePoolRecycleRuleMapper; + @Resource + private CluePoolService cluePoolService; + @Resource + private ClueOwnerHistoryService clueOwnerHistoryService; + @Resource + private CommonNoticeSendService commonNoticeSendService; + + @Override + public void onApplicationEvent(ExecuteEvent event) { + try { + recycle(); + } catch (Exception e) { + log.error("定时回收线索池异常:", e.getMessage()); + } + } + + /** + * 回收线索到线索池 + */ + public void recycle() { + log.info("开始回收线索资源"); + + // 获取启用的自动回收线索池 + List enabledPools = getEnabledPools(); + if (CollectionUtils.isEmpty(enabledPools)) { + log.info("没有启用的自动回收线索池,回收任务结束"); + return; + } + + // 获取所有者到线索池的最佳匹配映射 + Map, CluePool> ownersPoolMap = cluePoolService.getOwnersBestMatchPoolMap(enabledPools); + List recycleOwnersIds = ownersPoolMap.keySet().stream() + .flatMap(List::stream) + .toList(); + + // 获取需要检查的线索列表 + List clues = getCluesForRecycle(recycleOwnersIds); + if (CollectionUtils.isEmpty(clues)) { + log.info("没有需要回收的线索,回收任务结束"); + return; + } + + // 获取线索池回收规则 + Map recycleRuleMap = getRecycleRules(enabledPools); + + // 处理每个线索的回收 + processCluesRecycle(clues, ownersPoolMap, recycleRuleMap); + + log.info("线索资源回收完成"); + } + + /** + * 获取启用的自动回收线索池 + */ + private List getEnabledPools() { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(CluePool::getEnable, true) + .eq(CluePool::getAuto, true); + return cluePoolMapper.selectListByLambda(queryWrapper); + } + + /** + * 获取需要检查回收的线索 + */ + private List getCluesForRecycle(List ownerIds) { + LambdaQueryWrapper clueQueryWrapper = new LambdaQueryWrapper<>(); + clueQueryWrapper.in(Clue::getOwner, ownerIds) + .eq(Clue::getInSharedPool, false); + return clueMapper.selectListByLambda(clueQueryWrapper); + } + + /** + * 获取线索池回收规则 + */ + private Map getRecycleRules(List pools) { + List poolIds = pools.stream() + .map(CluePool::getId) + .toList(); + LambdaQueryWrapper ruleQueryWrapper = new LambdaQueryWrapper<>(); + ruleQueryWrapper.in(CluePoolRecycleRule::getPoolId, poolIds); + List recycleRules = cluePoolRecycleRuleMapper.selectListByLambda(ruleQueryWrapper); + return recycleRules.stream() + .collect(Collectors.toMap(CluePoolRecycleRule::getPoolId, rule -> rule)); + } + + /** + * 处理线索回收 + */ + private void processCluesRecycle(List clues, Map, CluePool> ownersPoolMap, + Map recycleRuleMap) { + clues.forEach(clue -> + ownersPoolMap.forEach((ownerIds, pool) -> { + if (ownerIds.contains(clue.getOwner()) && StringUtils.isBlank(clue.getTransitionId())) { + // 满足负责人配置自动回收&&未转客户的线索 + CluePoolRecycleRule rule = recycleRuleMap.get(pool.getId()); + if (rule != null && cluePoolService.checkRecycled(clue, rule)) { + recycleClueToPool(clue, pool); + } + } + }) + ); + } + + /** + * 将线索回收到线索池 + */ + private void recycleClueToPool(Clue clue, CluePool pool) { + // 发送消息通知 + commonNoticeSendService.sendNotice( + NotificationConstants.Module.CLUE, + NotificationConstants.Event.CLUE_AUTOMATIC_MOVE_POOL, + clue.getName(), + InternalUser.ADMIN.getValue(), + clue.getOrganizationId(), + List.of(clue.getOwner()), + true + ); + + // 记录责任人历史 + clueOwnerHistoryService.add(clue, InternalUser.ADMIN.getValue(), false); + + // 更新线索数据 + clue.setPoolId(pool.getId()); + clue.setInSharedPool(true); + clue.setOwner(null); + clue.setCollectionTime(null); + clue.setReasonId("system"); + clue.setUpdateUser(InternalUser.ADMIN.getValue()); + clue.setUpdateTime(System.currentTimeMillis()); + + // 回收线索至线索池 + extClueMapper.moveToPool(clue); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/CustomerPoolRecycleListener.java b/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/CustomerPoolRecycleListener.java new file mode 100644 index 0000000..4afad97 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/CustomerPoolRecycleListener.java @@ -0,0 +1,203 @@ +package cn.cordys.crm.system.job.listener; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.domain.CustomerPool; +import cn.cordys.crm.customer.domain.CustomerPoolRecycleRule; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.crm.customer.service.CustomerContactService; +import cn.cordys.crm.customer.service.CustomerOwnerHistoryService; +import cn.cordys.crm.customer.service.CustomerPoolService; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.notice.CommonNoticeSendService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 客户池回收任务 + *

+ * 该定时任务负责根据设定的规则自动将符合条件的客户从个人池回收到公共客户池。 + * 回收规则基于CustomerPoolRecycleRule中定义的条件,如最后跟进时间、最后更新时间等。 + *

+ */ +@Component +@Slf4j +public class CustomerPoolRecycleListener implements ApplicationListener { + + @Resource + private BaseMapper customerMapper; + + @Resource + private BaseMapper customerPoolMapper; + + @Resource + private BaseMapper customerPoolRecycleRuleMapper; + + @Resource + private ExtCustomerMapper extCustomerMapper; + + @Resource + private CustomerPoolService customerPoolService; + + @Resource + private CustomerOwnerHistoryService customerOwnerHistoryService; + + @Resource + private CommonNoticeSendService commonNoticeSendService; + @Resource + private CustomerContactService customerContactService; + + + @Override + public void onApplicationEvent(ExecuteEvent event) { + try { + this.recycle(); + } catch (Exception e) { + log.error("回收客户资源异常: ", e.getMessage()); + } + } + + /** + * 执行客户回收 + *

+ * 回收流程: + * 1. 获取启用了自动回收的客户池 + * 2. 找出符合回收规则的客户 + * 3. 执行回收操作并发送通知 + *

+ */ + public void recycle() { + log.info("开始回收客户资源"); + + // 获取启用了自动回收的客户池 + List pools = getEnabledAutomaticPools(); + if (CollectionUtils.isEmpty(pools)) { + log.info("没有启用的自动回收公海,回收任务结束"); + return; + } + + // 获取池与负责人的映射关系 + Map, CustomerPool> ownersDefaultPoolMap = customerPoolService.getOwnersBestMatchPoolMap(pools); + List recycleOwnersIds = ownersDefaultPoolMap.keySet().stream().flatMap(List::stream).toList(); + + // 查询符合条件的客户 + List customers = getCustomersForRecycle(recycleOwnersIds); + if (CollectionUtils.isEmpty(customers)) { + log.info("没有需要回收的客户,回收任务结束"); + return; + } + + // 获取回收规则 + Map recycleRuleMap = getPoolRecycleRules(pools); + + // 执行回收操作 + recycleCustomers(customers, ownersDefaultPoolMap, recycleRuleMap); + + log.info("客户资源回收完成"); + } + + /** + * 获取已启用且设置为自动回收的客户池 + * + * @return 符合条件的客户池列表 + */ + private List getEnabledAutomaticPools() { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(CustomerPool::getEnable, true).eq(CustomerPool::getAuto, true); + return customerPoolMapper.selectListByLambda(queryWrapper); + } + + /** + * 获取需要被考虑回收的客户列表 + * + * @param recycleOwnersIds 需要考虑回收的负责人ID列表 + * + * @return 符合初步条件的客户列表 + */ + private List getCustomersForRecycle(List recycleOwnersIds) { + LambdaQueryWrapper customerQueryWrapper = new LambdaQueryWrapper<>(); + customerQueryWrapper.in(Customer::getOwner, recycleOwnersIds).eq(Customer::getInSharedPool, false); + return customerMapper.selectListByLambda(customerQueryWrapper); + } + + /** + * 获取客户池回收规则 + * + * @param pools 客户池列表 + * + * @return 池ID到回收规则的映射 + */ + private Map getPoolRecycleRules(List pools) { + List poolIds = pools.stream().map(CustomerPool::getId).toList(); + LambdaQueryWrapper ruleQueryWrapper = new LambdaQueryWrapper<>(); + ruleQueryWrapper.in(CustomerPoolRecycleRule::getPoolId, poolIds); + List recycleRules = customerPoolRecycleRuleMapper.selectListByLambda(ruleQueryWrapper); + return recycleRules.stream().collect(Collectors.toMap(CustomerPoolRecycleRule::getPoolId, r -> r)); + } + + /** + * 执行客户回收操作 + * + * @param customers 待检查回收的客户列表 + * @param ownersDefaultPoolMap 负责人与池的映射关系 + * @param recycleRuleMap 池ID与回收规则的映射 + */ + private void recycleCustomers(List customers, Map, CustomerPool> ownersDefaultPoolMap, + Map recycleRuleMap) { + customers.forEach(customer -> ownersDefaultPoolMap.forEach((ownerIds, pool) -> { + if (ownerIds.contains(customer.getOwner())) { + CustomerPoolRecycleRule rule = recycleRuleMap.get(pool.getId()); + boolean recycle = customerPoolService.checkRecycled(customer, rule); + if (recycle) { + processCustomerRecycle(customer, pool); + } + } + })); + } + + /** + * 处理单个客户的回收流程 + * + * @param customer 需要回收的客户 + * @param pool 目标客户池 + */ + private void processCustomerRecycle(Customer customer, CustomerPool pool) { + //更新责任人 + customerContactService.updateContactOwner(customer.getId(), "-", customer.getOwner(), customer.getOrganizationId()); + + // 消息通知 + commonNoticeSendService.sendNotice( + NotificationConstants.Module.CUSTOMER, + NotificationConstants.Event.CUSTOMER_AUTOMATIC_MOVE_HIGH_SEAS, + customer.getName(), + InternalUser.ADMIN.getValue(), + customer.getOrganizationId(), + List.of(customer.getOwner()), + true + ); + + // 插入责任人历史 + customerOwnerHistoryService.add(customer, InternalUser.ADMIN.getValue(), false); + + // 更新客户信息 + customer.setPoolId(pool.getId()); + customer.setInSharedPool(true); + customer.setOwner(null); + customer.setCollectionTime(null); + customer.setReasonId("system"); + customer.setUpdateUser(InternalUser.ADMIN.getValue()); + customer.setUpdateTime(System.currentTimeMillis()); + + // 回收客户至公海 + extCustomerMapper.moveToPool(customer); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/ExecuteEvent.java b/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/ExecuteEvent.java new file mode 100644 index 0000000..4c840a3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/ExecuteEvent.java @@ -0,0 +1,9 @@ +package cn.cordys.crm.system.job.listener; + +import org.springframework.context.ApplicationEvent; + +public class ExecuteEvent extends ApplicationEvent { + public ExecuteEvent(Object source) { + super(source); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/FollowUpPlanRemindListener.java b/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/FollowUpPlanRemindListener.java new file mode 100644 index 0000000..726b2ba --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/FollowUpPlanRemindListener.java @@ -0,0 +1,155 @@ +package cn.cordys.crm.system.job.listener; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.clue.mapper.ExtClueMapper; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.crm.follow.domain.FollowUpPlan; +import cn.cordys.crm.follow.mapper.ExtFollowUpPlanMapper; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityMapper; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.notice.CommonNoticeSendService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 跟进计划提醒监听器 + *

+ * 该监听器负责监听执行事件,当触发时检查并提醒到期的跟进计划。 + * 支持客户、商机和线索三种类型的跟进计划提醒。 + *

+ */ +@Component +@Slf4j +public class FollowUpPlanRemindListener implements ApplicationListener { + + @Resource + private ExtFollowUpPlanMapper extFollowUpPlanMapper; + @Resource + private CommonNoticeSendService commonNoticeSendService; + @Resource + private ExtCustomerMapper extCustomerMapper; + @Resource + private ExtOpportunityMapper extOpportunityMapper; + @Resource + private ExtClueMapper extClueMapper; + + /** + * 处理执行事件 + *

+ * 当接收到执行事件时,触发跟进计划提醒功能 + *

+ * + * @param event 执行事件对象 + */ + @Override + public void onApplicationEvent(ExecuteEvent event) { + try { + this.followUpPlanRemind(); + } catch (Exception e) { + log.error("跟进计划到期提醒异常: ", e.getMessage()); + } + } + + /** + * 跟进计划到期自动通知 + *

+ * 查询当天到期的跟进计划,并向相关负责人发送通知提醒。 + *

+ */ + public void followUpPlanRemind() { + log.info("跟进计划到期提醒"); + // 获取当天零点的时间戳(毫秒) + long timestamp = LocalDate.now() + .atStartOfDay(ZoneId.systemDefault()) + .toEpochSecond() * 1000; + + // 查询到期的跟进计划列表 + List planList = extFollowUpPlanMapper.selectPlanByTimestamp(timestamp); + + if (CollectionUtils.isNotEmpty(planList)) { + // 提取客户、商机和线索的ID列表 + List customerIds = planList.stream().map(FollowUpPlan::getCustomerId).toList(); + List opportunityIds = planList.stream().map(FollowUpPlan::getOpportunityId).toList(); + List clueIds = planList.stream().map(FollowUpPlan::getClueId).toList(); + + // 构建客户ID与名称的映射 + Map customerMap = new HashMap<>(); + if (CollectionUtils.isNotEmpty(customerIds)) { + customerMap = extCustomerMapper.selectOptionByIds(customerIds) + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + } + + // 构建商机ID与名称的映射 + Map opportunityMap = new HashMap<>(); + if (CollectionUtils.isNotEmpty(opportunityIds)) { + opportunityMap = extOpportunityMapper.getOpportunityOptionsByIds(opportunityIds) + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + } + + // 构建线索ID与名称的映射 + Map clueMap = new HashMap<>(); + if (CollectionUtils.isNotEmpty(clueIds)) { + clueMap = extClueMapper.selectOptionByIds(clueIds) + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + } + + // 遍历所有到期的跟进计划,发送相应通知 + for (FollowUpPlan followUpPlan : planList) { + // 发送客户跟进计划提醒 + if (StringUtils.isNotBlank(followUpPlan.getCustomerId())) { + commonNoticeSendService.sendNotice( + NotificationConstants.Module.CUSTOMER, + NotificationConstants.Event.CUSTOMER_FOLLOW_UP_PLAN_DUE, + customerMap.get(followUpPlan.getCustomerId()), + followUpPlan.getCreateUser(), + followUpPlan.getOrganizationId(), + List.of(followUpPlan.getOwner()), + false + ); + } + + // 发送商机跟进计划提醒 + if (StringUtils.isNotBlank(followUpPlan.getOpportunityId())) { + commonNoticeSendService.sendNotice( + NotificationConstants.Module.OPPORTUNITY, + NotificationConstants.Event.BUSINESS_FOLLOW_UP_PLAN_DUE, + opportunityMap.get(followUpPlan.getOpportunityId()), + followUpPlan.getCreateUser(), + followUpPlan.getOrganizationId(), + List.of(followUpPlan.getOwner()), + false + ); + } + + // 发送线索跟进计划提醒 + if (StringUtils.isNotBlank(followUpPlan.getClueId())) { + commonNoticeSendService.sendNotice( + NotificationConstants.Module.CLUE, + NotificationConstants.Event.CLUE_FOLLOW_UP_PLAN_DUE, + clueMap.get(followUpPlan.getClueId()), + followUpPlan.getCreateUser(), + followUpPlan.getOrganizationId(), + List.of(followUpPlan.getOwner()), + false + ); + } + } + } + + log.info("跟进计划到期提醒完成"); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/OpportunityRuleListener.java b/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/OpportunityRuleListener.java new file mode 100644 index 0000000..b968b04 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/job/listener/OpportunityRuleListener.java @@ -0,0 +1,122 @@ +package cn.cordys.crm.system.job.listener; + +import cn.cordys.crm.opportunity.constants.OpportunityStageType; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.opportunity.domain.OpportunityRule; +import cn.cordys.crm.opportunity.domain.OpportunityStageConfig; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityStageConfigMapper; +import cn.cordys.crm.opportunity.service.OpportunityRuleService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 商机规则监听器 + *

+ * 此监听器负责监听定时执行事件,根据预设规则自动处理商机状态。 + * 当触发执行事件时,会根据启用的自动化规则检查所有相关商机, + * 并根据规则条件自动关闭符合条件的商机。 + *

+ */ +@Component +@Slf4j +public class OpportunityRuleListener implements ApplicationListener { + + @Resource + private BaseMapper opportunityRuleMapper; + + @Resource + private OpportunityRuleService opportunityRuleService; + + @Resource + private BaseMapper opportunityMapper; + @Resource + private ExtOpportunityStageConfigMapper extOpportunityStageConfigMapper; + + /** + * 处理应用事件 + *

+ * 当接收到执行事件时,触发商机规则处理逻辑。 + *

+ * + * @param event 执行事件 + */ + @Override + public void onApplicationEvent(ExecuteEvent event) { + try { + execute(); + } catch (Exception e) { + log.error("商机资源回收异常: ", e.getMessage()); + } + } + + /** + * 执行商机关闭规则任务 + *

+ * 此方法执行以下步骤: + * 1. 查询所有已启用且设置为自动执行的商机规则 + * 2. 获取规则对应的最佳匹配所有者映射关系 + * 3. 查询相关所有者的所有商机 + * 4. 根据规则检查每个商机是否需要关闭 + * 5. 自动关闭符合条件的商机 + *

+ */ + public void execute() { + log.info("开始回收商机资源"); + + // 查询已启用的自动执行规则 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(OpportunityRule::getEnable, true).eq(OpportunityRule::getAuto, true); + List rules = opportunityRuleMapper.selectListByLambda(queryWrapper); + if (CollectionUtils.isEmpty(rules)) { + log.info("没有启用的自动回收商机规则,回收任务结束"); + return; + } + + // 获取所有者与规则的最佳匹配映射 + Map, OpportunityRule> ownersBestMatchRuleMap = opportunityRuleService.getOwnersBestMatchRuleMap(rules); + List handleOwnerIds = ownersBestMatchRuleMap.keySet().stream().flatMap(List::stream).toList(); + + // 查询相关所有者的商机 + LambdaQueryWrapper opportunityWrapper = new LambdaQueryWrapper<>(); + opportunityWrapper.in(Opportunity::getOwner, handleOwnerIds); + List opportunities = opportunityMapper.selectListByLambda(opportunityWrapper); + if (CollectionUtils.isEmpty(opportunities)) { + log.info("没有需要回收的商机,回收任务结束"); + return; + } + + List stageConfigList = extOpportunityStageConfigMapper.getAllStageConfigList(); + + Map orgFailStageConfigMap = stageConfigList.stream() + .filter(config -> + Strings.CI.equals(config.getType(), OpportunityStageType.END.name()) && Strings.CI.equals(config.getRate(), "0")) + .collect(Collectors.toMap( + OpportunityStageConfig::getOrganizationId, + OpportunityStageConfig::getId)); + + // 根据规则处理每个商机 + opportunities.forEach(opportunity -> ownersBestMatchRuleMap.forEach((ownerIds, rule) -> { + if (ownerIds.contains(opportunity.getOwner())) { + boolean closed = opportunityRuleService.checkClosed(opportunity, rule); + if (closed) { + opportunity.setLastStage(opportunity.getStage()); + opportunity.setStage(orgFailStageConfigMap.get(opportunity.getOrganizationId())); + opportunity.setFailureReason("system"); + opportunityMapper.updateById(opportunity); + } + } + })); + + log.info("商机资源回收完成"); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtAnnouncementMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtAnnouncementMapper.java new file mode 100644 index 0000000..4990f55 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtAnnouncementMapper.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.crm.system.dto.request.AnnouncementPageRequest; +import cn.cordys.crm.system.dto.response.AnnouncementDTO; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ExtAnnouncementMapper { + List selectByBaseRequest(@Param("request") AnnouncementPageRequest request); + + AnnouncementDTO selectById(@Param("id") String id); + + List selectInEffectUnConvertData(@Param("timestamp") long timestamp); + + List selectFixTimeExpiredIds(@Param("startTime") long startTime, @Param("endTime") long endTime); + + int updateNotice(@Param("ids") List ids, @Param("notice") Boolean notice, @Param("organizationId") String organizationId); + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtAnnouncementMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtAnnouncementMapper.xml new file mode 100644 index 0000000..6270534 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtAnnouncementMapper.xml @@ -0,0 +1,58 @@ + + + + + + + UPDATE sys_announcement SET notice = #{notice} WHERE organization_id = #{organizationId} + + and id in + + #{id} + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtAttachmentMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtAttachmentMapper.java new file mode 100644 index 0000000..de81b15 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtAttachmentMapper.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.system.mapper; + +import java.util.List; + +public interface ExtAttachmentMapper { + + /** + * 通过附件名称获取附件ID列表 + * + * @param names 名称列表 + * + * @return 附件ID列表 + */ + List getAttachmentIdsByNames(List names); + + List selectNameByIds(List ids); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtAttachmentMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtAttachmentMapper.xml new file mode 100644 index 0000000..765b4ce --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtAttachmentMapper.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDepartmentCommanderMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDepartmentCommanderMapper.java new file mode 100644 index 0000000..cab30ba --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDepartmentCommanderMapper.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.system.mapper; + + +import cn.cordys.crm.system.domain.DepartmentCommander; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtDepartmentCommanderMapper { + + void deleteByDepartmentIds(@Param("departmentIds") List departmentIds); + + List selectCommander(@Param("departmentId") String departmentId, @Param("orgId") String orgId); + + List selectCommanderByUsers(@Param("departmentId") String departmentId, @Param("userIds") List userIds); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDepartmentCommanderMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDepartmentCommanderMapper.xml new file mode 100644 index 0000000..4ccddff --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDepartmentCommanderMapper.xml @@ -0,0 +1,36 @@ + + + + + + + + DELETE FROM sys_department_commander WHERE department_id in + + #{departmentId} + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDepartmentMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDepartmentMapper.java new file mode 100644 index 0000000..6270f97 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDepartmentMapper.java @@ -0,0 +1,58 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.common.dto.*; +import cn.cordys.crm.system.domain.Department; +import cn.cordys.crm.system.domain.DepartmentCommander; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + + +public interface ExtDepartmentMapper { + + List selectTreeNode(@Param("orgId") String orgId); + + List selectDeptUserTreeNode(@Param("orgId") String orgId); + + Long getNextPosByOrgId(@Param("orgId") String orgId); + + List getUserIdsByDeptIds(@Param("deptIds") List deptIds); + + List getIdNameByIds(@Param("ids") List ids); + + List getNameByIds(@Param("ids") List ids); + + List getIdsByNames(@Param("names") List names); + + void deleteByOrgId(@Param("orgId") String orgId); + + List selectAllDepartmentIds(@Param("orgId") String orgId); + + BaseTreeNode selectDepartment(@Param("name") String name, @Param("orgId") String orgId); + + int countByName(@Param("name") String name, @Param("parentId") String parentId, @Param("orgId") String orgId); + + BaseTree selectBaseTreeById(@Param("dragNodeId") String dragNodeId); + + BaseTree selectTreeByParentIdAndPosOperator(@Param("nodeSortQueryParam") NodeSortQueryParam nodeSortQueryParam); + + List selectChildrenIds(@Param("parentId") String parentId); + + List selectChildrenByIds(@Param("deptIds") List deptIds); + + void batchUpdate(@Param("departmentList") List departmentList); + + Department getInternalDepartment(@Param("orgId") String orgId, @Param("resource") String resource); + + void updateDepartment(@Param("department") Department department); + + List getDepartmentByOrgId(@Param("orgId") String orgId); + + List getDepartmentCommander(@Param("userIds") List userIds); + + void deleteDepartmentByIds(@Param("ids") List ids); + + List selectByOrgId(@Param("orgId") String orgId); + + String getParentIdById(@Param("id") String id); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDepartmentMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDepartmentMapper.xml new file mode 100644 index 0000000..37f30c4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDepartmentMapper.xml @@ -0,0 +1,238 @@ + + + + + + + + + + + + + + + + + DELETE + FROM sys_department + WHERE organization_id = #{orgId} + and parent_id != 'NONE' + + + + + + + + + + + + + + + + + + + + UPDATE sys_department + SET pos = CASE + + WHEN id = #{department.id} THEN #{department.pos} + + END + WHERE id IN + + #{department.id} + + + + + + + + + update sys_department + + + `name` = #{department.name}, + + + organization_id = #{department.organizationId}, + + + parent_id = #{department.parentId}, + + + pos = #{department.pos}, + + + create_time = #{department.createTime}, + + + update_time = #{department.updateTime}, + + + create_user = #{department.createUser}, + + + update_user = #{department.updateUser}, + + + `resource` = #{department.resource}, + + + resource_id = #{department.resourceId}, + + + where id = #{department.id} + + + + + + + + + + + + + + + + + + DELETE FROM sys_department WHERE id in + + #{id} + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDictMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDictMapper.java new file mode 100644 index 0000000..32461be --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDictMapper.java @@ -0,0 +1,53 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.crm.system.domain.DictConfig; +import org.apache.ibatis.annotations.Param; + +public interface ExtDictMapper { + + /** + * 更新字典模块配置 + * + * @param config 配置 + */ + void updateModuleConfig(@Param("config") DictConfig config); + + /** + * 字典区间上移 + * + * @param start 开始 + * @param end 结束 + * @param module 模块 + * @param orgId 组织ID + */ + void moveUpDict(@Param("start") Long start, @Param("end") Long end, @Param("module") String module, @Param("orgId") String orgId); + + /** + * 字典区间下移 + * + * @param start 开始 + * @param end 结束 + * @param module 模块 + * @param orgId 组织ID + */ + void moveDownDict(@Param("start") Long start, @Param("end") Long end, @Param("module") String module, @Param("orgId") String orgId); + + /** + * 获取字典模块下一个位置 + * + * @param module 模块 + * @param orgId 组织ID + * + * @return 下一个位置 + */ + Long getNextPos(@Param("module") String module, @Param("orgId") String orgId); + + /** + * 更新字典位置 + * + * @param id 字典ID + * @param pos 位置 + * @param currentUser 当前用户 + */ + void updateDictPos(@Param("id") String id, @Param("pos") Long pos, @Param("currentUser") String currentUser); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDictMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDictMapper.xml new file mode 100644 index 0000000..c623819 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtDictMapper.xml @@ -0,0 +1,23 @@ + + + + + update sys_dict_config set enabled = #{config.enabled} where module = #{config.module} and organization_id = #{config.organizationId} + + + + update sys_dict set pos = pos - 1 where pos > #{start} and pos <= #{end} and module = #{module} and organization_id = #{orgId} + + + + update sys_dict set pos = pos + 1 where pos >= #{start} and pos < #{end} and module = #{module} and organization_id = #{orgId} + + + + + + update sys_dict set pos = #{pos}, update_user = #{currentUser}, update_time = unix_timestamp() * 1000 where id = #{id} + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtExportTaskMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtExportTaskMapper.java new file mode 100644 index 0000000..ebebcab --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtExportTaskMapper.java @@ -0,0 +1,18 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.crm.system.domain.ExportTask; +import cn.cordys.crm.system.dto.request.ExportTaskCenterQueryRequest; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtExportTaskMapper { + + int getExportTaskCount(@Param("userId") String userId, @Param("status") String status); + + int updateExportTaskStatus(@Param("newStatus") String newStatus, @Param("oldStatus") String oldStatus); + + List selectExportTaskList(@Param("request") ExportTaskCenterQueryRequest request, @Param("oneDayBefore") long oneDayBefore, @Param("userId") String userId); + + int checkPreparedUniqueness(@Param("createUser") String createUser, @Param("resourceType") String resourceType); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtExportTaskMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtExportTaskMapper.xml new file mode 100644 index 0000000..8b8f097 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtExportTaskMapper.xml @@ -0,0 +1,50 @@ + + + + + update export_task + set status = #{newStatus} + where status = #{oldStatus} + + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtLicenseMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtLicenseMapper.java new file mode 100644 index 0000000..e1eda98 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtLicenseMapper.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.system.mapper; + + +import cn.cordys.crm.system.domain.License; +import org.apache.ibatis.annotations.Param; + +public interface ExtLicenseMapper { + + License get(); + + License selectLicenseCode(@Param("code") String code); + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtLicenseMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtLicenseMapper.xml new file mode 100644 index 0000000..676f0ab --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtLicenseMapper.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtLoginLogMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtLoginLogMapper.java new file mode 100644 index 0000000..23c233a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtLoginLogMapper.java @@ -0,0 +1,11 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.crm.system.dto.request.LoginLogRequest; +import cn.cordys.crm.system.dto.response.LoginLogListResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtLoginLogMapper { + List list(@Param("request") LoginLogRequest request, @Param("orgId") String orgId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtLoginLogMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtLoginLogMapper.xml new file mode 100644 index 0000000..83495f8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtLoginLogMapper.xml @@ -0,0 +1,27 @@ + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtMessageTaskConfigMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtMessageTaskConfigMapper.java new file mode 100644 index 0000000..53c7f47 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtMessageTaskConfigMapper.java @@ -0,0 +1,9 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.crm.system.domain.MessageTaskConfig; +import org.apache.ibatis.annotations.Param; + +public interface ExtMessageTaskConfigMapper { + MessageTaskConfig getConfigByModuleAndEvent(@Param("module") String taskType, @Param("event") String event, @Param("organizationId") String organizationId); + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtMessageTaskConfigMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtMessageTaskConfigMapper.xml new file mode 100644 index 0000000..06f847a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtMessageTaskConfigMapper.xml @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtMessageTaskMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtMessageTaskMapper.java new file mode 100644 index 0000000..6fccf79 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtMessageTaskMapper.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.crm.system.domain.MessageTask; +import cn.cordys.crm.system.dto.request.MessageTaskBatchRequest; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtMessageTaskMapper { + + List getEnableMessageTaskByReceiveTypeAndTaskType(@Param("module") String taskType, @Param("organizationId") String organizationId); + + List getMessageTaskList(@Param("organizationId") String organizationId); + + MessageTask getMessageByModuleAndEvent(@Param("module") String taskType, @Param("event") String event, @Param("organizationId") String organizationId); + + void updateMessageTask(@Param("request") MessageTaskBatchRequest messageTaskBatchRequest, @Param("organizationId") String organizationId); + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtMessageTaskMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtMessageTaskMapper.xml new file mode 100644 index 0000000..45baf09 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtMessageTaskMapper.xml @@ -0,0 +1,39 @@ + + + + + update sys_message_task + + + `email_enable` = #{request.emailEnable}, + + + sys_enable = #{request.sysEnable}, + + + we_com_enable = #{request.weComEnable}, + + + ding_talk_enable = #{request.dingTalkEnable}, + + + lark_enable = #{request.larkEnable}, + + + where organization_id = #{organizationId} + + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtModuleFieldMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtModuleFieldMapper.java new file mode 100644 index 0000000..671d893 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtModuleFieldMapper.java @@ -0,0 +1,56 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.system.domain.ModuleField; +import cn.cordys.crm.system.domain.ModuleFieldBlob; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtModuleFieldMapper { + + /** + * 根据ID集合批量删除字段 + * + * @param ids ID集合 + */ + void deleteByIds(@Param("ids") List ids); + + /** + * 根据ID集合批量删除字段属性 + * + * @param ids ID集合 + */ + void deletePropByIds(@Param("ids") List ids); + + List getSourceOptionsByIds(@Param("tableName") String table, @Param("ids") List ids); + List getCustomFormOptionsByIds(@Param("ids") List ids); + List getSourceOptionsByKeywords(@Param("tableName") String table, @Param("keywords") List keywords); + List getCustomFormOptionsByKeywords(@Param("keywords") List keywords); + List getSourceOptionsByName(@Param("tableName") String table, @Param("keyword") String keyword, @Param("orgId") String orgId); + List getCustomFormOptionsByName(@Param("keyword") String keyword, @Param("orgId") String orgId); + + List getModuleField(@Param("orgId") String orgId, @Param("formKeys") List formKeys); + + /** + * 批量更新移动端显示 + * + * @param ids ID集合 + * @param mobile 移动端显示 + */ + void batchUpdateMobile(@Param("ids") List ids, @Param("mobile") Boolean mobile); + + /** + * 获取表单字段最大位次 + * @param formId 表单ID + * @return 位置下标 + */ + Long getMaxFieldPosByFormId(String formId); + + /** + * 获取表单子表格字段集合 + * @param formKey 表单Key + * @return 子表格字段集合 + */ + List getFormSubFields(@Param("formKey") String formKey); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtModuleFieldMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtModuleFieldMapper.xml new file mode 100644 index 0000000..aaeec97 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtModuleFieldMapper.xml @@ -0,0 +1,108 @@ + + + + + + delete from sys_module_field where id in + + #{id} + + + + + delete from sys_module_field_blob where id in + + #{id} + + + + + update sys_module_field set mobile = #{mobile} where id in + + #{id} + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtModuleMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtModuleMapper.java new file mode 100644 index 0000000..ed16049 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtModuleMapper.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.crm.system.domain.Module; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtModuleMapper { + + /** + * 模块区间上移 + * + * @param start 开始 + * @param end 结束 + */ + void moveUpModule(@Param("start") Long start, @Param("end") Long end); + + /** + * 模块区间下移 + * + * @param start 开始 + * @param end 结束 + */ + void moveDownModule(@Param("start") Long start, @Param("end") Long end); + + /** + * 根据模块key查询模块集合按照pos属性倒叙排序 + */ + List selectModuleListByKeyOrderPosDesc(@Param("orgId") String orgId, @Param("moduleKey") String moduleKey); + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtModuleMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtModuleMapper.xml new file mode 100644 index 0000000..113d452 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtModuleMapper.xml @@ -0,0 +1,24 @@ + + + + + update sys_module + set pos = pos - 1 + where pos > #{start} + and pos <= #{end} + + + + update sys_module + set pos = pos + 1 + where pos >= #{start} + and pos < #{end} + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtNavigationMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtNavigationMapper.java new file mode 100644 index 0000000..78afe5f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtNavigationMapper.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.crm.system.domain.Navigation; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtNavigationMapper { + + List selectList(@Param("orgId") String orgId); + + + void moveUpNavigation(@Param("start") Long start, @Param("end") Long end); + + void moveDownNavigation(@Param("start") Long start, @Param("end") Long end); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtNavigationMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtNavigationMapper.xml new file mode 100644 index 0000000..1755214 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtNavigationMapper.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + update sys_navigation set pos = pos - 1 where pos > #{start} and pos <= #{end} + + + + + update sys_navigation set pos = pos + 1 where pos >= #{start} and pos < #{end} + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtNotificationMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtNotificationMapper.java new file mode 100644 index 0000000..9f4efab --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtNotificationMapper.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.system.mapper; + + +import cn.cordys.crm.system.domain.Notification; +import cn.cordys.crm.system.dto.request.NotificationRequest; +import cn.cordys.crm.system.dto.response.NotificationDTO; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtNotificationMapper { + + List listNotification(@Param("request") NotificationRequest notificationRequest, @Param("organizationId") String organizationId); + + void deleteByTime(@Param("timestamp") long timestamp); + + void deleteByResourceId(@Param("resourceId") String resourceId); + + int updateByReceiver(@Param("request") Notification request); + + int countByReceiver(@Param("request") Notification request); + + List selectByAnyOne(@Param("request") Notification request); + + List selectLastList(@Param("userId") String userId, @Param("organizationId") String organizationId, @Param("modules") List modules); + + List selectLastAnnouncementList(@Param("userId") String userId, @Param("organizationId") String organizationId); + + void deleteByReceivers(@Param("receivers") List receivers, @Param("organizationId") String organizationId); + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtNotificationMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtNotificationMapper.xml new file mode 100644 index 0000000..6fb10cd --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtNotificationMapper.xml @@ -0,0 +1,116 @@ + + + + + + + + DELETE + FROM sys_notification + WHERE create_time < #{timestamp} + + + + DELETE + FROM sys_notification + WHERE resource_id = #{resourceId} + + + DELETE + FROM sys_notification + WHERE receiver IN + + #{receiver} + + and organization_id = #{organizationId} + + + + UPDATE sys_notification SET status = #{request.status} WHERE sys_notification.receiver = #{request.receiver} + + AND sys_notification.resource_type LIKE #{request.resourceType} + + + AND sys_notification.subject LIKE #{request.subject} + + AND sys_notification.organization_id = #{request.organizationId} + + + + + + + + + diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOperationLogMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOperationLogMapper.java new file mode 100644 index 0000000..183f5a1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOperationLogMapper.java @@ -0,0 +1,12 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.crm.system.dto.request.OperationLogRequest; +import cn.cordys.crm.system.dto.response.OperationLogResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtOperationLogMapper { + + List list(@Param("request") OperationLogRequest request, @Param("orgId") String orgId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOperationLogMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOperationLogMapper.xml new file mode 100644 index 0000000..d216324 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOperationLogMapper.xml @@ -0,0 +1,42 @@ + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationConfigDetailMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationConfigDetailMapper.java new file mode 100644 index 0000000..b466848 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationConfigDetailMapper.java @@ -0,0 +1,29 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.crm.system.domain.OrganizationConfigDetail; +import cn.cordys.crm.system.dto.request.AuthSourceRequest; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtOrganizationConfigDetailMapper { + + OrganizationConfigDetail getOrganizationConfigDetail(@Param("configId") String configId); + + OrganizationConfigDetail getOrgConfigDetailByIdAndType(@Param("configId") String configId, @Param("type") String type); + + List getOrganizationConfigDetails(@Param("configId") String configId, @Param("name") String name); + + List getOrganizationConfigDetailList(@Param("request") AuthSourceRequest request); + + int getOrganizationConfigDetailCount(@Param("configId") String configId, @Param("name") String name, @Param("type") String type); + + List getOrgConfigDetailByType(@Param("configId") String configId, @Param("name") String name, @Param("types") List types); + + int getRepeatDetails(@Param("id") String id, @Param("name") String name); + + int updateStatus(@Param("enable") Boolean enable, @Param("type") String type, @Param("configId") String configId); + + List getEnableOrganizationConfigDetails(@Param("configId") String configId, @Param("types") List types); + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationConfigDetailMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationConfigDetailMapper.xml new file mode 100644 index 0000000..57845b4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationConfigDetailMapper.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + UPDATE sys_organization_config_detail SET enable = #{enable} WHERE type = #{type} + AND config_id = #{configId} + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationConfigMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationConfigMapper.java new file mode 100644 index 0000000..beacd4d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationConfigMapper.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.crm.system.domain.OrganizationConfig; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface ExtOrganizationConfigMapper { + OrganizationConfig getOrganizationConfig(@Param("organizationId") String organizationId, @Param("type") String type); + + void updateSyncFlag(@Param("orgId") String orgId, @Param("syncResource") String syncResource, @Param("type") String type, @Param("syncStatus") Boolean syncStatus); + + int getSyncFlag(@Param("organizationId") String organizationId, @Param("type") String type); + + OrganizationConfig getSyncStatus(@Param("organizationId") String organizationId, @Param("type") String type, @Param("syncResource") String syncResource); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationConfigMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationConfigMapper.xml new file mode 100644 index 0000000..0ab4a7d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationConfigMapper.xml @@ -0,0 +1,27 @@ + + + + + + + + update sys_organization_config + set sync = #{syncStatus}, + sync_resource = #{syncResource} + where organization_id = #{orgId} and type = #{type} + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationMapper.java new file mode 100644 index 0000000..2bae4df --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationMapper.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.system.mapper; + +import java.util.Set; + +public interface ExtOrganizationMapper { + + /** + * 获取所有组织ID集合 + * + * @return 组织ID集合 + */ + Set selectAllOrganizationIds(); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationMapper.xml new file mode 100644 index 0000000..e23b6de --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationMapper.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationUserMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationUserMapper.java new file mode 100644 index 0000000..3c08d93 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationUserMapper.java @@ -0,0 +1,63 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.system.domain.OrganizationUser; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.request.UserBatchEditRequest; +import cn.cordys.crm.system.dto.request.UserBatchEnableRequest; +import cn.cordys.crm.system.dto.request.UserBatchRequest; +import cn.cordys.crm.system.dto.request.UserPageRequest; +import cn.cordys.crm.system.dto.response.UserImportDTO; +import cn.cordys.crm.system.dto.response.UserPageResponse; +import cn.cordys.crm.system.dto.response.UserResponse; +import cn.cordys.security.UserDTO; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * @author jianxing + * @date 2025-01-09 16:11:37 + */ +public interface ExtOrganizationUserMapper { + + int countUserByDepartmentIds(@Param("departmentIds") List departmentIds, @Param("orgId") String orgId); + + List list(@Param("request") UserPageRequest request, @Param("orderByClause") String orderByClause); + + void enable(@Param("request") UserBatchEnableRequest request, @Param("operatorId") String operatorId, @Param("time") long time); + + List getUserList(@Param("request") UserBatchRequest request); + + void deleteUserByOrgId(@Param("orgId") String orgId); + + UserDTO getEnableUser(@Param("resourceUserId") String resourceUserId); + + List selectEnableOrgUser(@Param("ids") List ids, @Param("enable") boolean enable); + + void updateUserByIds(@Param("request") UserBatchEditRequest request, @Param("operatorId") String operatorId, @Param("orgId") String orgId); + + List selectSupervisor(@Param("nameList") List nameList, @Param("orgId") String orgId); + + List getUserByOrgId(@Param("orgId") String orgId); + + void updateOrganizationUser(@Param("organizationUser") OrganizationUser organizationUser); + + void deleteUserByIds(@Param("ids") List ids); + + List selectUserByUserIds(@Param("userIds") List userIds); + + List getUserDepAndPhoneByUserIds(@Param("userIds") List userIds, @Param("orgId") String orgId); + + String getOrgUserIdByUserId(@Param("orgId") String orgId, @Param("userId") String userId); + + List selectByIds(@Param("ids") List ids); + + void updateById(@Param("organizationUser") OrganizationUser organizationUser); + + void disableUser(@Param("organizationUser") OrganizationUser organizationUser); + + void updateUserByUserId(@Param("userId") String userId, @Param("time") long time, @Param("operatorId") String operatorId); + + String getDepartmentByUserId(@Param("userId") String userId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationUserMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationUserMapper.xml new file mode 100644 index 0000000..a801229 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtOrganizationUserMapper.xml @@ -0,0 +1,537 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + order by + + + su.name ${sortType}, + + + sou.work_city ${sortType}, + + + sou.${sortName} ${sortType}, + + + sou.enable DESC, + ${orderByClause}, + is_commander DESC, + sou.create_time DESC, + su.name DESC + + + order by + sou.enable DESC, + ${orderByClause}, + is_commander DESC, + sou.create_time DESC, + su.name DESC + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + sou.enable is true + + + sou.enable is false + + + + + sou.enable is false + + + sou.enable is true + + + + sou.enable IS NULL + + + sou.enable IS NOT NULL + + + + + + su.gender is true + + + su.gender is false + + + + + + su.gender is false + + + su.gender is true + + + + su.gender IS NULL + + + su.gender IS NOT NULL + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + update sys_organization_user + set `enable` = #{request.enable}, + update_time = UNIX_TIMESTAMP() * 1000, + update_user = #{operatorId} + where id in + + #{id} + + + + + + + + + DELETE + FROM sys_organization_user + WHERE organization_id = #{orgId} + + + + + + update sys_organization_user + + + department_id = #{request.departmentId}, + + + supervisor_id = #{request.supervisorId}, + + + work_city = #{request.workCity}, + + + onboarding_date = #{request.onboardingDate}, + + update_time = UNIX_TIMESTAMP() * 1000, + update_user = #{operatorId} + + + id in + + #{id} + + + + + + + + + + + + + update sys_organization_user + + + organization_id = #{organizationUser.organizationId}, + + + department_id = #{organizationUser.departmentId}, + + + resource_user_id = #{organizationUser.resourceUserId}, + + + user_id = #{organizationUser.userId}, + + + `enable` = #{organizationUser.enable}, + + + employee_id = #{organizationUser.employeeId}, + + + `position` = #{organizationUser.position}, + + + employee_type = #{organizationUser.employeeType}, + + + supervisor_id = #{organizationUser.supervisorId}, + + + work_city = #{organizationUser.workCity}, + + + create_time = #{organizationUser.createTime}, + + + update_time = #{organizationUser.updateTime}, + + + create_user = #{organizationUser.createUser}, + + + update_user = #{organizationUser.updateUser}, + + + where id = #{organizationUser.id} + + + + DELETE FROM sys_organization_user WHERE id in + + #{id} + + + + + + + + + + + + + + update sys_organization_user + set department_id = #{organizationUser.departmentId}, + `enable` = #{organizationUser.enable}, + employee_id = #{organizationUser.employeeId}, + `position` = #{organizationUser.position}, + employee_type = #{organizationUser.employeeType}, + supervisor_id = #{organizationUser.supervisorId}, + work_city = #{organizationUser.workCity}, + update_time = #{organizationUser.updateTime}, + update_user = #{organizationUser.updateUser}, + onboarding_date = #{organizationUser.onboardingDate} + where id = #{organizationUser.id} + + + + + update sys_organization_user + set enable = 0 + where id = #{organizationUser.id} + + + + + update sys_organization_user + set update_time = #{time}, + update_user = #{operatorId} + where user_id = #{userId} + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtRoleMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtRoleMapper.java new file mode 100644 index 0000000..6513b57 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtRoleMapper.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.crm.system.domain.Role; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * @author jianxing + * @date 2025-01-10 18:35:02 + */ +public interface ExtRoleMapper { + + boolean checkAddExist(@Param("role") Role role); + + boolean checkUpdateExist(@Param("role") Role role); + + List getInternalRoleIds(); + + List getIdNameByIds(@Param("ids") List ids); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtRoleMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtRoleMapper.xml new file mode 100644 index 0000000..ca29d51 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtRoleMapper.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtScheduleMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtScheduleMapper.java new file mode 100644 index 0000000..36a9f78 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtScheduleMapper.java @@ -0,0 +1,11 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.crm.system.domain.Schedule; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtScheduleMapper { + + List getScheduleByLimit(@Param("start") int start, @Param("limit") int limit); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtScheduleMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtScheduleMapper.xml new file mode 100644 index 0000000..6c577ac --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtScheduleMapper.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtStageAdvancedConfigMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtStageAdvancedConfigMapper.java new file mode 100644 index 0000000..2e2b3d1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtStageAdvancedConfigMapper.java @@ -0,0 +1,19 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.common.dto.stage.StageConfigResponse; +import cn.cordys.crm.system.domain.StageAdvancedConfig; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtStageAdvancedConfigMapper { + + + void update(@Param("sourceTable") String sourceTable, @Param("circulationType") String circulationType, @Param("orgId") String orgId, @Param("userId") String userId, @Param("updateTime") Long updateTime); + + List selectConfigByType(@Param("orgId") String orgId, @Param("moduleType") String moduleType); + + StageConfigResponse getStageConfig(@Param("sourceTable")String sourceTable, @Param("id")String id); + + StageAdvancedConfig getConfigByOriginAndTarget(@Param("originId")String originId, @Param("targetId")String targetId,@Param("moduleType")String moduleType); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtStageAdvancedConfigMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtStageAdvancedConfigMapper.xml new file mode 100644 index 0000000..bb3932a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtStageAdvancedConfigMapper.xml @@ -0,0 +1,44 @@ + + + + + + update ${sourceTable} + + + circulation_type = #{circulationType}, + + + update_user = #{userId}, + + + update_time = #{updateTime}, + + + where organization_id = #{orgId} + + + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserExtendMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserExtendMapper.java new file mode 100644 index 0000000..ebbe4f6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserExtendMapper.java @@ -0,0 +1,20 @@ +package cn.cordys.crm.system.mapper; + + +import cn.cordys.crm.system.dto.ScopeNameDTO; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtUserExtendMapper { + void deleteUser(@Param("ids") List ids); + + /** + * 根据范围ID分组 + * + * @param scopeIds 范围ID集合 + * + * @return 分组结果 + */ + List groupByScopeIds(@Param("ids") List scopeIds); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserExtendMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserExtendMapper.xml new file mode 100644 index 0000000..1c65c96 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserExtendMapper.xml @@ -0,0 +1,28 @@ + + + + + + DELETE FROM sys_user_extend WHERE id in + + #{id} + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserMapper.java new file mode 100644 index 0000000..0064c1b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserMapper.java @@ -0,0 +1,63 @@ +package cn.cordys.crm.system.mapper; + + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.UserDeptDTO; +import cn.cordys.crm.system.domain.OrganizationUser; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.convert.UserRoleConvert; +import cn.cordys.crm.system.dto.response.UserResponse; +import cn.cordys.security.UserDTO; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtUserMapper { + + UserDTO selectByPhoneOrEmail(String id); + + List selectUserOptionByIds(@Param("userIds") List userIds); + + List selectUserNameByIds(@Param("userIds") List userIds); + + List selectUserIdsByRoleIds(@Param("roleIds") List roleIds); + + List filterEnabledUserIds(@Param("userIds") List userIds, @Param("orgId") String orgId); + + + List selectUserIdsByNames(@Param("names") List names); + + List getOrgUserByUserIds(@Param("organizationId") String organizationId, @Param("userIds") List userIds); + + List getUserRole(@Param("userIds") List userIds, @Param("orgId") String orgId); + + List selectUserOption(@Param("orgId") String orgId); + + UserResponse getUserDetail(@Param("id") String id); + + List getUserDetailList(@Param("userIds") List userIds); + + void batchUpdatePassword(@Param("userList") List userList); + + void updateUserPassword(@Param("password") String password, @Param("id") String id); + + List getAllUserIds(@Param("orgId") String orgId); + + void deleteByIds(@Param("ids") List ids); + + int countByEmail(@Param("email") String email, @Param("id") String id); + + int countByPhone(@Param("phone") String phone, @Param("id") String id); + + List selectUserOptionByOrgId(@Param("orgId") String orgId, @Param("defaultOrder") String defaultOrder); + + List selectNameAndEmail(@Param("orgId") String orgId); + + void updateUser(@Param("user") User user); + + List getUserDeptByUserIds(@Param("userIds") List userIds, @Param("orgId") String orgId); + + void updateUserInfo(@Param("user") OrganizationUser user); + + List getOrgUserResourceIds(@Param("userIds") List userIds, @Param("orgId") String orgId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserMapper.xml new file mode 100644 index 0000000..4a89d05 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserMapper.xml @@ -0,0 +1,301 @@ + + + + + + + + + + + + + + + + + + + + + UPDATE sys_user + SET password = CASE + + WHEN id = #{user.id} THEN #{user.password} + + END + WHERE id IN + + #{user.id} + + + + + UPDATE sys_user + SET password = #{password} + WHERE id = #{id} + + + + + + + + DELETE FROM sys_user WHERE id in + + #{id} + + + + + + + + + + + + + + + + + + + + + + + update sys_user + + + `name` = #{user.name}, + + + phone = #{user.phone}, + + + email = #{user.email}, + + + `password` = #{user.password}, + + + `language` = #{user.language}, + + + last_organization_id = #{user.lastOrganizationId}, + + + gender = #{user.gender}, + + + create_time = #{user.createTime}, + + + update_time = #{user.updateTime}, + + + create_user = #{user.createUser}, + + + update_user = #{user.updateTime}, + + + where id = #{user.id} + + + + + update sys_user + set email = #{user.id} + where id = #{user.userId} + + diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserRoleMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserRoleMapper.java new file mode 100644 index 0000000..8999c86 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserRoleMapper.java @@ -0,0 +1,46 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.common.dto.DeptUserTreeNode; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.RoleUserTreeNode; +import cn.cordys.crm.system.dto.request.RoleUserPageRequest; +import cn.cordys.crm.system.dto.response.RoleUserListResponse; +import cn.cordys.crm.system.dto.response.RoleUserOptionResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * @author jianxing + * @date 2025-01-13 17:33:23 + */ +public interface ExtUserRoleMapper { + + List list(@Param("request") RoleUserPageRequest request, @Param("orgId") String orgId); + + List selectUserDeptForRelevance(@Param("orgId") String orgId, @Param("roleId") String roleId); + + List selectUserDeptForOrg(@Param("orgId") String orgId); + + List selectUserRoleForRelevance(@Param("orgId") String orgId, @Param("roleId") String roleId); + + List selectUserRoleForOrg(@Param("orgId") String orgId); + + void deleteUserRoleByUserId(@Param("userId") String userId); + + void deleteUserRoleByUserIds(@Param("userIds") List userIds); + + List getUserIdsByRoleIds(@Param("roleIds") List roleIds); + + void deleteByIds(@Param("ids") List ids); + + List selectUserOption(@Param("orgId") String orgId); + + List selectUserOptionByRoleId(@Param("orgId") String orgId, @Param("roleId") String roleId); + + List selectUserRole(@Param("userId") String userId, @Param("orgId") String orgId); + + List selectPermissionsByUserId(@Param("userId") String userId); + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserRoleMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserRoleMapper.xml new file mode 100644 index 0000000..e7c596a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserRoleMapper.xml @@ -0,0 +1,182 @@ + + + + + + + DELETE + FROM sys_user_role + WHERE user_id = #{userId} + + + + DELETE + FROM sys_user_role + WHERE id in + + #{id} + + + + DELETE + FROM sys_user_role + WHERE user_id in + + #{userId} + + + + + + + + + + + + + + + + + + + + + + + + + + + order by + + + su.`name` ${sortType} + + + sou.`department_id` ${sortType} + + + sou.`position` ${sortType} + + + sou.`enable` ${sortType} + + + su.`name` ${sortType} + + + sur.create_time ${sortType} + + + + + order by sur.create_time desc, sur.id asc + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserViewConditionMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserViewConditionMapper.java new file mode 100644 index 0000000..48446de --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserViewConditionMapper.java @@ -0,0 +1,17 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.crm.system.domain.UserViewCondition; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtUserViewConditionMapper { + + + List getViewConditions(@Param("viewId") String viewId); + + void delete(@Param("viewId") String viewId); + + //根据userIds删除视图条件 + void deleteByUserIds(@Param("userIds") List userIds, @Param("orgId") String orgId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserViewConditionMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserViewConditionMapper.xml new file mode 100644 index 0000000..b8da53b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserViewConditionMapper.xml @@ -0,0 +1,32 @@ + + + + + + + + + DELETE FROM sys_user_view_condition WHERE sys_user_view_id = #{viewId} + + + delete from sys_user_view_condition where sys_user_view_id in ( + select id from sys_user_view where organization_id = #{orgId} + and user_id in + + #{item} + + ); + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserViewMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserViewMapper.java new file mode 100644 index 0000000..5a48476 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserViewMapper.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.crm.system.dto.response.UserViewListResponse; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface ExtUserViewMapper { + Long getNextPos(@Param("orgId") String orgId, @Param("userId") String userId, @Param("resourceType") String resourceType); + + int countUserView(@Param("userId") String userId, @Param("id") String id, @Param("orgId") String orgId); + + List selectViewList(@Param("resourceType") String resourceType, @Param("userId") String userId, @Param("orgId") String orgId); + + Long getPrePos(@Param("orgId") String orgId, @Param("basePos") Long basePos, @Param("userId") String userId, @Param("resourceType") String resourceType); + + Long getLastPos(@Param("orgId") String orgId, @Param("basePos") Long basePos, @Param("userId") String userId, @Param("resourceType") String resourceType); + + //根据userIds删除视图 + void deleteUserViewByUserIds(@Param("userIds") List userIds, @Param("orgId") String orgId); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserViewMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserViewMapper.xml new file mode 100644 index 0000000..c0860d2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtUserViewMapper.xml @@ -0,0 +1,65 @@ + + + + + + delete from sys_user_view where organization_id = #{orgId} + and user_id in + + #{item} + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtWorkerNodeMapper.java b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtWorkerNodeMapper.java new file mode 100644 index 0000000..67b4a60 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtWorkerNodeMapper.java @@ -0,0 +1,7 @@ +package cn.cordys.crm.system.mapper; + +import cn.cordys.crm.system.domain.WorkerNode; + +public interface ExtWorkerNodeMapper { + int insert(WorkerNode record); +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtWorkerNodeMapper.xml b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtWorkerNodeMapper.xml new file mode 100644 index 0000000..4fa9003 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/mapper/ExtWorkerNodeMapper.xml @@ -0,0 +1,9 @@ + + + + + + insert into worker_node (host_name, port, type, launch_date, modified, created) + values (#{hostName,jdbcType=VARCHAR}, #{port,jdbcType=VARCHAR}, #{type,jdbcType=INTEGER}, #{launchDate,jdbcType=BIGINT}, #{modified,jdbcType=BIGINT}, #{created,jdbcType=BIGINT}) + + \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/notice/CommonNoticeSendService.java b/backend/crm/src/main/java/cn/cordys/crm/system/notice/CommonNoticeSendService.java new file mode 100644 index 0000000..f516633 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/notice/CommonNoticeSendService.java @@ -0,0 +1,234 @@ +package cn.cordys.crm.system.notice; + + +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.domain.DepartmentCommander; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.MessageTaskConfigDTO; +import cn.cordys.crm.system.mapper.ExtDepartmentMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationUserMapper; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.crm.system.notice.common.NoticeModel; +import cn.cordys.crm.system.notice.common.Receiver; +import cn.cordys.crm.system.utils.MessageTemplateUtils; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.util.*; + +@Component +public class CommonNoticeSendService { + @Resource + private NoticeSendService noticeSendService; + @Resource + private BaseMapper userBaseMapper; + @Resource + private BaseMapper departmentCommanderMapper; + @Resource + private ExtDepartmentMapper extDepartmentMapper; + @Resource + private ExtOrganizationUserMapper extOrganizationUserMapper; + @Resource + private ExtUserMapper extUserMapper; + + private static void setLanguage(String language) { + Locale locale = Locale.SIMPLIFIED_CHINESE; + if (Strings.CI.contains(language, "US")) { + locale = Locale.US; + } else if (Strings.CI.contains(language, "TW")) { + locale = Locale.TAIWAN; + } + LocaleContextHolder.setLocale(locale); + } + + @Async("threadPoolTaskExecutor") + public void sendNotice(String module, String event, List resources, String userId, String currentOrganizationId) { + User operator = userBaseMapper.selectByPrimaryKey(userId); + setLanguage(operator.getLanguage()); + // 有批量操作发送多次 + for (Map resource : resources) { + Map paramMap = new HashMap<>(); + paramMap.put(NotificationConstants.RelatedUser.OPERATOR, operator.getName()); + paramMap.put("Language", operator.getLanguage()); + paramMap.putAll(resource); + paramMap.putIfAbsent("organizationId", currentOrganizationId); + + String context = getContext(event); + List relatedUsers = getRelatedUsers(resource.get("relatedUsers")); + List receivers = getReceivers(relatedUsers); + NoticeModel noticeModel = NoticeModel.builder() + .operator(operator.getId()) + .context(context) + .paramMap(paramMap) + .event(event) + .status((String) paramMap.get("status")) + .excludeSelf(true) + .receivers(receivers) + .build(); + noticeSendService.send(module, noticeModel); + } + } + + private List getReceivers(List relatedUsers) { + List receivers = new ArrayList<>(); + for (String relatedUserId : relatedUsers) { + receivers.add(new Receiver(relatedUserId, NotificationConstants.Type.SYSTEM_NOTICE.name())); + } + return receivers; + } + + /** + * 发送通知 + * + * @param taskType 发送类型 + * @param event 发送事件 + * @param currentOrgId 当前组织id + * @param resourceName 资源名称 + * @param users 需要通知的用户 + * @param excludeSelf 是否排除自己 + */ + public void sendNotice(String taskType, String event, String resourceName, String operatorId, String currentOrgId, + List users, boolean excludeSelf) { + sendNotice(taskType, event, Map.of("name", resourceName), operatorId, currentOrgId, users, excludeSelf); + } + + /** + * 发送通知 + * + * @param taskType 发送类型 + * @param event 发送事件 + * @param currentOrgId 当前组织id + * @param resource 消息变量的名称 以及其他变量 eg: xxxx 的名称,resource.put("name", "名称"); + * @param users 需要通知的用户 + * @param excludeSelf 是否排除自己 + */ + public void sendNotice(String taskType, String event, Map resource, String operatorId, String currentOrgId, + List users, boolean excludeSelf) { + Map paramMap = new HashMap<>(); + User operator = userBaseMapper.selectByPrimaryKey(operatorId); + if (operator != null) { + paramMap.put(NotificationConstants.RelatedUser.OPERATOR, operator.getName()); + paramMap.put("Language", operator.getLanguage()); + } else { + paramMap.put(NotificationConstants.RelatedUser.OPERATOR, Translator.get("setting")); + paramMap.put("Language", "zh-CN"); + } + + paramMap.putAll(resource); + paramMap.putIfAbsent("organizationId", currentOrgId); + + String context = getContext(event); + + List receivers = getReceivers(users); + NoticeModel noticeModel = NoticeModel.builder() + .operator(operatorId) + .context(context) + .paramMap(paramMap) + .event(event) + .status((String) paramMap.get("status")) + .excludeSelf(excludeSelf) + .receivers(receivers) + .build(); + noticeSendService.sendOther(taskType, noticeModel, excludeSelf); + } + + private List getRelatedUsers(Object relatedUsers) { + String relatedUser = (String) relatedUsers; + List relatedUserList = new ArrayList<>(); + if (StringUtils.isNotBlank(relatedUser)) { + relatedUserList = Arrays.asList(relatedUser.split(";")); + } + return relatedUserList; + } + + private String getContext(String event) { + if (StringUtils.isBlank(event)) { + return null; + } + Map defaultTemplateMap = MessageTemplateUtils.getDefaultTemplateMap(); + return defaultTemplateMap.get(event + "_TEXT"); + } + + /** + * 获取消息接收用户ID列表 + * + * @param messageTaskConfigDTO 消息任务配置DTO + * @param createUser 创建人ID + * @param owner 责任人ID + * @param orgId 组织ID + * @return 消息接收用户ID列表 + */ + public List getNoticeReceiveUserIds(MessageTaskConfigDTO messageTaskConfigDTO, String createUser, String owner, String orgId) { + List receiveUserIds = new ArrayList<>(); + if (CollectionUtils.isNotEmpty(messageTaskConfigDTO.getUserIds())) { + if (messageTaskConfigDTO.getUserIds().contains(NotificationConstants.RelatedUser.CREATE_USER)) { + receiveUserIds.add(createUser); + } + if (messageTaskConfigDTO.getUserIds().contains(NotificationConstants.RelatedUser.OWNER)) { + receiveUserIds.add(owner); + } + List normalUserIds = messageTaskConfigDTO.getUserIds().stream() + .filter(userId -> !userId.equals(NotificationConstants.RelatedUser.CREATE_USER) + && !userId.equals(NotificationConstants.RelatedUser.OWNER)) + .toList(); + receiveUserIds.addAll(normalUserIds); + } + if (messageTaskConfigDTO.isRoleEnable() && CollectionUtils.isNotEmpty(messageTaskConfigDTO.getRoleIds())) { + receiveUserIds.addAll(extUserMapper.selectUserIdsByRoleIds(messageTaskConfigDTO.getRoleIds())); + } + + if (!messageTaskConfigDTO.isOwnerEnable()) { + return receiveUserIds; + } + + if (StringUtils.isBlank(orgId)) { + return receiveUserIds; + } + + String departmentId = extOrganizationUserMapper.getDepartmentByUserId(owner); + + if (StringUtils.isBlank(departmentId)) { + return receiveUserIds; + } + + List departmentIds = new ArrayList<>(); + departmentIds.add(departmentId); + int ownerLevel = messageTaskConfigDTO.getOwnerLevel(); + + if (ownerLevel > 0) { + String currentDepartmentId = departmentId; + for (int i = 1; i < ownerLevel; i++) { + String parentId = extDepartmentMapper.getParentIdById(currentDepartmentId); + if (StringUtils.isBlank(parentId)) { + break; + } + departmentIds.add(parentId); + currentDepartmentId = parentId; + } + } + if (CollectionUtils.isEmpty(departmentIds)) { + return receiveUserIds; + } + List departmentCommanderList = departmentCommanderMapper.selectListByLambda(new LambdaQueryWrapper() + .in(DepartmentCommander::getDepartmentId, departmentIds)); + if (CollectionUtils.isEmpty(departmentCommanderList)) { + return receiveUserIds; + } + List deptCommanderUserIds = departmentCommanderList.stream() + .map(DepartmentCommander::getUserId) + .toList(); + receiveUserIds.addAll(deptCommanderUserIds); + + return receiveUserIds.stream().distinct().toList(); + + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/notice/NoticeSendService.java b/backend/crm/src/main/java/cn/cordys/crm/system/notice/NoticeSendService.java new file mode 100644 index 0000000..f89f0ad --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/notice/NoticeSendService.java @@ -0,0 +1,114 @@ +package cn.cordys.crm.system.notice; + +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.crm.integration.dingtalk.service.DingTalkNoticeSender; +import cn.cordys.crm.integration.lark.service.LarkNoticeSender; +import cn.cordys.crm.integration.wecom.service.WeComNoticeSender; +import cn.cordys.crm.system.dto.MessageDetailDTO; +import cn.cordys.crm.system.notice.common.NoticeModel; +import cn.cordys.crm.system.notice.message.MessageDetailService; +import cn.cordys.crm.system.notice.sender.insite.InSiteNoticeSender; +import cn.cordys.crm.system.notice.sender.mail.MailNoticeSender; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.SerializationUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Locale; + +@Service +@RequiredArgsConstructor +@Slf4j +public class NoticeSendService { + + private final MailNoticeSender mailNoticeSender; + private final InSiteNoticeSender inSiteNoticeSender; + private final MessageDetailService messageDetailService; + + private void doSend(String module, NoticeModel noticeModel, String organizationId) { + setLanguage(noticeModel.getParamMap().get("Language")); + boolean useTemplate = Boolean.parseBoolean( + (String) noticeModel.getParamMap().getOrDefault("useTemplate", "false")); + String template = (String) noticeModel.getParamMap().get("template"); + try { + List messageDetails = messageDetailService + .searchMessageByTypeAndOrgId(module, useTemplate, template, organizationId); + + messageDetails.stream() + .filter(md -> Strings.CS.equals(md.getEvent(), noticeModel.getEvent())) + .forEach(md -> sendNotification(md, noticeModel)); + } catch (Exception e) { + log.error("Error sending notification", e); + } + } + + @Async("threadPoolTaskExecutor") + public void send(String module, NoticeModel noticeModel) { + String organizationId = (String) noticeModel.getParamMap().get("organizationId"); + doSend(module, noticeModel, organizationId); + } + + private void setLanguage(Object languageObj) { + String language = languageObj instanceof String ? (String) languageObj : ""; + Locale locale = Locale.SIMPLIFIED_CHINESE; + if (Strings.CI.contains(language, "US")) { + locale = Locale.US; + } + LocaleContextHolder.setLocale(locale); + } + + public void sendNotification(MessageDetailDTO messageDetail, NoticeModel noticeModel) { + MessageDetailDTO clonedMessageDetail = SerializationUtils.clone(messageDetail); + NoticeModel clonedNoticeModel = SerializationUtils.clone(noticeModel); + try { + if (clonedMessageDetail.isSysEnable()) { + inSiteNoticeSender.send(clonedMessageDetail, clonedNoticeModel); + } + if (clonedMessageDetail.isEmailEnable()) { + mailNoticeSender.send(clonedMessageDetail, clonedNoticeModel); + } + if (clonedMessageDetail.isWeComEnable()) { + WeComNoticeSender weComNoticeSender = CommonBeanFactory.getBean(WeComNoticeSender.class); + if (weComNoticeSender != null) { + weComNoticeSender.sendWeCom(clonedMessageDetail, clonedNoticeModel); + } else { + log.warn("WeComNoticeSender bean not found, skipping WeCom notification."); + } + } + if (clonedMessageDetail.isDingTalkEnable()) { + DingTalkNoticeSender dingTalkNoticeSender = CommonBeanFactory.getBean(DingTalkNoticeSender.class); + if (dingTalkNoticeSender != null) { + dingTalkNoticeSender.sendDingTalk(clonedMessageDetail, clonedNoticeModel); + } else { + log.warn("DingTalkNoticeSender bean not found, skipping DingTalk notification."); + } + } + if (clonedMessageDetail.isLarkEnable()) { + LarkNoticeSender larkNoticeSender = CommonBeanFactory.getBean(LarkNoticeSender.class); + if (larkNoticeSender != null) { + larkNoticeSender.sendLark(clonedMessageDetail, clonedNoticeModel); + } else { + log.warn("LarkNoticeSender bean not found, skipping Lark notification."); + } + } + } catch (Exception e) { + log.error("Error sending individual notification", e); + } + } + + @Async("threadPoolTaskExecutor") + public void send(String organizationId, String module, NoticeModel noticeModel) { + doSend(module, noticeModel, organizationId); + } + + @Async("threadPoolTaskExecutor") + public void sendOther(String module, NoticeModel noticeModel, boolean excludeSelf) { + noticeModel.setExcludeSelf(excludeSelf); + String organizationId = (String) noticeModel.getParamMap().get("organizationId"); + doSend(module, noticeModel, organizationId); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/notice/SendNotice.java b/backend/crm/src/main/java/cn/cordys/crm/system/notice/SendNotice.java new file mode 100644 index 0000000..e2b5d20 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/notice/SendNotice.java @@ -0,0 +1,37 @@ +package cn.cordys.crm.system.notice; + +import java.lang.annotation.*; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface SendNotice { + + String module(); + + /** + * Event + */ + String event() default ""; + + /** + * 消息主题 + */ + String subject() default ""; + + /** + * 获取实际值 + */ + String target() default ""; + + /** + * 资源目标 + */ + Class targetClass() default Object.class; + + /** + * 消息内容 + */ + String context() default ""; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/notice/SendNoticeAspect.java b/backend/crm/src/main/java/cn/cordys/crm/system/notice/SendNoticeAspect.java new file mode 100644 index 0000000..3ef431a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/notice/SendNoticeAspect.java @@ -0,0 +1,174 @@ +package cn.cordys.crm.system.notice; + + +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.context.OrganizationContext; +import cn.cordys.security.SessionUtils; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.beanutils.BeanMap; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.StandardReflectionParameterNameDiscoverer; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +@Aspect +@Component +@Slf4j +public class SendNoticeAspect { + private final static String ID = "id"; + private final static String CREATE_USER = "createUser"; + private final static String CREATE_TIME = "createTime"; + private final static String UPDATE_TIME = "updateTime"; + private final static String UPDATE_USER = "updateUser"; + private final ExpressionParser parser = new SpelExpressionParser(); + private final StandardReflectionParameterNameDiscoverer discoverer = new StandardReflectionParameterNameDiscoverer(); + private final ThreadLocal source = new ThreadLocal<>(); + @Resource + private CommonNoticeSendService commonNoticeSendService; + + @Pointcut("@annotation(cn.cordys.crm.system.notice.SendNotice)") + public void pointcut() { + } + + @Before("pointcut()") + public void before(JoinPoint joinPoint) { + try { + //从切面织入点处通过反射机制获取织入点处的方法 + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + //获取切入点所在的方法 + Method method = signature.getMethod(); + //获取参数对象数组 + Object[] args = joinPoint.getArgs(); + SendNotice sendNotice = method.getAnnotation(SendNotice.class); + + if (StringUtils.isNotEmpty(sendNotice.target())) { + // 操作内容 + //获取方法参数名 + String[] params = discoverer.getParameterNames(method); + //将参数纳入Spring管理 + EvaluationContext context = new StandardEvaluationContext(); + for (int len = 0; len < Objects.requireNonNull(params).length; len++) { + context.setVariable(params[len], args[len]); + } + context.setVariable("targetClass", CommonBeanFactory.getBean(sendNotice.targetClass())); + + String target = sendNotice.target(); + Expression titleExp = parser.parseExpression(target); + Object v = titleExp.getValue(context, Object.class); + source.set(JSON.toJSONString(v)); + } + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + + @AfterReturning(value = "pointcut()", returning = "retValue") + public void sendNotice(JoinPoint joinPoint, Object retValue) { + try { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + Object[] args = joinPoint.getArgs(); + String[] params = discoverer.getParameterNames(method); + + SendNotice sendNotice = method.getAnnotation(SendNotice.class); + EvaluationContext context = new StandardEvaluationContext(); + + if (params != null) { + for (int i = 0; i < params.length; i++) { + context.setVariable(params[i], args[i]); + } + } + + // 获取目标对象最新数据 + if (StringUtils.isNotEmpty(sendNotice.target())) { + Object targetBean = CommonBeanFactory.getBean(sendNotice.targetClass()); + context.setVariable("targetClass", targetBean); + + Object targetValue = parser.parseExpression(sendNotice.target()) + .getValue(context, Object.class); + + String jsonValue = JSON.toJSONString(targetValue); + if (targetValue != null && !Strings.CS.equals("{}", jsonValue) && !Strings.CS.equals("[]", jsonValue)) { + source.set(jsonValue); + } + } + + // 组装资源列表 + var resources = new ArrayList(); + String src = source.get(); + if (StringUtils.isNotBlank(src)) { + if (Strings.CS.startsWith(src, "[")) { + resources.addAll(JSON.parseArray(src, Map.class)); + } else { + resources.add(JSON.parseObject(src, Map.class)); + } + } else { + resources.add(new BeanMap(retValue)); + } + + // 解析模块名 + String module = sendNotice.module(); + if (StringUtils.isNotEmpty(module)) { + try { + module = parser.parseExpression(module).getValue(resources, String.class); + } catch (Exception e) { + log.info("使用原值 module:{}", module); + } + } + + // 解析事件名 + String event = sendNotice.event(); + if (StringUtils.isNotEmpty(event)) { + try { + event = parser.parseExpression(event).getValue(context, String.class); + } catch (Exception e) { + log.info("使用原值 event:{}", event); + } + } + + log.info("event:{}", event); + + // 补全基础字段 + var object = JSON.parseMap(JSON.toJSONString(retValue)); + if (MapUtils.isNotEmpty(object)) { + List.of(ID, CREATE_USER, CREATE_TIME, UPDATE_TIME, UPDATE_USER) + .forEach(key -> + resources.forEach(resource -> { + if (object.containsKey(key) && resource.get(key) == null) { + resource.put(key, object.get(key)); + } + }) + ); + } + + commonNoticeSendService.sendNotice(module, event, resources, SessionUtils.getUserId(), OrganizationContext.getOrganizationId()); + + } catch (Exception e) { + log.error(e.getMessage(), e); + } finally { + source.remove(); + } + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/notice/common/NoticeModel.java b/backend/crm/src/main/java/cn/cordys/crm/system/notice/common/NoticeModel.java new file mode 100644 index 0000000..484361b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/notice/common/NoticeModel.java @@ -0,0 +1,54 @@ +package cn.cordys.crm.system.notice.common; + +import lombok.Builder; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; +import java.util.Map; + +@Data +@Builder +public class NoticeModel implements Serializable { + /** + * 保存 测试id + */ + private String testId; + /** + * 操作人 + */ + private String operator; + /** + * 保存状态 + */ + private String status; + /** + * Event + */ + private String event; + /** + * 消息主题 + */ + private String subject; + /** + * 消息内容 + */ + private String context; + + /** + * 模版里的参数信息 + */ + private Map paramMap; + /** + * 接收人 + */ + private List receivers; + /** + * 抄送人 + */ + private List recipients; + /** + * 排除自己 + */ + private boolean excludeSelf; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/notice/common/Receiver.java b/backend/crm/src/main/java/cn/cordys/crm/system/notice/common/Receiver.java new file mode 100644 index 0000000..4a8e0c0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/notice/common/Receiver.java @@ -0,0 +1,15 @@ +package cn.cordys.crm.system.notice.common; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; + +@Data +@AllArgsConstructor +@EqualsAndHashCode +public class Receiver implements Serializable { + private String userId; + private String type; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/notice/dto/NoticeRedisMessage.java b/backend/crm/src/main/java/cn/cordys/crm/system/notice/dto/NoticeRedisMessage.java new file mode 100644 index 0000000..8daf5a3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/notice/dto/NoticeRedisMessage.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.system.notice.dto; + +import cn.cordys.common.dto.RedisMessage; +import lombok.Data; + +@Data +public class NoticeRedisMessage extends RedisMessage { + /** + * redis 发布订阅消息补充 + */ + private String noticeType; + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/notice/dto/SseMessageDTO.java b/backend/crm/src/main/java/cn/cordys/crm/system/notice/dto/SseMessageDTO.java new file mode 100644 index 0000000..b3fd25f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/notice/dto/SseMessageDTO.java @@ -0,0 +1,16 @@ +package cn.cordys.crm.system.notice.dto; + +import cn.cordys.crm.system.dto.response.NotificationDTO; +import lombok.Data; + +import java.util.List; + +@Data +public class SseMessageDTO { + + private boolean read; + + private List notificationDTOList; + + private List announcementDTOList; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/notice/message/MessageDetailService.java b/backend/crm/src/main/java/cn/cordys/crm/system/notice/message/MessageDetailService.java new file mode 100644 index 0000000..ed59c26 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/notice/message/MessageDetailService.java @@ -0,0 +1,80 @@ +package cn.cordys.crm.system.notice.message; + + +import cn.cordys.common.util.BeanUtils; +import cn.cordys.crm.system.domain.MessageTask; +import cn.cordys.crm.system.dto.MessageDetailDTO; +import cn.cordys.crm.system.mapper.ExtMessageTaskMapper; +import cn.cordys.crm.system.utils.MessageTemplateUtils; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * @author guoyuqi + */ +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class MessageDetailService { + + @Resource + private ExtMessageTaskMapper extMessageTaskMapper; + + /** + * 获取唯一的消息任务 + * + * @param module 任务类型 + * @param organizationId 项目ID + * + * @return Listlist + */ + public List searchMessageByTypeAndOrgId(String module, boolean useTemplate, String template, String organizationId) { + try { + return getMessageDetailDTOs(module, useTemplate, template, organizationId); + } catch (Exception e) { + log.error(e.getMessage(), e); + return new ArrayList<>(); + } + } + + private List getMessageDetailDTOs(String module, boolean useTemplate, String template, String organizationId) { + List messageDetails = new ArrayList<>(); + List messageTaskLists = extMessageTaskMapper.getEnableMessageTaskByReceiveTypeAndTaskType(module, organizationId); + if (messageTaskLists == null) { + return new ArrayList<>(); + } + buildMessageDetails(useTemplate, template, messageDetails, messageTaskLists); + return messageDetails.stream() + .sorted(Comparator.comparing(MessageDetailDTO::getCreateTime, Comparator.nullsLast(Long::compareTo)).reversed()) + .distinct() + .collect(Collectors.toList()); + } + + private void buildMessageDetails(boolean useTemplate, String customTemplate, List messageDetails, List messageTaskLists) { + //消息通知任务以消息类型事件接收类型唯一进行分组 + Map> messageTaskGroup = messageTaskLists.stream().collect(Collectors.groupingBy(t -> (t.getTaskType() + t.getEvent()))); + messageTaskGroup.forEach((messageTaskId, messageTaskList) -> { + //获取同一任务所有的接收人 + MessageTask messageTask = messageTaskList.getFirst(); + MessageDetailDTO messageDetailDTO = new MessageDetailDTO(); + BeanUtils.copyBean(messageDetailDTO, messageTask); + if (!useTemplate) { + String template = MessageTemplateUtils.getTemplate(messageTask.getEvent()); + messageDetailDTO.setTemplate(template); + } else { + //这里特殊处理,如果使用模版,这里用调用处传来的模板 + messageDetailDTO.setTemplate(customTemplate); + } + messageDetails.add(messageDetailDTO); + }); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/notice/sender/AbstractNoticeSender.java b/backend/crm/src/main/java/cn/cordys/crm/system/notice/sender/AbstractNoticeSender.java new file mode 100644 index 0000000..3df25aa --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/notice/sender/AbstractNoticeSender.java @@ -0,0 +1,100 @@ +package cn.cordys.crm.system.notice.sender; + + +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.MessageDetailDTO; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.crm.system.notice.common.NoticeModel; +import cn.cordys.crm.system.notice.common.Receiver; +import cn.cordys.crm.system.utils.MessageTemplateUtils; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public abstract class AbstractNoticeSender implements NoticeSender { + + @Resource + private ExtUserMapper extUserMapper; + + protected String getContext(MessageDetailDTO messageDetailDTO, NoticeModel noticeModel) { + // 处理 userIds 中包含的特殊值 + noticeModel.setReceivers(getRealUserIds(noticeModel, messageDetailDTO.getOrganizationId())); + // 如果配置了模版就直接使用模版 + if (StringUtils.isNotBlank(messageDetailDTO.getTemplate())) { + return MessageTemplateUtils.getContent(messageDetailDTO.getTemplate(), noticeModel.getParamMap()); + } + String context = noticeModel.getContext(); + return MessageTemplateUtils.getContent(context, noticeModel.getParamMap()); + } + + protected String getSubjectText(MessageDetailDTO messageDetailDTO, NoticeModel noticeModel) { + //目前只有公告有标题消息通知使用事件+“通知” + // 如果配置了模版就直接使用模版 + if (StringUtils.isNotBlank(messageDetailDTO.getSubject())) { + return MessageTemplateUtils.getContent(messageDetailDTO.getSubject(), noticeModel.getParamMap()); + } + Map eventMap = MessageTemplateUtils.getEventMap(); + String eventText = eventMap.get(messageDetailDTO.getEvent()); + String context = eventText + Translator.get("notice.event.subject"); + if (StringUtils.isBlank(context)) { + context = noticeModel.getSubject(); + } + return MessageTemplateUtils.getContent(context, noticeModel.getParamMap()); + } + + private List getRealUserIds(NoticeModel noticeModel, String orgId) { + List toUsers = noticeModel.getReceivers(); + if (CollectionUtils.isEmpty(toUsers)) { + return new ArrayList<>(); + } + + // 去重复 + List userIds = toUsers.stream().map(Receiver::getUserId).distinct().toList(); + List users = getUsers(userIds, orgId); + Set realUserIds = users.stream().map(User::getId).collect(Collectors.toSet()); + //如果是admin用户,为特殊用户,将admin加入 + if (userIds.contains("admin")) { + realUserIds.add("admin"); + } + return toUsers.stream().filter(t -> realUserIds.contains(t.getUserId())).distinct().toList(); + } + + protected List getUsers(List userIds, String organizationId) { + if (CollectionUtils.isNotEmpty(userIds)) { + return extUserMapper.getOrgUserByUserIds(organizationId, userIds); + } else { + return new ArrayList<>(); + } + } + + protected List getResourceUserIds(List userIds, String organizationId) { + if (CollectionUtils.isNotEmpty(userIds)) { + return extUserMapper.getOrgUserResourceIds(userIds, organizationId); + } else { + return new ArrayList<>(); + } + } + + protected List getReceivers(List receivers, boolean excludeSelf, String operator) { + // 排除自己 + List realReceivers = new ArrayList<>(); + if (excludeSelf) { + for (Receiver receiver : receivers) { + if (!Strings.CS.equals(receiver.getUserId(), operator)) { + realReceivers.add(receiver); + } + } + } else { + realReceivers.addAll(receivers); + } + return realReceivers.stream().distinct().toList(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/notice/sender/NoticeSender.java b/backend/crm/src/main/java/cn/cordys/crm/system/notice/sender/NoticeSender.java new file mode 100644 index 0000000..a726ebb --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/notice/sender/NoticeSender.java @@ -0,0 +1,8 @@ +package cn.cordys.crm.system.notice.sender; + +import cn.cordys.crm.system.dto.MessageDetailDTO; +import cn.cordys.crm.system.notice.common.NoticeModel; + +public interface NoticeSender { + void send(MessageDetailDTO messageDetailDTO, NoticeModel noticeModel); +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/notice/sender/insite/InSiteNoticeSender.java b/backend/crm/src/main/java/cn/cordys/crm/system/notice/sender/insite/InSiteNoticeSender.java new file mode 100644 index 0000000..fe161ba --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/notice/sender/insite/InSiteNoticeSender.java @@ -0,0 +1,113 @@ +package cn.cordys.crm.system.notice.sender.insite; + + +import cn.cordys.common.constants.TopicConstants; +import cn.cordys.common.redis.MessagePublisher; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.domain.Notification; +import cn.cordys.crm.system.dto.MessageDetailDTO; +import cn.cordys.crm.system.notice.common.NoticeModel; +import cn.cordys.crm.system.notice.common.Receiver; +import cn.cordys.crm.system.notice.dto.NoticeRedisMessage; +import cn.cordys.crm.system.notice.sender.AbstractNoticeSender; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Component +@Slf4j +public class InSiteNoticeSender extends AbstractNoticeSender { + + protected static final String USER_PREFIX = "msg_user:"; // Redis 存储系统通知用户前缀 + protected static final String MSG_PREFIX = "msg_content:"; // Redis 存储系统通知内容信息前缀 + protected static final String USER_READ_PREFIX = "user_read:"; // Redis 存储用户读取前缀 + @Resource + private BaseMapper notificationBaseMapper; + @Resource + private StringRedisTemplate stringRedisTemplate; + @Resource + private MessagePublisher messagePublisher; + + public void sendAnnouncement(MessageDetailDTO messageDetailDTO, NoticeModel noticeModel, String context, String subjectText) { + List receivers = super.getReceivers(noticeModel.getReceivers(), noticeModel.isExcludeSelf(), noticeModel.getOperator()); + if (CollectionUtils.isEmpty(receivers)) { + // 日志记录没有接收者 + log.info("系统内没有接收者"); + return; + } + log.info("系统内发送通知,接收者为:{}", JSON.toJSONString(receivers)); + receivers.forEach(receiver -> { + String id = IDGenerator.nextStr(); + Map paramMap = noticeModel.getParamMap(); + Notification notification = new Notification(); + notification.setId(id); + notification.setSubject(StringUtils.isBlank(subjectText) ? Translator.get("notice.default.subject") : subjectText); + notification.setOrganizationId(messageDetailDTO.getOrganizationId()); + notification.setOperator(noticeModel.getOperator()); + notification.setOperation(noticeModel.getEvent()); + if (paramMap.get("resourceId") != null) { + notification.setResourceId((String) paramMap.get("resourceId")); + } else { + notification.setResourceId(messageDetailDTO.getId()); + } + notification.setResourceType(messageDetailDTO.getTaskType()); + if (paramMap.get("name") != null) { + notification.setResourceName((String) paramMap.get("name")); + } + if (paramMap.get("title") != null) { + notification.setResourceName((String) paramMap.get("title")); + } + notification.setType(receiver.getType()); + notification.setStatus(NotificationConstants.Status.UNREAD.name()); + notification.setReceiver(receiver.getUserId()); + notification.setContent(context.getBytes()); + notification.setCreateUser(noticeModel.getOperator()); + notification.setUpdateUser(noticeModel.getOperator()); + notification.setCreateTime(System.currentTimeMillis()); + notification.setUpdateTime(System.currentTimeMillis()); + notificationBaseMapper.insert(notification); + String messageText = JSON.toJSONString(notification); + //储存信息 + String userRedisKey = USER_PREFIX + receiver.getUserId(); + stringRedisTemplate.opsForZSet().add(userRedisKey, id, System.currentTimeMillis()); + stringRedisTemplate.opsForValue().set(MSG_PREFIX + id, messageText); + // 限制 Redis 只存 5 条消息:移除排名 5 之后的所有条目 + Set oldNotificationIds = stringRedisTemplate.opsForZSet() + .reverseRange(userRedisKey, 4, -1); + if (CollectionUtils.isNotEmpty(oldNotificationIds)) { + stringRedisTemplate.delete(oldNotificationIds.stream() + .map(nid -> MSG_PREFIX + nid) + .toList()); + stringRedisTemplate.opsForZSet().removeRange(userRedisKey, 0, -6); + } + //更新用户的已读全部消息状态 0 为未读,1为已读 + stringRedisTemplate.opsForValue().set(USER_READ_PREFIX + receiver.getUserId(), "False"); + + // 发送消息 + NoticeRedisMessage noticeRedisMessage = new NoticeRedisMessage(); + noticeRedisMessage.setMessage(receiver.getUserId()); + noticeRedisMessage.setNoticeType(NotificationConstants.Type.SYSTEM_NOTICE.toString()); + messagePublisher.publish(TopicConstants.SSE_TOPIC, JSON.toJSONString(noticeRedisMessage)); + }); + + } + + @Override + public void send(MessageDetailDTO messageDetailDTO, NoticeModel noticeModel) { + String context = super.getContext(messageDetailDTO, noticeModel); + String subjectText = super.getSubjectText(messageDetailDTO, noticeModel); + sendAnnouncement(messageDetailDTO, noticeModel, context, subjectText); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/notice/sender/mail/MailNoticeSender.java b/backend/crm/src/main/java/cn/cordys/crm/system/notice/sender/mail/MailNoticeSender.java new file mode 100644 index 0000000..65c62ed --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/notice/sender/mail/MailNoticeSender.java @@ -0,0 +1,54 @@ +package cn.cordys.crm.system.notice.sender.mail; + + +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.MessageDetailDTO; +import cn.cordys.crm.system.notice.common.NoticeModel; +import cn.cordys.crm.system.notice.common.Receiver; +import cn.cordys.crm.system.notice.sender.AbstractNoticeSender; +import cn.cordys.crm.system.utils.MailSender; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +@Slf4j +public class MailNoticeSender extends AbstractNoticeSender { + + @Resource + private MailSender mailSender; + + public void sendMail(String context, NoticeModel noticeModel, String organizationId, String subjectText) { + List receivers = super.getReceivers(noticeModel.getReceivers(), noticeModel.isExcludeSelf(), noticeModel.getOperator()); + if (CollectionUtils.isEmpty(receivers)) { + return; + } + List userIds = receivers.stream() + .map(Receiver::getUserId) + .distinct() + .collect(Collectors.toList()); + String[] users = super.getUsers(userIds, organizationId).stream() + .map(User::getEmail) + .distinct() + .toArray(String[]::new); + + mailSender.send(subjectText, context, users, new String[0], organizationId); + } + + @Override + public void send(MessageDetailDTO messageDetailDTO, NoticeModel noticeModel) { + String context = super.getContext(messageDetailDTO, noticeModel); + String subjectText = super.getSubjectText(messageDetailDTO, noticeModel); + try { + sendMail(context, noticeModel, messageDetailDTO.getOrganizationId(), subjectText); + log.debug("发送邮件结束"); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/notice/sse/SseController.java b/backend/crm/src/main/java/cn/cordys/crm/system/notice/sse/SseController.java new file mode 100644 index 0000000..2918685 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/notice/sse/SseController.java @@ -0,0 +1,43 @@ +package cn.cordys.crm.system.notice.sse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; + +@RestController +@RequestMapping("/sse") +@Tag(name = "消息通知-SSE") +public class SseController { + + private final SseService sseService; + + public SseController(SseService sseService) { + this.sseService = sseService; + } + + /** + * 客户端订阅 SSE 事件流 + */ + @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @Operation(summary = "客户端订阅 SSE 事件流") + @CrossOrigin + public Flux subscribe(@RequestParam String userId, @RequestParam String clientId, HttpServletResponse response) { + response.setHeader("X-Accel-Buffering", "no"); + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache"); + return sseService.addClient(userId, clientId); + } + + + /** + * 主动断开客户端连接 + */ + @GetMapping("/close") + @Operation(summary = "主动断开客户端连接") + public void close(@RequestParam String userId, @RequestParam String clientId) { + sseService.removeClient(userId, clientId); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/notice/sse/SseService.java b/backend/crm/src/main/java/cn/cordys/crm/system/notice/sse/SseService.java new file mode 100644 index 0000000..97dd3a8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/notice/sse/SseService.java @@ -0,0 +1,199 @@ +package cn.cordys.crm.system.notice.sse; + +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.domain.Notification; +import cn.cordys.crm.system.dto.response.NotificationDTO; +import cn.cordys.crm.system.mapper.ExtNotificationMapper; +import cn.cordys.crm.system.notice.dto.SseMessageDTO; +import cn.cordys.crm.system.service.SendModuleService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Sinks; + +import java.time.Duration; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@Slf4j +public class SseService { + + private static final String USER_ANNOUNCE_PREFIX = "announce_user:"; + private static final String ANNOUNCE_PREFIX = "announce_content:"; + private static final String USER_PREFIX = "msg_user:"; + private static final String MSG_PREFIX = "msg_content:"; + private static final String USER_READ_PREFIX = "user_read:"; + private final Map> userClients = new ConcurrentHashMap<>(); + @Resource + private ExtNotificationMapper extNotificationMapper; + @Resource + private SendModuleService sendModuleService; + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 添加或获取现有客户端流 + */ + public Flux addClient(String userId, String clientId) { + log.info("当前在线用户数: {} ", userClients.size()); + + // 过滤掉默认 admin 用户 + if (StringUtils.isAnyBlank(userId, clientId) || "admin".equals(userId)) { + log.info("User ID or Client ID is blank, cannot add client."); + return null; + } + Map inner = userClients.computeIfAbsent(userId, + k -> Collections.synchronizedMap(new LinkedHashMap<>())); + + synchronized (inner) { + if (inner.containsKey(clientId)) { + return inner.get(clientId).flux; + } + ClientSinkWrapper wrapper = new ClientSinkWrapper(); + // 控制最多 2 个客户端 + inner.put(clientId, wrapper); + if (inner.size() > 2) { + Iterator it = inner.keySet().iterator(); + String oldest = it.next(); + ClientSinkWrapper old = inner.remove(oldest); + old.complete(); + } + // 首次心跳 + wrapper.emit("HEARTBEAT: " + System.currentTimeMillis()); + return wrapper.flux; + } + } + + /** + * 移除客户端 + */ + public void removeClient(String userId, String clientId) { + if (StringUtils.isAnyBlank(userId, clientId)) return; + Map map = userClients.get(userId); + if (map == null) return; + synchronized (map) { + ClientSinkWrapper w = map.remove(clientId); + if (w != null) w.complete(); + if (map.isEmpty()) userClients.remove(userId); + } + } + + /** + * 向指定用户所有客户端发送事件 + */ + public void sendToUser(String userId, Object data) { + Map map = userClients.get(userId); + if (map != null) { + map.forEach((clientId, wrapper) -> wrapper.emit(JSON.toJSONString(data))); + } + } + + /** + * 向单个客户端发送事件 + */ + public void sendToClient(String userId, String clientId, Object data) { + Optional.ofNullable(userClients.get(userId)) + .map(m -> m.get(clientId)).ifPresent(wrapper -> wrapper.emit(JSON.toJSONString(data))); + } + + /** + * 定时广播逻辑调用 + */ + public void broadcastPeriodically(String userId, String sendType) { + SseMessageDTO msg = buildMessage(userId, sendType); + sendToUser(userId, msg); + log.info("Broadcast to user {} at {}", userId, System.currentTimeMillis()); + } + + /** + * 构建通知消息体 + */ + private SseMessageDTO buildMessage(String userId, String sendType) { + SseMessageDTO dto = new SseMessageDTO(); + if (Strings.CI.equals(sendType, NotificationConstants.Type.SYSTEM_NOTICE.toString())) { + List modules = sendModuleService.getNoticeModules(); + Set sysValues = stringRedisTemplate.opsForZSet().range(USER_PREFIX + userId, 0, -1); + if (CollectionUtils.isNotEmpty(sysValues)) { + dto.setNotificationDTOList(buildDTOList(sysValues, MSG_PREFIX)); + } else { + if (CollectionUtils.isNotEmpty(modules)) { + List list = extNotificationMapper + .selectLastList(userId, OrganizationContext.getOrganizationId(), modules); + list.forEach(n -> n.setContentText(new String(n.getContent()))); + dto.setNotificationDTOList(list); + } + } + } + if (Strings.CI.equals(sendType, NotificationConstants.Type.ANNOUNCEMENT_NOTICE.toString())) { + Set values = stringRedisTemplate.opsForZSet().range(USER_ANNOUNCE_PREFIX + userId, 0, -1); + if (CollectionUtils.isNotEmpty(values)) { + dto.setAnnouncementDTOList(buildDTOList(values, ANNOUNCE_PREFIX)); + } + } + dto.setRead(Boolean.parseBoolean( + stringRedisTemplate.opsForValue().get(USER_READ_PREFIX + userId) + )); + return dto; + } + + /** + * 根据 Redis 构建 DTO 列表 + */ + private List buildDTOList(Set values, String prefix) { + if (CollectionUtils.isEmpty(values)) { + return Collections.emptyList(); + } + return values.stream() + .map(val -> stringRedisTemplate.opsForValue().get(prefix + val)) + .filter(StringUtils::isNotBlank) + .map(json -> { + try { + Notification notification = JSON.parseObject(json, Notification.class); + NotificationDTO dto = new NotificationDTO(); + BeanUtils.copyBean(dto, notification); + dto.setContentText(new String(notification.getContent())); + return dto; + } catch (Exception e) { + log.warn("Failed to parse notification from Redis: {}", e.getMessage()); + return null; + } + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(NotificationDTO::getCreateTime).reversed()) + .toList(); + } + + /** + * 客户端包装,包含 Sink 与 Flux + */ + private static class ClientSinkWrapper { + private final Sinks.Many sink; + private final Flux flux; + + ClientSinkWrapper() { + this.sink = Sinks.many().multicast().onBackpressureBuffer(); + this.flux = sink.asFlux() + // 心跳自动推送每 15 秒一次 + .mergeWith(Flux.interval(Duration.ofSeconds(15)) + .map(tick -> "HEARTBEAT: " + System.currentTimeMillis())) + .doOnCancel(this::complete); + } + + void emit(String message) { + sink.tryEmitNext(message); + } + + void complete() { + sink.tryEmitComplete(); + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/AnnouncementService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/AnnouncementService.java new file mode 100644 index 0000000..e2208d6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/AnnouncementService.java @@ -0,0 +1,377 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.TopicConstants; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.redis.MessagePublisher; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.SubListUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.domain.Announcement; +import cn.cordys.crm.system.domain.Notification; +import cn.cordys.crm.system.dto.AnnouncementReceiveTypeDTO; +import cn.cordys.crm.system.dto.convert.AnnouncementContentDTO; +import cn.cordys.crm.system.dto.log.AnnouncementLogDTO; +import cn.cordys.crm.system.dto.request.AnnouncementPageRequest; +import cn.cordys.crm.system.dto.request.AnnouncementRequest; +import cn.cordys.crm.system.dto.response.AnnouncementDTO; +import cn.cordys.crm.system.dto.response.NotificationDTO; +import cn.cordys.crm.system.dto.response.OptionScopeDTO; +import cn.cordys.crm.system.mapper.ExtAnnouncementMapper; +import cn.cordys.crm.system.mapper.ExtDepartmentMapper; +import cn.cordys.crm.system.mapper.ExtNotificationMapper; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.crm.system.notice.dto.NoticeRedisMessage; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.*; + + +@Service +public class AnnouncementService { + + // Redis 存储用户前缀 + private static final String USER_ANNOUNCE_PREFIX = "announce_user:"; + // Redis 存储信息前缀 + private static final String ANNOUNCE_PREFIX = "announce_content:"; + // Redis 存储用户读取前缀 + private static final String USER_READ_PREFIX = "user_read:"; + @Resource + private BaseMapper announcementMapper; + @Resource + private BaseMapper notificationBaseMapper; + @Resource + private ExtAnnouncementMapper extAnnouncementMapper; + @Resource + private ExtDepartmentMapper extDepartmentMapper; + @Resource + private ExtNotificationMapper notificationMapper; + @Resource + private ExtUserMapper userMapper; + @Resource + private StringRedisTemplate stringRedisTemplate; + @Resource + private MessagePublisher messagePublisher; + + @Transactional(rollbackFor = Exception.class) + @OperationLog(module = LogModule.SYSTEM_MESSAGE_ANNOUNCEMENT, type = LogType.ADD) + public void add(AnnouncementRequest request, String userId) { + Set userSet = new HashSet<>(); + AnnouncementReceiveTypeDTO announcementReceiveTypeDTO = new AnnouncementReceiveTypeDTO(); + dealReceiverIds(request, userSet, announcementReceiveTypeDTO); + List userIds = new ArrayList<>(userSet); + + Announcement announcement = new Announcement(); + announcement.setId(IDGenerator.nextStr()); + announcement.setSubject(request.getSubject()); + announcement.setContent(request.getContent().getBytes()); + announcement.setStartTime(request.getStartTime()); + announcement.setEndTime(request.getEndTime()); + announcement.setUrl(request.getUrl()); + announcement.setRenameUrl(request.getRenameUrl()); + announcement.setReceiver(JSON.toJSONString(userIds).getBytes(StandardCharsets.UTF_8)); + announcement.setReceiveType(JSON.toJSONString(announcementReceiveTypeDTO).getBytes(StandardCharsets.UTF_8)); + announcement.setOrganizationId(request.getOrganizationId()); + announcement.setCreateTime(System.currentTimeMillis()); + announcement.setUpdateTime(System.currentTimeMillis()); + announcement.setCreateUser(userId); + announcement.setUpdateUser(userId); + announcement.setNotice(false); + //根据有效时间判断是否生成notification + if (request.getStartTime() <= announcement.getCreateTime() && request.getEndTime() >= announcement.getCreateTime()) { + convertNotification(userId, announcement, userIds); + announcement.setNotice(true); + } + announcementMapper.insert(announcement); + AnnouncementLogDTO announcementLogDTO = buildNewLogDTO(request, announcement, announcementReceiveTypeDTO); + // 添加日志上下文 + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(announcement.getId()) + .resourceName(announcement.getSubject()) + .modifiedValue(announcementLogDTO) + .build() + ); + } + + private AnnouncementLogDTO buildNewLogDTO(AnnouncementRequest request, Announcement announcement, AnnouncementReceiveTypeDTO announcementReceiveTypeDTO) { + AnnouncementLogDTO announcementLogDTO = new AnnouncementLogDTO(); + BeanUtils.copyBean(announcementLogDTO, announcement); + List receiverName = getReceiverName(announcementReceiveTypeDTO); + announcementLogDTO.setReceiver(receiverName); + announcementLogDTO.setContent(request.getContent()); + if (request.getStartTime() != null) { + announcementLogDTO.setStartTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(request.getStartTime())); + } + if (request.getEndTime() != null) { + announcementLogDTO.setEndTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(request.getEndTime())); + } + return announcementLogDTO; + } + + private AnnouncementLogDTO getOldLogDTO(Announcement originalAnnouncement) { + AnnouncementReceiveTypeDTO oldReceiveTypeDTO = JSON.parseObject(new String(originalAnnouncement.getReceiveType()), AnnouncementReceiveTypeDTO.class); + AnnouncementLogDTO oldLogDTO = new AnnouncementLogDTO(); + BeanUtils.copyBean(oldLogDTO, originalAnnouncement); + List receiverName = getReceiverName(oldReceiveTypeDTO); + oldLogDTO.setReceiver(receiverName); + oldLogDTO.setContent(new String(originalAnnouncement.getContent(), StandardCharsets.UTF_8)); + oldLogDTO.setStartTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(originalAnnouncement.getStartTime())); + oldLogDTO.setEndTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(originalAnnouncement.getEndTime())); + return oldLogDTO; + } + + + @Transactional(rollbackFor = Exception.class) + @OperationLog(module = LogModule.SYSTEM_MESSAGE_ANNOUNCEMENT, type = LogType.UPDATE) + public void update(AnnouncementRequest request, String userId) { + String organizationId = request.getOrganizationId(); + Announcement originalAnnouncement = announcementMapper.selectByPrimaryKey(request.getId()); + if (originalAnnouncement == null) { + throw new GenericException(Translator.get("announcement.blank")); + } + AnnouncementReceiveTypeDTO announcementReceiveTypeDTO = new AnnouncementReceiveTypeDTO(); + Set userSet = new HashSet<>(); + dealReceiverIds(request, userSet, announcementReceiveTypeDTO); + List userIds = new ArrayList<>(userSet); + Announcement announcement = BeanUtils.copyBean(new Announcement(), request); + announcement.setSubject(request.getSubject()); + announcement.setContent(request.getContent().getBytes()); + announcement.setStartTime(request.getStartTime()); + announcement.setEndTime(request.getEndTime()); + announcement.setUrl(request.getUrl()); + announcement.setRenameUrl(request.getRenameUrl()); + announcement.setReceiver(JSON.toJSONString(userIds).getBytes(StandardCharsets.UTF_8)); + announcement.setReceiveType(JSON.toJSONString(announcementReceiveTypeDTO).getBytes(StandardCharsets.UTF_8)); + announcement.setOrganizationId(organizationId); + announcement.setUpdateTime(System.currentTimeMillis()); + announcement.setUpdateUser(userId); + announcement.setNotice(false); + announcementMapper.updateById(announcement); + if (originalAnnouncement.getNotice()) { + //删除旧的notice,根据接收人生成新的未读的notice + String announcementId = announcement.getId(); + deleteAnnouncementKeyById(announcementId, organizationId); + notificationMapper.deleteByResourceId(announcementId); + //根据有效时间判断是否生成notification + if (request.getStartTime() <= System.currentTimeMillis() && request.getEndTime() >= System.currentTimeMillis()) { + //如果公告的有效时间在当前时间内,则生成通知 + convertNotification(userId, announcement, userIds); + } + } + // 添加日志上下文 + AnnouncementLogDTO oldLogDTO = getOldLogDTO(originalAnnouncement); + if (request.getStartTime() == null) { + request.setStartTime(originalAnnouncement.getStartTime()); + } + if (request.getEndTime() == null) { + request.setEndTime(originalAnnouncement.getEndTime()); + } + AnnouncementLogDTO announcementLogDTO = buildNewLogDTO(request, announcement, announcementReceiveTypeDTO); + String resourceName = Optional.ofNullable(announcement.getSubject()).orElse(originalAnnouncement.getSubject()); + OperationLogContext.setContext( + LogContextInfo.builder() + .originalValue(oldLogDTO) + .resourceId(originalAnnouncement.getId()) + .resourceName(resourceName) + .modifiedValue(announcementLogDTO) + .build() + ); + } + + private void dealReceiverIds(AnnouncementRequest request, Set userSet, AnnouncementReceiveTypeDTO announcementReceiveTypeDTO) { + if (CollectionUtils.isNotEmpty(request.getDeptIds())) { + List depIds = extDepartmentMapper.selectChildrenByIds(request.getDeptIds()); + if (CollectionUtils.isNotEmpty(depIds)) { + userSet.addAll(extDepartmentMapper.getUserIdsByDeptIds(depIds)); + } + announcementReceiveTypeDTO.setDeptIds(request.getDeptIds()); + } + if (CollectionUtils.isNotEmpty(request.getUserIds())) { + userSet.addAll(request.getUserIds()); + announcementReceiveTypeDTO.setUserIds(request.getUserIds()); + } + } + + /** + * 删除缓存中的公告提示 + * + * @param announcementId 公告id + * @param organizationId 组织Id + */ + private void deleteAnnouncementKeyById(String announcementId, String organizationId) { + Notification notificationRequest = new Notification(); + notificationRequest.setResourceId(announcementId); + notificationRequest.setOrganizationId(organizationId); + List notificationDTOS = notificationMapper.selectByAnyOne(notificationRequest); + for (NotificationDTO notificationDTO : notificationDTOS) { + String notificationDTOId = notificationDTO.getId(); + stringRedisTemplate.opsForZSet().remove(USER_ANNOUNCE_PREFIX + notificationDTO.getReceiver(), notificationDTOId); + stringRedisTemplate.delete(ANNOUNCE_PREFIX + notificationDTOId); + } + } + + /** + * 公告转为接收人收到的通知 + * + * @param userId 创建人 + * @param announcement 公告 + * @param userIds 接收人集合 + */ + public void convertNotification(String userId, Announcement announcement, List userIds) { + SubListUtils.dealForSubList(userIds, 50, (subUserIds) -> { + List notifications = new ArrayList<>(); + for (String subUserId : subUserIds) { + Notification notification = new Notification(); + String id = IDGenerator.nextStr(); + notification.setId(id); + notification.setType(NotificationConstants.Type.ANNOUNCEMENT_NOTICE.name()); + notification.setReceiver(subUserId); + notification.setSubject(announcement.getSubject()); + notification.setStatus(NotificationConstants.Status.UNREAD.name()); + notification.setOperator(userId); + notification.setOperation(NotificationConstants.Type.ANNOUNCEMENT_NOTICE.name()); + notification.setOrganizationId(announcement.getOrganizationId()); + notification.setResourceId(announcement.getId()); + notification.setResourceType(NotificationConstants.Type.ANNOUNCEMENT_NOTICE.name()); + notification.setResourceName(announcement.getSubject()); + AnnouncementContentDTO announcementContentDTO = new AnnouncementContentDTO(); + announcementContentDTO.setUrl(announcement.getUrl()); + announcementContentDTO.setRenameUrl(announcement.getRenameUrl()); + announcementContentDTO.setContent(new String(announcement.getContent())); + notification.setContent(JSON.toJSONString(announcementContentDTO).getBytes()); + notification.setCreateUser(userId); + notification.setUpdateUser(userId); + notification.setCreateTime(System.currentTimeMillis()); + notification.setUpdateTime(System.currentTimeMillis()); + notifications.add(notification); + //根据公告的有效时间判断是否需要发送通知 + if (announcement.getStartTime() <= System.currentTimeMillis() && announcement.getEndTime() >= System.currentTimeMillis()) { + NotificationDTO notificationDTO = new NotificationDTO(); + BeanUtils.copyBean(notificationDTO, notification); + notificationDTO.setContentText(JSON.toJSONString(announcementContentDTO)); + String messageText = JSON.toJSONString(notificationDTO); + + stringRedisTemplate.opsForZSet().add(USER_ANNOUNCE_PREFIX + subUserId, id, System.currentTimeMillis()); + stringRedisTemplate.opsForValue().set(ANNOUNCE_PREFIX + id, messageText); + //更新用户的已读全部消息状态 0 为未读,1为已读 + stringRedisTemplate.opsForValue().set(USER_READ_PREFIX + subUserId, "False"); + // 发送消息 + NoticeRedisMessage noticeRedisMessage = new NoticeRedisMessage(); + noticeRedisMessage.setMessage(subUserId); + noticeRedisMessage.setNoticeType(NotificationConstants.Type.ANNOUNCEMENT_NOTICE.toString()); + messagePublisher.publish(TopicConstants.SSE_TOPIC, JSON.toJSONString(noticeRedisMessage)); + //sseService.broadcastPeriodically(subUserId, NotificationConstants.Type.ANNOUNCEMENT_NOTICE.toString()); + } + } + notificationBaseMapper.batchInsert(notifications); + }); + } + + @Transactional(rollbackFor = Exception.class) + @OperationLog(module = LogModule.SYSTEM_MESSAGE_ANNOUNCEMENT, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id) { + Announcement announcement = announcementMapper.selectByPrimaryKey(id); + if (announcement == null) { + throw new RuntimeException(Translator.get("announcement.blank")); + } + deleteAnnouncementKeyById(id, announcement.getOrganizationId()); + announcementMapper.deleteByPrimaryKey(id); + notificationMapper.deleteByResourceId(id); + + // 添加日志上下文 + OperationLogContext.setResourceName(announcement.getSubject()); + } + + /** + * 公告列表分页查询 + * + * @param request request + * + * @return 公告列表 + */ + public List page(AnnouncementPageRequest request) { + List announcementDTOS = extAnnouncementMapper.selectByBaseRequest(request); + if (CollectionUtils.isNotEmpty(announcementDTOS)) { + for (AnnouncementDTO announcementDTO : announcementDTOS) { + announcementDTO.setContentText(new String(announcementDTO.getContent(), StandardCharsets.UTF_8)); + setReceiverNameOption(announcementDTO); + } + } + return announcementDTOS; + } + + /** + * 获取公告 + * + * @param id 公告id + * + * @return AnnouncementDTO + */ + public AnnouncementDTO detail(String id) { + AnnouncementDTO announcementDTO = extAnnouncementMapper.selectById(id); + if (announcementDTO == null) { + throw new RuntimeException(Translator.get("announcement.blank")); + } + setReceiverNameOption(announcementDTO); + announcementDTO.setContentText(new String(announcementDTO.getContent(), StandardCharsets.UTF_8)); + return announcementDTO; + } + + private void setReceiverNameOption(AnnouncementDTO announcementDTO) { + AnnouncementReceiveTypeDTO announcementReceiveTypeDTO = JSON.parseObject(new String(announcementDTO.getReceiveType()), AnnouncementReceiveTypeDTO.class); + if (CollectionUtils.isNotEmpty(announcementReceiveTypeDTO.getDeptIds())) { + List idNameByIds = extDepartmentMapper.getIdNameByIds(announcementReceiveTypeDTO.getDeptIds()); + List optionScopeDTOList = new ArrayList<>(); + for (OptionDTO idNameById : idNameByIds) { + OptionScopeDTO optionScopeDTO = new OptionScopeDTO(); + BeanUtils.copyBean(optionScopeDTO, idNameById); + optionScopeDTO.setScope("DEPARTMENT"); + optionScopeDTOList.add(optionScopeDTO); + } + announcementDTO.setDeptIdName(optionScopeDTOList); + } + if (CollectionUtils.isNotEmpty(announcementReceiveTypeDTO.getUserIds())) { + List idNameByIds = userMapper.selectUserOptionByIds(announcementReceiveTypeDTO.getUserIds()); + List optionScopeDTOList = new ArrayList<>(); + for (OptionDTO idNameById : idNameByIds) { + OptionScopeDTO optionScopeDTO = new OptionScopeDTO(); + BeanUtils.copyBean(optionScopeDTO, idNameById); + optionScopeDTO.setScope("USER"); + optionScopeDTOList.add(optionScopeDTO); + } + announcementDTO.setUserIdName(optionScopeDTOList); + } + } + + public List getReceiverName(AnnouncementReceiveTypeDTO announcementReceiveTypeDTO) { + List returnNames = new ArrayList<>(); + if (CollectionUtils.isNotEmpty(announcementReceiveTypeDTO.getDeptIds())) { + List names = extDepartmentMapper.getNameByIds(announcementReceiveTypeDTO.getDeptIds()); + returnNames.addAll(names); + } + if (CollectionUtils.isNotEmpty(announcementReceiveTypeDTO.getUserIds())) { + List names = userMapper.selectUserNameByIds(announcementReceiveTypeDTO.getUserIds()); + returnNames.addAll(names); + } + return returnNames; + + } + +} + diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/AttachmentService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/AttachmentService.java new file mode 100644 index 0000000..3360c96 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/AttachmentService.java @@ -0,0 +1,282 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.crm.system.domain.Attachment; +import cn.cordys.crm.system.dto.request.UploadTransferRequest; +import cn.cordys.file.engine.DefaultRepositoryDir; +import cn.cordys.file.engine.FileCopyRequest; +import cn.cordys.file.engine.FileRequest; +import cn.cordys.file.engine.StorageType; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * @author song-cc-rock + */ +@Service +@Slf4j +public class AttachmentService { + + @Resource + private BaseMapper attachmentMapper; + + @Resource + private FileCommonService fileCommonService; + + /** + * 上传临时附件 + * + * @param files 上传附件集合 + */ + public List uploadTemp(List files) { + List tempPicIds = new ArrayList<>(); + FileRequest tempRequest = new FileRequest(null, StorageType.LOCAL.name(), null); + files.forEach(file -> { + String tempPicId = IDGenerator.nextStr(); + tempRequest.setFolder(DefaultRepositoryDir.getTempFileDir(tempPicId)); + tempRequest.setFileName(file.getOriginalFilename()); + fileCommonService.upload(file, tempRequest); + tempPicIds.add(tempPicId); + }); + return tempPicIds; + } + + /** + * 删除附件 + * + * @param attachmentId 附件ID + */ + public void delete(String attachmentId) { + Attachment attachment = attachmentMapper.selectByPrimaryKey(attachmentId); + if (attachment == null) { + // 删除临时文件目录 + fileCommonService.deleteFolder(new FileRequest(DefaultRepositoryDir.getTempFileDir(attachmentId), StorageType.LOCAL.name(), null), true); + } else { + // 删除正式文件 + fileCommonService.deleteFolder(new FileRequest(DefaultRepositoryDir.getTransferFileDir(attachment.getOrganizationId(), attachment.getResourceId(), attachment.getId()), + StorageType.LOCAL.name(), attachment.getName()), false); + } + } + + /** + * 获取文件流 + * + * @param attachmentId 附件ID + * + * @return 文件流 + */ + public ResponseEntity getResource(String attachmentId) { + Attachment attachment = attachmentMapper.selectByPrimaryKey(attachmentId); + FileRequest request; + ResponseEntity.BodyBuilder responseBuilder = ResponseEntity.ok(); + try { + InputStream fileStream; + if (attachment == null) { + // get pic from temp dir + request = new FileRequest(DefaultRepositoryDir.getTempFileDir(attachmentId), StorageType.LOCAL.name(), null); + List folderFiles = fileCommonService.getFolderFiles(request); + if (CollectionUtils.isEmpty(folderFiles)) { + return null; + } + File file = folderFiles.getFirst(); + fileStream = new FileInputStream(file); + responseBuilder.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodeName(file.getName())) + .contentLength(file.length()) + .contentType(isSvg(file.getName()) ? MediaType.parseMediaType("image/svg+xml") : MediaType.parseMediaType("application/octet-stream")); + } else { + // get attachment from transferred dir + request = new FileRequest(DefaultRepositoryDir.getTransferFileDir(attachment.getOrganizationId(), attachment.getResourceId(), attachment.getId()), StorageType.LOCAL.name(), attachment.getName()); + fileStream = fileCommonService.getFileInputStream(request); + if (fileStream == null) { + throw new GenericException("The file does not exist or has been deleted"); + } + responseBuilder.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodeName(attachment.getName())) + .contentLength(attachment.getSize()) + .contentType(isSvg(attachment.getName()) ? MediaType.parseMediaType("image/svg+xml") : MediaType.parseMediaType("application/octet-stream")); + } + return responseBuilder + .body(new InputStreamResource(fileStream)); + } catch (Exception e) { + log.error(e.getMessage()); + return null; + } + } + + + /** + * 判断是否svg文件 + * + * @param fileName 文件名 + * + * @return 是否svg + */ + private boolean isSvg(String fileName) { + return fileName.endsWith(".svg") || fileName.endsWith(".SVG"); + } + + /** + * 处理临时附件: 转存新的临时附件, 移除删除的正式附件 + * + * @param transferRequest 转存参数 + */ + public void processTemp(UploadTransferRequest transferRequest) { + List attachments = new ArrayList<>(); + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(Attachment::getResourceId, transferRequest.getResourceId()); + List transferredAttachments = attachmentMapper.selectListByLambda(queryWrapper); + List transferredIds = transferredAttachments.stream().map(Attachment::getId).toList(); + // insert new attachment + transferRequest.getTempFileIds().stream().filter(tempFileId -> !transferredIds.contains(tempFileId)).forEach(tempFileId -> { + // transfer new pic + FileRequest request = new FileRequest(DefaultRepositoryDir.getTmpDir() + "/" + tempFileId, StorageType.LOCAL.name(), null); + List folderTempFiles = fileCommonService.getFolderFiles(request); + if (!CollectionUtils.isEmpty(folderTempFiles)) { + File tempFile = folderTempFiles.getFirst(); + FileCopyRequest copyRequest = new FileCopyRequest(DefaultRepositoryDir.getTempFileDir(tempFileId), + DefaultRepositoryDir.getTransferFileDir(transferRequest.getOrganizationId(), transferRequest.getResourceId(), tempFileId), + tempFile.getName()); + fileCommonService.copyFile(copyRequest, StorageType.LOCAL.name()); + Attachment attachment = new Attachment(); + attachment.setId(tempFileId); + attachment.setName(tempFile.getName()); + String type = ""; + int index = tempFile.getName().lastIndexOf('.'); + if (index != -1 && index < tempFile.getName().length() - 1) { + type = tempFile.getName().substring(index + 1); + } + attachment.setType(type); + attachment.setSize(tempFile.length()); + attachment.setStorage(StorageType.LOCAL.name()); + attachment.setOrganizationId(transferRequest.getOrganizationId()); + attachment.setResourceId(transferRequest.getResourceId()); + attachment.setCreateTime(System.currentTimeMillis()); + attachment.setCreateUser(transferRequest.getOperatorUserId()); + attachment.setUpdateTime(System.currentTimeMillis()); + attachment.setUpdateUser(transferRequest.getOperatorUserId()); + attachments.add(attachment); + } + }); + attachmentMapper.batchInsert(attachments); + // remove deleted + List removedIds = transferredIds.stream().filter(aId -> !transferRequest.getTempFileIds().contains(aId)).toList(); + if (!CollectionUtils.isEmpty(removedIds)) { + LambdaQueryWrapper duplicateQueryWrapper = new LambdaQueryWrapper<>(); + duplicateQueryWrapper.in(Attachment::getId, removedIds); + attachmentMapper.deleteByLambda(duplicateQueryWrapper); + removedIds.forEach(removeId -> { + FileRequest request = new FileRequest(DefaultRepositoryDir.getTransferFileDir(transferRequest.getOrganizationId(), transferRequest.getResourceId(), removeId), StorageType.LOCAL.name(), null); + fileCommonService.deleteFolder(request, true); + }); + } + } + + /** + * 通过ID映射批量复制附件 + * + * @param oldOfNewIdMap {旧的附件ID: 新的附件ID} 集合 + * @param targetId 目标资源ID + * @param currentUser 当前操作用户 + */ + public void batchCopyOfIdMap(Map oldOfNewIdMap, String targetId, String currentUser) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.in(Attachment::getId, oldOfNewIdMap.keySet().stream().toList()); + List attachments = attachmentMapper.selectListByLambda(queryWrapper); + Map attachmentMap = attachments.stream().collect(Collectors.toMap(Attachment::getId, Function.identity())); + List newAttachments = new ArrayList<>(); + oldOfNewIdMap.forEach((oId, nId) -> { + if (!attachmentMap.containsKey(oId)) { + return; + } + Attachment oldAttachment = attachmentMap.get(oId); + // 复制文件 + FileCopyRequest copyRequest = new FileCopyRequest( + DefaultRepositoryDir.getTransferFileDir(oldAttachment.getOrganizationId(), oldAttachment.getResourceId(), oId), + DefaultRepositoryDir.getTransferFileDir(oldAttachment.getOrganizationId(), targetId, nId), + oldAttachment.getName()); + Thread.startVirtualThread(() -> fileCommonService.copyFile(copyRequest, StorageType.LOCAL.name())); + // 复制附件记录 + oldAttachment.setId(nId); + oldAttachment.setResourceId(targetId); + oldAttachment.setCreateTime(System.currentTimeMillis()); + oldAttachment.setCreateUser(currentUser); + oldAttachment.setUpdateTime(System.currentTimeMillis()); + oldAttachment.setUpdateUser(currentUser); + newAttachments.add(oldAttachment); + }); + attachmentMapper.batchInsert(newAttachments); + } + + /** + * 编码文件名 + * + * @param fileName 文件名 + * + * @return 编码后文件名 + */ + private String encodeName(String fileName) { + return URLEncoder.encode(fileName, StandardCharsets.UTF_8) + .replaceAll("\\+", "%20"); + } + + /** + * 追加临时文件 + * @param transferRequest 参数 + */ + @Async + public void appendTemp(UploadTransferRequest transferRequest) { + List attachments = new ArrayList<>(); + // insert new attachment + transferRequest.getTempFileIds().forEach(tempFileId -> { + FileRequest request = new FileRequest(DefaultRepositoryDir.getTmpDir() + "/" + tempFileId, StorageType.LOCAL.name(), null); + List folderTempFiles = fileCommonService.getFolderFiles(request); + if (!CollectionUtils.isEmpty(folderTempFiles)) { + File tempFile = folderTempFiles.getFirst(); + FileCopyRequest copyRequest = new FileCopyRequest(DefaultRepositoryDir.getTempFileDir(tempFileId), + DefaultRepositoryDir.getTransferFileDir(transferRequest.getOrganizationId(), transferRequest.getResourceId(), tempFileId), + tempFile.getName()); + fileCommonService.copyFile(copyRequest, StorageType.LOCAL.name()); + Attachment attachment = new Attachment(); + attachment.setId(tempFileId); + attachment.setName(tempFile.getName()); + String type = ""; + int index = tempFile.getName().lastIndexOf('.'); + if (index != -1 && index < tempFile.getName().length() - 1) { + type = tempFile.getName().substring(index + 1); + } + attachment.setType(type); + attachment.setSize(tempFile.length()); + attachment.setStorage(StorageType.LOCAL.name()); + attachment.setOrganizationId(transferRequest.getOrganizationId()); + attachment.setResourceId(transferRequest.getResourceId()); + attachment.setCreateTime(System.currentTimeMillis()); + attachment.setCreateUser(transferRequest.getOperatorUserId()); + attachment.setUpdateTime(System.currentTimeMillis()); + attachment.setUpdateUser(transferRequest.getOperatorUserId()); + attachments.add(attachment); + } + }); + attachmentMapper.batchInsert(attachments); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/BaseModuleLogService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/BaseModuleLogService.java new file mode 100644 index 0000000..658c73b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/BaseModuleLogService.java @@ -0,0 +1,543 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.common.resolver.field.ModuleFieldResolverFactory; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.clue.service.ClueService; +import cn.cordys.crm.contract.service.BusinessTitleService; +import cn.cordys.crm.contract.service.ContractService; +import cn.cordys.crm.customer.service.CustomerContactService; +import cn.cordys.crm.opportunity.service.OpportunityService; +import cn.cordys.crm.product.domain.Product; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.dto.field.DateTimeField; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.SubField; +import cn.cordys.crm.system.dto.form.FormProp; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +public abstract class BaseModuleLogService { + @Resource + private BaseMapper productMapper; + + protected String oldValue; + + protected String newValue; + + public void setOldValue(String oldValue) { + this.oldValue = oldValue; + } + + public void setNewValue(String newValue) { + this.newValue = newValue; + } + + /** + * 翻译字段名称 + * 赋值旧值名称和新值名称 + * + * @param differ + */ + public static void translatorDifferInfo(JsonDifferenceDTO differ) { + //主表字段 + differ.setColumnName(Translator.get("log." + differ.getColumn(), differ.getColumn())); + differ.setOldValueName(differ.getOldValue()); + differ.setNewValueName(differ.getNewValue()); + } + + /** + * TODO: 处理字段变更日志详情 + * @param differ 差异 + */ + public void handleFieldsLogDetail(JsonDifferenceDTO differ) { + List oldFieldNames = parseFieldList(differ.getOldValue()).stream() + .map(f -> String.valueOf(((Map) f).get("name"))) + .toList(); + List newFieldNames = parseFieldList(differ.getNewValue()).stream() + .map(f -> String.valueOf(((Map) f).get("name"))) + .toList(); + + // 如果字段没有实际变化则不产生日志 + if (Objects.equals(oldFieldNames, newFieldNames)) { + differ.setOldValueName(null); + differ.setNewValueName(null); + return; + } + + differ.setOldValueName(oldFieldNames); + differ.setNewValueName(newFieldNames); + } + + private List parseFieldList(Object value) { + if (value instanceof List list) { + return list; + } + return Collections.emptyList(); + } + + /** + * 表单配置的日志详情对比 + * @param differ 差异 + */ + public void handleFormPropLogDetail(JsonDifferenceDTO differ) { + if (differ.getOldValue() != null && differ.getNewValue() != null) { + FormProp oldFormProp = JSON.parseObject(JSON.toJSONString(differ.getOldValue()), FormProp.class); + differ.setOldValueName(buildFormPropDisplayText(oldFormProp)); + + FormProp newFormProp = JSON.parseObject(JSON.toJSONString(differ.getNewValue()), FormProp.class); + differ.setNewValueName(buildFormPropDisplayText(newFormProp)); + } + } + + /** + * 构建表单属性展示文本 + * 格式: viewSize, layout, labelPos, inputWidth, optBtnPos + * + * @param formProp 表单属性 + * @return 展示文本 + */ + private String buildFormPropDisplayText(FormProp formProp) { + String viewSize = Translator.get(formProp.getViewSize()); + String layout = Translator.get("formProp.layout." + formProp.getLayout()); + String labelPos = Translator.get("formProp.labelPos." + formProp.getLabelPos()); + String inputWidth = Translator.get("formProp.inputWidth." + formProp.getInputWidth()); + String optBtnPos = Translator.get("formProp.optBtnPos." + formProp.getOptBtnPos()); + return String.join(", ", viewSize, layout, labelPos, inputWidth, optBtnPos); + } + + abstract public List handleLogField(List differences, String orgId); + + /** + * 处理非业务字段的自定义字段 + * 同时会翻译其他字段的 ColumnName + * + * @param differences + * @param orgId + * @param formKey + */ + protected List handleModuleLogField(List differences, String orgId, String formKey) { + ModuleFormConfigDTO formConfig = Objects.requireNonNull(CommonBeanFactory.getBean(ModuleFormCacheService.class)) + .getBusinessFormConfig(formKey, orgId); + + List fields = formConfig.getFields(); + + // 模块字段 map + Map moduleFieldMap = fields + .stream() + .collect(Collectors.toMap(BaseField::getId, Function.identity())); + + Map subFieldMap = new HashMap<>(); + Set subFieldRemoveIds = new HashSet<>() { + }; + for (BaseField field : fields) { + if (field.isSubField()) { + if (StringUtils.isNotBlank(field.getBusinessKey())) { + subFieldMap.put(field.getBusinessKey(), field); + } else { + subFieldMap.put(field.getId(), field); + } + //记录系统字段需要移除的子表字段 + subFieldRemoveIds.add(field.getId()); + } + } + + Map businessModuleFieldMap = fields + .stream() + .filter(f -> StringUtils.isNotEmpty(f.getBusinessKey()) && StringUtils.isEmpty(f.getResourceFieldId())) + .collect(Collectors.toMap(BaseField::getBusinessKey, Function.identity())); + + + List modifiable = new ArrayList<>(differences); + modifiable.removeIf(differ -> { + return subFieldRemoveIds.contains(differ.getColumn()); + }); + differences = modifiable; + // 记录选项字段的字段值 + List optionFieldValues = new ArrayList<>(); + // 记录子表选项字段的字段值 + List optionSubFieldValues = new ArrayList<>(); + List returnDifferences = new ArrayList<>(); + differences.forEach(differ -> { + boolean addDiffer = differ.getOldValue() != null && (differ.getOldValue() instanceof String ? !StringUtils.isBlank((String) differ.getOldValue()) && !Strings.CI.equals((String) differ.getOldValue(), "null") : !((List) differ.getOldValue()).isEmpty()); + if (differ.getNewValue() != null && (differ.getNewValue() instanceof String ? !StringUtils.isBlank((String) differ.getNewValue()) && !Strings.CI.equals((String) differ.getNewValue(), "null") : !((List) differ.getNewValue()).isEmpty())) { + addDiffer = true; + } + if (addDiffer) { + returnDifferences.add(differ); + } + BaseField moduleField = moduleFieldMap.get(differ.getColumn()); + if (moduleField != null && moduleField.hasSingleOptions()) { + if (differ.getOldValue() != null && (differ.getOldValue() instanceof String ? !StringUtils.isBlank((String) differ.getOldValue()) && !Strings.CI.equals((String) differ.getOldValue(), "null") : !((List) differ.getOldValue()).isEmpty())) { + BaseModuleFieldValue fieldValue = new BaseModuleFieldValue(); + fieldValue.setFieldId(differ.getColumn()); + fieldValue.setFieldValue(differ.getOldValue()); + optionFieldValues.add(fieldValue); + } + if (differ.getNewValue() != null && (differ.getNewValue() instanceof String ? !StringUtils.isBlank((String) differ.getNewValue()) && !Strings.CI.equals((String) differ.getNewValue(), "null") : !((List) differ.getNewValue()).isEmpty())) { + BaseModuleFieldValue fieldValue = new BaseModuleFieldValue(); + fieldValue.setFieldId(differ.getColumn()); + fieldValue.setFieldValue(differ.getNewValue()); + optionFieldValues.add(fieldValue); + + } + + } + subFieldMap.forEach((key, value) -> { + // 子表字段 + for (BaseField subField : ((SubField) value).getSubFields()) { + String subBusinessKey = subField.getBusinessKey(); + String subFieldIdKey = subField.getId(); + //如果differ.getColumn() 包含"-",则截取最后一个-后面的字符串 + String differColumn = differ.getColumn(); + if (StringUtils.isNotBlank(differ.getColumn())) { + differColumn = differ.getColumn().substring(differ.getColumn().lastIndexOf("-") + 1); + } + if (Strings.CS.equals(subBusinessKey, differColumn) || Strings.CS.equals(subFieldIdKey, differColumn)) { + //判断differ.getOldValue() 是否为空,如果是String 类型 不能为空字符串,不能为"null",如果是List 类型 不能为空 + if (differ.getOldValue() != null && (differ.getOldValue() instanceof String ? !StringUtils.isBlank((String) differ.getOldValue()) && !Strings.CI.equals((String) differ.getOldValue(), "null") : !((List) differ.getOldValue()).isEmpty())) { + BaseModuleFieldValue fieldValue = new BaseModuleFieldValue(); + fieldValue.setFieldId(differColumn); + fieldValue.setFieldValue(differ.getOldValue()); + optionSubFieldValues.add(fieldValue); + } + if (differ.getNewValue() != null && (differ.getNewValue() instanceof String ? !StringUtils.isBlank((String) differ.getNewValue()) && !Strings.CI.equals((String) differ.getNewValue(), "null") : !((List) differ.getNewValue()).isEmpty())) { + BaseModuleFieldValue fieldValue = new BaseModuleFieldValue(); + fieldValue.setFieldId(differColumn); + fieldValue.setFieldValue(differ.getNewValue()); + optionSubFieldValues.add(fieldValue); + } + } + } + }); + }); + + Map> optionMap = Objects.requireNonNull(CommonBeanFactory.getBean(ModuleFormService.class)) + .getOptionMap(formConfig, optionFieldValues); + // 子表选项字段的选项值 map + Map> subOptionMap = Objects.requireNonNull(CommonBeanFactory.getBean(ModuleFormService.class)) + .getOptionMap(formConfig, optionSubFieldValues); + + Set subFieldIds = optionSubFieldValues.stream().map(BaseModuleFieldValue::getFieldId).collect(Collectors.toSet()); + returnDifferences.forEach(differ -> { + String originDifferColumn = differ.getColumn(); + String differColumn = differ.getColumn(); + String prefix = differ.getColumn(); + // 子表字段处理 + if (StringUtils.isNotBlank(differ.getColumn()) && differ.getColumn().contains("-")) { + differColumn = originDifferColumn.substring(differ.getColumn().lastIndexOf("-") + 1); + prefix = originDifferColumn.substring(0, originDifferColumn.lastIndexOf("-")); + } + BaseField moduleField = moduleFieldMap.get(differ.getColumn()); + if (moduleField != null) { + differ.setColumnName(moduleField.getName()); + // 设置字段值名称 + setColumnValueName(optionMap.get(differ.getColumn()), differ, moduleField); + } else { + if (optionMap.containsKey(differ.getColumn())) { + differ.setColumnName(Translator.get("log." + differ.getColumn(), differ.getColumn())); + // 设置字段值名称 + setColumnValueName(optionMap.get(differColumn), differ, moduleField); + } else if (subFieldIds.contains(differColumn)) { + // 子表字段处理 + if (subOptionMap.containsKey(differColumn)) { + differ.setColumn(prefix); + String finalDifferColumn = differColumn; + subFieldMap.forEach((key, value) -> { + if (value.isSubField()) { + if (value instanceof SubField) { + // 子表字段 + for (BaseField subField : ((SubField) value).getSubFields()) { + String subKey = StringUtils.isNotBlank(subField.getBusinessKey()) ? subField.getBusinessKey() : subField.getId(); + if (Strings.CS.equals(subKey, finalDifferColumn)) { + // 设置字段值名称 + setColumnValueName(subOptionMap.get(finalDifferColumn), differ, subField); + } + } + } + } + }); + } else { + //如果prefix = differ.column,则说明没有-符号,设置为Translator.get("log."+differ.getColumn()),否则设置为prefix + if (Strings.CS.equals(prefix, differ.getColumn())) { + differ.setColumnName(Translator.get("log." + differ.getColumn(), differ.getColumn())); + } else { + differ.setColumn(prefix); + } + differ.setOldValueName(differ.getOldValue()); + differ.setNewValueName(differ.getNewValue()); + } + } else { + translatorDifferInfo(differ); + } + } + + // 翻译业务字段名 + if (businessModuleFieldMap.get(differ.getColumn()) != null) { + BaseField baseField = businessModuleFieldMap.get(differ.getColumn()); + differ.setColumnName(baseField.getName()); + if (Strings.CI.equals(baseField.getType(), FieldType.DATE_TIME.name())) { + // 日期时间格式化 + DateTimeField dateTimeField = (DateTimeField) baseField; + switch (dateTimeField.getDateType()) { + case "date" -> setFormatDataTimeFieldValueName(differ, new SimpleDateFormat("yyyy-M-d")); + case "datetime" -> setFormatDataTimeFieldValueName(differ, new SimpleDateFormat("yyyy-M-d HH:mm:ss")); + case "month" -> setFormatDataTimeFieldValueName(differ, new SimpleDateFormat("yyyy-M")); + } + } + + + } + }); + + return returnDifferences; + } + + private void setColumnValueName(List options, JsonDifferenceDTO differ, BaseField moduleField) { + if (options == null) { + //解析各种数据 + parseValue(moduleField, differ); + return; + } + List oldNameList = new ArrayList<>(); + List newNameList = new ArrayList<>(); + for (OptionDTO option : options) { + if (differ.getOldValue() instanceof String strValue && Strings.CS.equals(option.getId(), strValue)) { + // 设置旧值名称 + differ.setOldValueName(option.getName()); + } + if (differ.getNewValue() instanceof String strValue && Strings.CS.equals(option.getId(), strValue)) { + // 设置新值名称 + differ.setNewValueName(option.getName()); + } + if (differ.getOldValue() instanceof List oldValueList) { + for (Object oldValue : oldValueList) { + if (oldValue instanceof String strValue && Strings.CS.equals(option.getId(), strValue)) { + // 设置旧值名称 + oldNameList.add(option.getName()); + } + } + + } + if (differ.getNewValue() instanceof List newValueList) { + + for (Object newValue : newValueList) { + if (newValue instanceof String strValue && Strings.CS.equals(option.getId(), strValue)) { + // 设置新值名称 + newNameList.add(option.getName()); + } + } + } + + } + if (!oldNameList.isEmpty()) { + differ.setOldValueName(String.join(",", oldNameList)); + } + if (!newNameList.isEmpty()) { + differ.setNewValueName(String.join(",", newNameList)); + } + + } + + private void parseValue(BaseField moduleField, JsonDifferenceDTO differ) { + if (moduleField != null) { + if (differ.getOldValue() != null) { + Object ov = transformFieldValue(moduleField, differ.getOldValue()); + if (ov == null || StringUtils.isBlank(ov.toString())) { + if (differ.getOldValue() instanceof List) { + differ.setOldValueName(String.join(",", (List) differ.getOldValue())); + } else { + differ.setOldValueName(differ.getOldValue()); + } + } else { + differ.setOldValueName(ov); + } + } + if (differ.getNewValue() != null) { + Object nv = transformFieldValue(moduleField, differ.getNewValue()); + if (nv == null || StringUtils.isBlank(nv.toString())) { + if (differ.getNewValue() instanceof List) { + differ.setNewValueName(String.join(",", (List) differ.getNewValue())); + } else { + differ.setNewValueName(differ.getNewValue()); + } + } else { + differ.setNewValueName(nv); + } + } + } else { + differ.setOldValueName(differ.getOldValue()); + differ.setNewValueName(differ.getNewValue()); + } + } + + public void setFormatDataTimeFieldValueName(JsonDifferenceDTO differ, SimpleDateFormat simpleDateFormat) { + if (differ.getOldValue() != null) { + differ.setOldValueName(formatDataTime(differ.getOldValue().toString(), simpleDateFormat)); + } + if (differ.getNewValue() != null) { + differ.setNewValueName(formatDataTime(differ.getNewValue().toString(), simpleDateFormat)); + } + } + + public Object transformFieldValue(BaseField field, Object value) { + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(field.getType()); + // 将数据库中的字符串值,转换为对应的对象值 + return customFieldResolver.transformToValue(field, value instanceof List ? JSON.toJSONString(value) : value.toString()); + } + + private String formatDataTime(String value, SimpleDateFormat simpleDateFormat) { + if (StringUtils.isBlank(value) || Strings.CI.equals(value, "null")) { + return StringUtils.EMPTY; + } + return simpleDateFormat.format(Long.parseLong(value)); + } + + protected void setUserFieldName(JsonDifferenceDTO differ) { + BaseService baseService = CommonBeanFactory.getBean(BaseService.class); + assert baseService != null; + if (differ.getOldValue() != null) { + String userName = baseService.getUserName(differ.getOldValue().toString()); + differ.setOldValueName(userName); + } + if (differ.getNewValue() != null) { + String userName = baseService.getUserName(differ.getNewValue().toString()); + differ.setNewValueName(userName); + } + } + + + /** + * 商机名称 + * + * @param differ + */ + protected void setOpportunityName(JsonDifferenceDTO differ) { + OpportunityService opportunityService = CommonBeanFactory.getBean(OpportunityService.class); + assert opportunityService != null; + if (differ.getOldValue() != null) { + String customerName = opportunityService.getOpportunityName(differ.getOldValue().toString()); + differ.setOldValueName(customerName); + } + if (differ.getNewValue() != null) { + String userName = opportunityService.getOpportunityName(differ.getNewValue().toString()); + differ.setNewValueName(userName); + } + } + + + /** + * 线索名称 + * + * @param differ + */ + protected void setClueName(JsonDifferenceDTO differ) { + ClueService clueService = CommonBeanFactory.getBean(ClueService.class); + assert clueService != null; + if (differ.getOldValue() != null) { + String customerName = clueService.getClueName(differ.getOldValue().toString()); + differ.setOldValueName(customerName); + } + if (differ.getNewValue() != null) { + String userName = clueService.getClueName(differ.getNewValue().toString()); + differ.setNewValueName(userName); + } + } + + + /** + * 联系人 + * + * @param differ + */ + protected void setContactFieldName(JsonDifferenceDTO differ) { + CustomerContactService customerContactService = CommonBeanFactory.getBean(CustomerContactService.class); + assert customerContactService != null; + if (differ.getOldValue() != null) { + String customerName = customerContactService.getContactName(differ.getOldValue().toString()); + differ.setOldValueName(customerName); + } + if (differ.getNewValue() != null) { + String userName = customerContactService.getContactName(differ.getNewValue().toString()); + differ.setNewValueName(userName); + } + } + + /** + * 合同名称 + * + * @param differ + */ + protected void setContractFieldName(JsonDifferenceDTO differ) { + ContractService contractService = CommonBeanFactory.getBean(ContractService.class); + assert contractService != null; + if (differ.getOldValue() != null) { + String customerName = contractService.getContractName(differ.getOldValue().toString()); + differ.setOldValueName(customerName); + } + if (differ.getNewValue() != null) { + String userName = contractService.getContractName(differ.getNewValue().toString()); + differ.setNewValueName(userName); + } + } + + protected void setApprovalName(JsonDifferenceDTO differ) { + differ.setColumnName(Translator.get("log.approvalStatus")); + if (differ.getOldValue() != null) { + differ.setOldValueName(Translator.get("log.approvalStatus." + differ.getOldValueName().toString())); + } + if (differ.getNewValue() != null) { + differ.setNewValueName(Translator.get("log.approvalStatus." + differ.getNewValueName().toString())); + } + } + + protected void setBusinessTitleName(JsonDifferenceDTO differ) { + BusinessTitleService businessTitleService = CommonBeanFactory.getBean(BusinessTitleService.class); + assert businessTitleService != null; + if (differ.getOldValue() != null) { + String customerName = businessTitleService.getBusinessTitleName(differ.getOldValue().toString()); + differ.setOldValueName(customerName); + } + if (differ.getNewValue() != null) { + String userName = businessTitleService.getBusinessTitleName(differ.getNewValue().toString()); + differ.setNewValueName(userName); + } + } + + /** + * 产品 + * + * @param differ + */ + protected void setProductName(JsonDifferenceDTO differ) { + Optional.ofNullable(differ.getOldValue()).ifPresent(oldValue -> { + List ids = ((Collection) oldValue).stream() + .map(String::valueOf) + .toList(); + List products = productMapper.selectByIds(ids); + differ.setOldValueName(products.stream().map(Product::getName).toList()); + }); + + Optional.ofNullable(differ.getNewValue()).ifPresent(newValue -> { + List ids = ((Collection) newValue).stream() + .map(String::valueOf) + .toList(); + List products = productMapper.selectByIds(ids); + differ.setNewValueName(products.stream().map(Product::getName).toList()); + }); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/DepartmentService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/DepartmentService.java new file mode 100644 index 0000000..94b2713 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/DepartmentService.java @@ -0,0 +1,582 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.ThirdConfigTypeConstants; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.DeptUserTreeNode; +import cn.cordys.common.dto.NodeSortDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.NodeSortUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.domain.Department; +import cn.cordys.crm.system.domain.DepartmentCommander; +import cn.cordys.crm.system.domain.OrganizationUser; +import cn.cordys.crm.system.dto.log.DepartmentSetCommanderLog; +import cn.cordys.crm.system.dto.request.DepartmentAddRequest; +import cn.cordys.crm.system.dto.request.DepartmentCommanderRequest; +import cn.cordys.crm.system.dto.request.DepartmentRenameRequest; +import cn.cordys.crm.system.dto.request.NodeMoveRequest; +import cn.cordys.crm.system.mapper.ExtDepartmentMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationUserMapper; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.ibatis.session.ExecutorType; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.mybatis.spring.SqlSessionUtils; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + + +@Service("departmentService") +@Transactional(rollbackFor = Exception.class) +public class DepartmentService extends MoveNodeService { + + @Resource + private BaseMapper departmentMapper; + @Resource + private ExtDepartmentMapper extDepartmentMapper; + @Resource + private BaseMapper departmentCommanderMapper; + @Resource + private ExtOrganizationUserMapper extOrganizationUserMapper; + @Resource + private SqlSessionFactory sqlSessionFactory; + @Resource + private LogService logService; + + /** + * 获取部门树 + * + * @return List + */ + @Cacheable(value = "dept_tree_cache", key = "#orgId") + public List getTree(String orgId) { + List departmentList = extDepartmentMapper.selectTreeNode(orgId); + return BaseTreeNode.buildTree(departmentList); + } + + /** + * 添加子部门 + * + * @param request + * @param orgId + * @param userId + */ + @OperationLog(module = LogModule.SYSTEM_ORGANIZATION, type = LogType.ADD) + @CacheEvict(value = "dept_tree_cache", key = "#orgId", beforeInvocation = true) + public Department addDepartment(DepartmentAddRequest request, String orgId, String userId) { + //同一层级部门名称唯一 + checkDepartmentName(request.getName(), request.getParentId(), orgId); + String id = IDGenerator.nextStr(); + Department department = new Department(); + department.setId(id); + department.setName(request.getName()); + department.setParentId(request.getParentId()); + department.setOrganizationId(orgId); + department.setPos(getNextPos(orgId)); + department.setCreateTime(System.currentTimeMillis()); + department.setUpdateTime(System.currentTimeMillis()); + department.setCreateUser(userId); + department.setUpdateUser(userId); + department.setResource(ThirdConfigTypeConstants.INTERNAL.name()); + departmentMapper.insert(department); + + // 添加日志上下文 + OperationLogContext.setContext(LogContextInfo.builder() + .modifiedValue(department) + .resourceId(id) + .resourceName(department.getName()) + .build()); + return department; + } + + private void checkDepartmentName(String name, String parentId, String orgId) { + if (extDepartmentMapper.countByName(name, parentId, orgId) > 0) { + throw new GenericException(Translator.get("department_name_exist")); + } + } + + public Long getNextPos(String orgId) { + Long pos = extDepartmentMapper.getNextPosByOrgId(orgId); + return (pos == null ? 0 : pos) + NodeSortUtils.DEFAULT_NODE_INTERVAL_POS; + } + + + /** + * 部门重命名 + * + * @param request + * @param userId + */ + @OperationLog(module = LogModule.SYSTEM_ORGANIZATION, type = LogType.UPDATE) + @CacheEvict(value = "dept_tree_cache", key = "#orgId", beforeInvocation = true) + public void rename(DepartmentRenameRequest request, String userId, String orgId) { + Department originalDepartment = checkDepartment(request.getId()); + checkDepartmentName(request.getName(), originalDepartment.getParentId(), originalDepartment.getOrganizationId()); + + Department department = BeanUtils.copyBean(new Department(), request); + department.setUpdateTime(System.currentTimeMillis()); + department.setName(request.getName()); + department.setUpdateUser(userId); + departmentMapper.updateById(department); + + // 添加日志上下文 + String resourceName = Optional.ofNullable(department.getName()).orElse(originalDepartment.getName()); + OperationLogContext.setContext( + LogContextInfo.builder() + .originalValue(originalDepartment) + .modifiedValue(checkDepartment(request.getId())) + .resourceId(request.getId()) + .resourceName(resourceName) + .build() + ); + } + + private Department checkDepartment(String id) { + Department department = departmentMapper.selectByPrimaryKey(id); + if (department == null) { + throw new GenericException(Translator.get("department.blank")); + } + return department; + } + + /** + * 设置部门负责人 + * + * @param request + * @param userId + */ + @OperationLog(module = LogModule.SYSTEM_ORGANIZATION, type = LogType.UPDATE) + public void setCommander(DepartmentCommanderRequest request, String userId) { + Department department = departmentMapper.selectByPrimaryKey(request.getDepartmentId()); + if (department == null) { + throw new GenericException(Translator.get("department.blank")); + } + // 获取原部门负责人 + String originCommander = setCommanderByDeptId(request.getDepartmentId()); + + // 先删除 + deleteCommanderByDeptId(request.getDepartmentId()); + + // 再插入 + DepartmentCommander commander = new DepartmentCommander(); + commander.setId(IDGenerator.nextStr()); + commander.setUserId(request.getCommanderId()); + commander.setDepartmentId(request.getDepartmentId()); + commander.setCreateTime(System.currentTimeMillis()); + commander.setUpdateTime(System.currentTimeMillis()); + commander.setCreateUser(userId); + commander.setUpdateUser(userId); + departmentCommanderMapper.insert(commander); + + // 设置日志上下文 + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(request.getDepartmentId()) + .resourceName(department.getName()) + .originalValue( + DepartmentSetCommanderLog.builder() + .commander(originCommander) + .build() + ) + .modifiedValue( + DepartmentSetCommanderLog.builder() + .commander(request.getCommanderId()) + .build() + ) + .build() + ); + } + + private String setCommanderByDeptId(String departmentId) { + DepartmentCommander example = new DepartmentCommander(); + example.setDepartmentId(departmentId); + List commanders = departmentCommanderMapper.select(example); + if (CollectionUtils.isNotEmpty(commanders)) { + return commanders.getFirst().getUserId(); + } + return null; + } + + private void deleteCommanderByDeptId(String departmentId) { + DepartmentCommander example = new DepartmentCommander(); + example.setDepartmentId(departmentId); + departmentCommanderMapper.delete(example); + } + + /** + * 刪除部门 + * + * @param ids + */ + @CacheEvict(value = "dept_tree_cache", key = "#orgId", beforeInvocation = true) + public void delete(List ids, String operator, String orgId) { + if (!deleteCheck(ids, orgId)) { + throw new GenericException(Translator.get("department.employees.exist")); + } + List departmentList = departmentMapper.selectByIds(ids); + //刪除部門 + departmentMapper.deleteByIds(ids); + List logs = new ArrayList<>(); + // 添加日志上下文 + departmentList.forEach(department -> { + LogDTO logDTO = new LogDTO(department.getOrganizationId(), department.getId(), operator, LogType.DELETE, LogModule.SYSTEM_ORGANIZATION, department.getName()); + logDTO.setOriginalValue(department); + logs.add(logDTO); + }); + logService.batchAdd(logs); + } + + + /** + * 删除部门前校验 + * + * @param ids + * @param orgId + * + * @return + */ + public boolean deleteCheck(List ids, String orgId) { + List departmentList = departmentMapper.selectByIds(ids); + if (CollectionUtils.isNotEmpty(departmentList)) { + departmentList.forEach(department -> { + if (Strings.CI.equalsAny(department.getResource(), ThirdConfigTypeConstants.INTERNAL.name()) + && Strings.CI.equalsAny(department.getParentId(), "NONE")) { + throw new GenericException(Translator.get("department.internal")); + } + }); + + } + + return extOrganizationUserMapper.countUserByDepartmentIds(ids, orgId) <= 0; + } + + + /** + * 保存部门信息 + * + * @param departments + */ + public void save(List departments) { + departmentMapper.batchInsert(departments); + } + + /** + * 删除部门数据 + * + * @param orgId + */ + public void deleteByOrgId(String orgId) { + extDepartmentMapper.deleteByOrgId(orgId); + } + + + /** + * 创建部门 + * + * @param departmentPath + * @param orgId + * @param departmentTree + * @param operatorId + * @param departmentMap + * + * @return + */ + public Map createDepartment(List departmentPath, String orgId, List departmentTree, String operatorId, Map departmentMap, List logs) { + departmentPath.forEach(path -> { + path = "/" + path; + List depNames = new ArrayList<>(List.of(path.split("/"))); + Iterator itemIterator = depNames.iterator(); + AtomicReference hasNode = new AtomicReference<>(false); + //当前节点部门名称 + String currentDepName; + if (depNames.size() <= 1) { + throw new GenericException(Translator.get("department_create_fail") + ":" + path); + } else { + itemIterator.next(); + itemIterator.remove(); + currentDepName = itemIterator.next().trim(); + departmentTree.forEach(department -> { + //根节点是否存在 + if (Strings.CI.equals(currentDepName, department.getName())) { + hasNode.set(true); + //根节点存在,检查子节点是否存在 + createDepByPathIterator(itemIterator, "/" + currentDepName, department, departmentMap, orgId, operatorId, logs); + } + }); + } + if (!hasNode.get()) { + //获取顶级部门id + BaseTreeNode top = extDepartmentMapper.selectDepartment(currentDepName, orgId); + createDepByPath(itemIterator, currentDepName, top, orgId, StringUtils.EMPTY, departmentMap, operatorId, logs); + } + }); + return departmentMap; + } + + private void createDepByPathIterator(Iterator itemIterator, String currentDepPath, BaseTreeNode departmentTreeNode, Map departmentMap, String orgId, String operatorId, List logs) { + List children = departmentTreeNode.getChildren(); + if (CollectionUtils.isEmpty(children) || !itemIterator.hasNext()) { + //没有子节点,根据当前部门创建部门节点 + departmentMap.put(currentDepPath, departmentTreeNode.getId()); + if (itemIterator.hasNext()) { + createDepByPath(itemIterator, itemIterator.next().trim(), departmentTreeNode, orgId, currentDepPath, departmentMap, operatorId, logs); + } + return; + } + String nodeName = itemIterator.next().trim(); + AtomicReference hasNode = new AtomicReference<>(false); + children.forEach(child -> { + if (Strings.CI.equals(nodeName, child.getName())) { + hasNode.set(true); + createDepByPathIterator(itemIterator, currentDepPath + "/" + child.getName(), child, departmentMap, orgId, operatorId, logs); + } + }); + + //若子节点中不包含该目标节点,则在该节点下创建 + if (!hasNode.get()) { + createDepByPath(itemIterator, nodeName, departmentTreeNode, orgId, currentDepPath, departmentMap, operatorId, logs); + } + } + + private void createDepByPath(Iterator itemIterator, String departmentName, BaseTreeNode parentDep, String orgId, String currentDepPath, Map departmentMap, String operatorId, List logs) { + StringBuilder path = new StringBuilder(currentDepPath); + path.append("/").append(departmentName.trim()); + + //模块id + String pid; + if (departmentMap.get(path.toString()) != null) { + //如果创建过,直接获取模块ID + pid = departmentMap.get(path.toString()); + } else { + pid = insertNode(departmentName, parentDep.getId(), orgId, operatorId, logs); + departmentMap.put(path.toString(), pid); + } + + while (itemIterator.hasNext()) { + String nextDepName = itemIterator.next().trim(); + path.append("/").append(nextDepName); + if (departmentMap.get(path.toString()) != null) { + pid = departmentMap.get(path.toString()); + } else { + pid = insertNode(nextDepName, pid, orgId, operatorId, logs); + departmentMap.put(path.toString(), pid); + } + } + } + + private String insertNode(String departmentName, String parentId, String orgId, String operatorId, List logs) { + String id = IDGenerator.nextStr(); + Department department = new Department(); + department.setId(id); + department.setName(departmentName); + department.setParentId(parentId); + department.setOrganizationId(orgId); + department.setPos(getNextPos(orgId)); + department.setCreateTime(System.currentTimeMillis()); + department.setUpdateTime(System.currentTimeMillis()); + department.setCreateUser(operatorId); + department.setUpdateUser(operatorId); + department.setResource(ThirdConfigTypeConstants.INTERNAL.name()); + departmentMapper.insert(department); + + LogDTO logDTO = new LogDTO(orgId, department.getId(), operatorId, LogType.ADD, LogModule.SYSTEM_ORGANIZATION, departmentName); + logDTO.setModifiedValue(department); + logs.add(logDTO); + + return id; + } + + + /** + * 部门树排序 + * + * @param request + * @param operatorId + * @param orgId + */ + @CacheEvict(value = "dept_tree_cache", key = "#orgId", beforeInvocation = true) + public void sort(NodeMoveRequest request, String operatorId, String orgId) { + NodeSortDTO nodeSortDTO = super.getNodeSortDTO(request, + extDepartmentMapper::selectBaseTreeById, + extDepartmentMapper::selectTreeByParentIdAndPosOperator, + true); + Department department = new Department(); + department.setParentId(nodeSortDTO.getParent().getId()); + department.setId(request.getDragNodeId()); + if (departmentMapper.countByExample(department) == 0) { + Department moveDepartment = departmentMapper.selectByPrimaryKey(request.getDragNodeId()); + moveDepartment.setParentId(nodeSortDTO.getParent().getId()); + checkDepartmentName(moveDepartment.getName(), moveDepartment.getParentId(), orgId); + + moveDepartment.setUpdateUser(operatorId); + moveDepartment.setUpdateTime(System.currentTimeMillis()); + departmentMapper.update(moveDepartment); + } + super.sort(nodeSortDTO); + } + + @Override + public void updatePos(String id, long pos) { + Department department = new Department(); + department.setPos(pos); + department.setId(id); + departmentMapper.update(department); + } + + @Override + public void refreshPos(String parentId) { + List childrenIds = extDepartmentMapper.selectChildrenIds(parentId); + List departmentList = new ArrayList<>(); + for (int i = 0; i < childrenIds.size(); i++) { + String nodeId = childrenIds.get(i); + Department updateDepartment = new Department(); + updateDepartment.setId(nodeId); + updateDepartment.setPos((i + 1) * LIMIT_POS); + departmentList.add(updateDepartment); + } + extDepartmentMapper.batchUpdate(departmentList); + } + + /** + * 获取内部部门 (XPack 中使用) + * + * @param orgId + * @param resource + * + * @return + */ + public Department getInternalDepartment(String orgId, String resource) { + return extDepartmentMapper.getInternalDepartment(orgId, resource); + } + + /** + * 更新部门信息 + * + * @param departments + */ + public void update(List departments) { + SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); + ExtDepartmentMapper mapper = sqlSession.getMapper(ExtDepartmentMapper.class); + for (Department department : departments) { + mapper.updateDepartment(department); + } + sqlSession.flushStatements(); + SqlSessionUtils.closeSqlSession(sqlSession, sqlSessionFactory); + } + + + /** + * 获取全部部门 + * + * @param orgId + * + * @return + */ + public List getDepartmentByOrgId(String orgId) { + return extDepartmentMapper.getDepartmentByOrgId(orgId); + } + + + /** + * 获取责任人 + * + * @param userList + * + * @return + */ + public List getDepartmentCommander(List userList) { + if (CollectionUtils.isEmpty(userList)) { + return new ArrayList<>(); + } + List userIds = userList.stream() + .map(OrganizationUser::getUserId) + .toList(); + return extDepartmentMapper.getDepartmentCommander(userIds); + } + + public void deleteDepartments(List departmentList) { + if (CollectionUtils.isNotEmpty(departmentList)) { + List ids = departmentList.stream() + .filter(department -> !Strings.CI.equalsAny(department.getParentId(), "NONE")) + .map(Department::getId).toList(); + if (CollectionUtils.isNotEmpty(ids)) { + extDepartmentMapper.deleteDepartmentByIds(ids); + } + } + } + + /** + * 获取部门选项 + * + * @param ids 部门ID集合 + * + * @return 部门选项 + */ + public List getDepartmentOptionsByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return new ArrayList<>(); + } + return departmentMapper.selectByIds(ids.toArray(new String[0])); + } + + public List getDepartmentOptionsById(String id) { + if (StringUtils.isBlank(id)) { + return List.of(); + } + return getDepartmentOptionsByIds(List.of(id)); + } + + public String getDepartmentName(String id) { + Department department = departmentMapper.selectByPrimaryKey(id); + return Optional.ofNullable(department).map(Department::getName).orElse(null); + } + + public List selectByOrgId(String orgId) { + return extDepartmentMapper.selectByOrgId(orgId); + } + + /** + * 按照部门负责人优先排序 + * 并且设置上负责人标识 + * + * @param orgId + * @param userNodes + * + * @return + */ + public List sortByCommander(String orgId, List userNodes) { + Set commanders = selectByOrgId(orgId) + .stream() + .map(DepartmentCommander::getUserId) + .collect(Collectors.toSet()); + + userNodes = userNodes.stream() + .sorted(Comparator.comparing(userNode -> { + if (commanders.contains(userNode.getId())) { + userNode.setCommander(true); + return 0; + } + return 1; + })) + .collect(Collectors.toList()); + return userNodes; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/DictService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/DictService.java new file mode 100644 index 0000000..5b040e7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/DictService.java @@ -0,0 +1,230 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.constants.DictModule; +import cn.cordys.crm.system.domain.Dict; +import cn.cordys.crm.system.domain.DictConfig; +import cn.cordys.crm.system.dto.DictConfigDTO; +import cn.cordys.crm.system.dto.request.DictAddRequest; +import cn.cordys.crm.system.dto.request.DictSortRequest; +import cn.cordys.crm.system.dto.request.DictSwitchRequest; +import cn.cordys.crm.system.dto.request.DictUpdateRequest; +import cn.cordys.crm.system.mapper.ExtDictMapper; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Service +@Transactional(rollbackFor = Exception.class) +public class DictService { + + @Resource + private BaseMapper dictMapper; + @Resource + private BaseMapper dictConfigMapper; + @Resource + private ExtDictMapper extDictMapper; + + public List getDictListByType(String module, String orgId) { + LambdaQueryWrapper dictLambdaQueryWrapper = new LambdaQueryWrapper<>(); + dictLambdaQueryWrapper.eq(Dict::getModule, module).eq(Dict::getOrganizationId, orgId); + List dictList = dictMapper.selectListByLambda(dictLambdaQueryWrapper); + dictList.sort(Comparator.comparingLong(Dict::getPos)); + return dictList; + } + + /** + * 添加字典值 + * + * @param request 请求参数 + * @param currentUser 当前用户 + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.ADD) + public void addDict(DictAddRequest request, String currentUser, String orgId) { + Dict dict = new Dict(); + dict.setId(IDGenerator.nextStr()); + dict.setName(request.getName()); + dict.setModule(request.getModule()); + dict.setOrganizationId(orgId); + dict.setType("TEXT"); + Long nextPos = extDictMapper.getNextPos(request.getModule(), orgId); + dict.setPos(nextPos == null ? 1L : nextPos); + dict.setCreateUser(currentUser); + dict.setCreateTime(System.currentTimeMillis()); + dict.setUpdateUser(currentUser); + dict.setUpdateTime(System.currentTimeMillis()); + dictMapper.insert(dict); + + // 添加日志上下文 + OperationLogContext.setContext(LogContextInfo.builder() + .modifiedValue(dict) + .resourceId(dict.getId()) + .resourceName(Translator.get("module." + request.getModule().toLowerCase()) + Translator.get("module.reason.setting")) + .build()); + } + + /** + * 更新字典值 + * + * @param request 请求参数 + * @param currentUser 当前用户 + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE) + public void updateDict(DictUpdateRequest request, String currentUser) { + Dict oldDict = dictMapper.selectByPrimaryKey(request.getId()); + if (oldDict == null) { + throw new GenericException(Translator.get("dict.not_exist")); + } + Dict dict = BeanUtils.copyBean(new Dict(), oldDict); + dict.setName(request.getName()); + dict.setUpdateUser(currentUser); + dict.setUpdateTime(System.currentTimeMillis()); + dictMapper.updateById(dict); + + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(request.getId()) + .resourceName(Translator.get("module." + oldDict.getModule().toLowerCase()) + Translator.get("module.reason.setting")) + .originalValue(oldDict) + .modifiedValue(dict) + .build() + ); + } + + /** + * 删除字典值 + * + * @param id 字典ID + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.DELETE, resourceId = "{#id}") + public void deleteDict(String id) { + Dict dict = dictMapper.selectByPrimaryKey(id); + if (dict == null) { + throw new GenericException(Translator.get("dict.not_exist")); + } + dictMapper.deleteByPrimaryKey(id); + OperationLogContext.setResourceName(Translator.get("module." + dict.getModule().toLowerCase()) + Translator.get("module.reason.setting") + "/" + dict.getName()); + } + + /** + * 切换字典配置开关 + * + * @param request 请求参数 + * @param orgId 组织ID + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE, operator = "{#currentUser}") + public void switchDict(DictSwitchRequest request, String orgId) { + LambdaQueryWrapper configLambdaQueryWrapper = new LambdaQueryWrapper<>(); + configLambdaQueryWrapper.eq(DictConfig::getModule, request.getModule()).eq(DictConfig::getOrganizationId, orgId); + List configs = dictConfigMapper.selectListByLambda(configLambdaQueryWrapper); + if (CollectionUtils.isNotEmpty(configs)) { + DictConfig config = configs.getFirst(); + config.setEnabled(request.getEnable()); + extDictMapper.updateModuleConfig(config); + } else { + DictConfig config = new DictConfig(); + BeanUtils.copyBean(config, request); + config.setEnabled(request.getEnable()); + config.setOrganizationId(orgId); + dictConfigMapper.insert(config); + } + + //添加日志上下文 + Map originalVal = new HashMap<>(1); + originalVal.put("module.switch", !request.getEnable() ? Translator.get("log.enable.true") : Translator.get("log.enable.false")); + Map modifiedVal = new HashMap<>(1); + modifiedVal.put("module.switch", request.getEnable() ? Translator.get("log.enable.true") : Translator.get("log.enable.false")); + String resourceName = Translator.get("module." + request.getModule().toLowerCase()); + if (Strings.CI.equalsAny(request.getModule(), DictModule.CLUE_POOL_RS.name(), DictModule.CUSTOMER_POOL_RS.name(), DictModule.OPPORTUNITY_FAIL_RS.name())) { + resourceName += Translator.get("module.reason.setting"); + } + OperationLogContext.setContext(LogContextInfo.builder() + .originalValue(originalVal) + .resourceName(resourceName) + .modifiedValue(modifiedVal) + .resourceId(request.getModule()) + .build()); + } + + /** + * 字典排序 + * + * @param request 请求参数 + * @param currentUser 当前用户 + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE, operator = "{#currentUser}") + public void sort(DictSortRequest request, String currentUser) { + Dict oldDict = dictMapper.selectByPrimaryKey(request.getDragDictId()); + if (oldDict == null) { + throw new GenericException(Translator.get("dict.not_exist")); + } + // sort-before + List beforeKeys = getDictListByType(oldDict.getModule(), oldDict.getOrganizationId()).stream().map(Dict::getName).toList(); + if (request.getStart() < request.getEnd()) { + // start < end, 区间模块上移, pos - 1 + extDictMapper.moveUpDict(request.getStart(), request.getEnd(), oldDict.getModule(), oldDict.getOrganizationId()); + } else { + // start > end, 区间模块下移, pos + 1 + extDictMapper.moveDownDict(request.getEnd(), request.getStart(), oldDict.getModule(), oldDict.getOrganizationId()); + } + extDictMapper.updateDictPos(request.getDragDictId(), request.getEnd(), currentUser); + // sort-after + List afterKeys = getDictListByType(oldDict.getModule(), oldDict.getOrganizationId()).stream().map(Dict::getName).toList(); + + //添加日志上下文 + Map> originalVal = new HashMap<>(1); + originalVal.put("sort", beforeKeys); + Map> modifiedVal = new HashMap<>(1); + modifiedVal.put("sort", afterKeys); + OperationLogContext.setContext(LogContextInfo.builder() + .originalValue(originalVal) + .resourceName(Translator.get("module." + oldDict.getModule().toLowerCase()) + Translator.get("module.reason.setting")) + .modifiedValue(modifiedVal) + .resourceId(request.getDragDictId()) + .build()); + } + + /** + * 获取模块字典配置 + * + * @param module 模块 + * @param orgId 组织ID + * + * @return 字典配置 + */ + public DictConfigDTO getDictConf(String module, String orgId) { + List dictList = new ArrayList<>(getDictListByType(module, orgId)); + Dict sysDt = new Dict(); + sysDt.setId("system"); + if (Strings.CS.equalsAny(module, DictModule.CLUE_POOL_RS.name(), DictModule.CUSTOMER_POOL_RS.name())) { + sysDt.setName(Translator.get("system.auto.recycle")); + } else { + sysDt.setName(Translator.get("system.auto.closed")); + } + dictList.addLast(sysDt); + LambdaQueryWrapper configLambdaQueryWrapper = new LambdaQueryWrapper<>(); + configLambdaQueryWrapper.eq(DictConfig::getModule, module).eq(DictConfig::getOrganizationId, orgId); + List configs = dictConfigMapper.selectListByLambda(configLambdaQueryWrapper); + return DictConfigDTO.builder().dictList(dictList).enable(CollectionUtils.isNotEmpty(configs) && configs.getFirst().getEnabled()).build(); + } + + public boolean isDictConfigEnable(String module, String orgId) { + DictConfigDTO dictConf = getDictConf(module, orgId); + return dictConf != null && BooleanUtils.isTrue(dictConf.getEnable()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/DisposableWorkerIdAssigner.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/DisposableWorkerIdAssigner.java new file mode 100644 index 0000000..4ac28b2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/DisposableWorkerIdAssigner.java @@ -0,0 +1,68 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.uid.utils.DockerUtils; +import cn.cordys.common.uid.utils.NetUtils; +import cn.cordys.common.uid.worker.WorkerIdAssigner; +import cn.cordys.common.uid.worker.WorkerNodeType; +import cn.cordys.crm.system.domain.WorkerNode; +import cn.cordys.crm.system.mapper.ExtWorkerNodeMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomUtils; +import org.springframework.stereotype.Service; + +/** + * Represents an implementation of {@link WorkerIdAssigner}, + * the worker id will be discarded after assigned to the UidGenerator + */ +@Service +@Slf4j +public class DisposableWorkerIdAssigner implements WorkerIdAssigner { + @Resource + private ExtWorkerNodeMapper extWorkerNodeMapper; + + /** + * Assign worker id base on database.

+ * If there is host name & port in the environment, we considered that the node runs in Docker container
+ * Otherwise, the node runs on an actual machine. + * + * @return assigned worker id + */ + public long assignWorkerId() { + // build worker node entity + try { + WorkerNode workerNode = buildWorkerNode(); + + // add worker node for new (ignore the same IP + PORT) + extWorkerNodeMapper.insert(workerNode); + log.info("Add worker node:{}", workerNode); + + return workerNode.getId(); + } catch (Exception e) { + log.error("Assign worker id exception. ", e); + return 1; + } + } + + /** + * Build worker node entity by IP and PORT + */ + private WorkerNode buildWorkerNode() { + WorkerNode workerNode = new WorkerNode(); + if (DockerUtils.isDocker()) { + workerNode.setType(WorkerNodeType.CONTAINER.value()); + workerNode.setHostName(DockerUtils.getDockerHost()); + workerNode.setPort(DockerUtils.getDockerPort()); + + } else { + workerNode.setType(WorkerNodeType.ACTUAL.value()); + workerNode.setHostName(NetUtils.getLocalAddress()); + workerNode.setPort(System.currentTimeMillis() + "-" + RandomUtils.insecure().randomInt()); + } + workerNode.setCreated(System.currentTimeMillis()); + workerNode.setModified(System.currentTimeMillis()); + workerNode.setLaunchDate(System.currentTimeMillis()); + return workerNode; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/ExportTaskCenterService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/ExportTaskCenterService.java new file mode 100644 index 0000000..afe611d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/ExportTaskCenterService.java @@ -0,0 +1,118 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.constants.TopicConstants; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.redis.MessagePublisher; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.domain.ExportTask; +import cn.cordys.crm.system.dto.request.ExportTaskCenterQueryRequest; +import cn.cordys.crm.system.mapper.ExtExportTaskMapper; +import cn.cordys.file.engine.DefaultRepositoryDir; +import cn.cordys.file.engine.FileRequest; +import cn.cordys.file.engine.StorageType; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.Strings; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; + +/** + * @author song-cc-rock + */ +@Service +@Slf4j +public class ExportTaskCenterService { + + @Resource + private BaseMapper exportTaskMapper; + @Resource + private MessagePublisher messagePublisher; + @Resource + private FileCommonService fileCommonService; + @Resource + private ExtExportTaskMapper extExportTaskMapper; + + /** + * 查询导出任务列表 + * + * @param request 请求参数 + * + * @return 导出任务列表 + */ + public List list(ExportTaskCenterQueryRequest request, String userId) { + LocalDateTime oneDayBefore = LocalDateTime.now().minusDays(1); + return extExportTaskMapper.selectExportTaskList(request, oneDayBefore.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(), userId); + } + + /** + * 取消导出任务 + * + * @param taskId 任务ID + */ + public void cancel(String taskId, String userId) { + ExportTask exportTask = exportTaskMapper.selectByPrimaryKey(taskId); + if (exportTask == null || !Strings.CS.equals(exportTask.getCreateUser(), userId)) { + throw new GenericException(Translator.get("task_not_found")); + } + if (Strings.CI.equals(exportTask.getStatus(), ExportConstants.ExportStatus.SUCCESS.name())) { + throw new GenericException(Translator.get("task_already_stopped")); + } + exportTask.setStatus(ExportConstants.ExportStatus.STOP.name()); + exportTaskMapper.updateById(exportTask); + //停止任务 + messagePublisher.publish(TopicConstants.DOWNLOAD_TOPIC, taskId); + } + + /** + * 下载任务文件 + * + * @param taskId 任务ID + */ + public ResponseEntity download(String taskId, String userId) { + ExportTask exportTask = exportTaskMapper.selectByPrimaryKey(taskId); + if (exportTask == null || !Strings.CS.equals(exportTask.getCreateUser(), userId)) { + throw new GenericException(Translator.get("task_not_found")); + } + String filePath = getFilePath(exportTask); + try { + FileRequest request = new FileRequest(filePath, StorageType.LOCAL.name(), exportTask.getFileName() + ".xlsx"); + return fileCommonService.download(request); + } catch (Exception e) { + log.error("下载导出任务文件失败,任务ID: {}", taskId, e); + throw new RuntimeException(e); + } + } + + private String getFilePath(ExportTask exportTask) { + return File.separator + DefaultRepositoryDir.getExportDir(exportTask.getOrganizationId()) + File.separator + exportTask.getFileId(); + } + + public void clean() { + LocalDateTime oneDayBefore = LocalDateTime.now().minusDays(1); + long epochMilli = oneDayBefore.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.ltT(ExportTask::getCreateTime, epochMilli).orderByDesc(ExportTask::getCreateTime); + List exportTasks = exportTaskMapper.selectListByLambda(queryWrapper); + exportTaskMapper.deleteByLambda(queryWrapper); + + exportTasks.forEach(exportTask -> { + try { + //删除文件 + FileRequest request = new FileRequest(getFilePath(exportTask), StorageType.LOCAL.name(), exportTask.getFileName() + ".xlsx"); + fileCommonService.deleteFolder(request, false); + } catch (GenericException e) { + log.error("定时任务删除导出任务文件失败,任务ID: {}", exportTask.getId(), e); + throw new RuntimeException(e); + } + }); + + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/ExportTaskService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/ExportTaskService.java new file mode 100644 index 0000000..739282a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/ExportTaskService.java @@ -0,0 +1,60 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.domain.ExportTask; +import cn.cordys.crm.system.mapper.ExtExportTaskMapper; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("ExportTaskService") +@Transactional(rollbackFor = Exception.class) +public class ExportTaskService { + + @Resource + private BaseMapper exportTaskMapper; + @Resource + private ExtExportTaskMapper extExportTaskMapper; + + public ExportTask saveTask(String orgId, String fileId, String userId, String resourceType, String fileName) { + ExportTask exportTask = new ExportTask(); + exportTask.setId(IDGenerator.nextStr()); + exportTask.setResourceType(resourceType); + exportTask.setCreateUser(userId); + exportTask.setCreateTime(System.currentTimeMillis()); + exportTask.setStatus(ExportConstants.ExportStatus.PREPARED.toString()); + exportTask.setUpdateUser(userId); + exportTask.setFileName(fileName); + exportTask.setUpdateTime(System.currentTimeMillis()); + exportTask.setOrganizationId(orgId); + exportTask.setFileId(fileId); + exportTaskMapper.insert(exportTask); + return exportTask; + } + + public void update(String taskId, String status, String userId) { + ExportTask exportTask = new ExportTask(); + exportTask.setId(taskId); + exportTask.setStatus(status); + exportTask.setUpdateTime(System.currentTimeMillis()); + exportTask.setUpdateUser(userId); + exportTaskMapper.updateById(exportTask); + } + + public void checkUserTaskLimit(String userId, String resourceType) { + // 先检查用户是否有10个或以上的待处理任务 + int userTaskCount = extExportTaskMapper.getExportTaskCount(userId, ExportConstants.ExportStatus.PREPARED.name()); + if (userTaskCount >= 10) { + throw new GenericException(Translator.get("user_export_task_limit")); + } + // 再检查是否存在相同资源类型的待处理任务,防止重复点击导致大量相同任务积压 + int count = extExportTaskMapper.checkPreparedUniqueness(userId, resourceType); + if (count > 0) { + throw new GenericException(Translator.get("user_export_task_prepared_limit")); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/ExportTaskStopService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/ExportTaskStopService.java new file mode 100644 index 0000000..976d59a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/ExportTaskStopService.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.crm.system.constants.ExportConstants; +import cn.cordys.crm.system.mapper.ExtExportTaskMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(rollbackFor = Exception.class) +public class ExportTaskStopService { + + @Resource + private ExtExportTaskMapper extExportTaskMapper; + + /** + * 重启时将所有导出中的任务停止,并且需要设置为失败 + */ + public void stopPreparedAll() { + extExportTaskMapper.updateExportTaskStatus(ExportConstants.ExportStatus.ERROR.name(), ExportConstants.ExportStatus.PREPARED.name()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/ExtScheduleService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/ExtScheduleService.java new file mode 100644 index 0000000..70cdbe5 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/ExtScheduleService.java @@ -0,0 +1,75 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.schedule.ScheduleManager; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.domain.Schedule; +import cn.cordys.crm.system.mapper.ExtScheduleMapper; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.quartz.JobKey; +import org.quartz.TriggerKey; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class ExtScheduleService { + + // TODO: Add RESOURCE_TYPES + private static final List RESOURCE_TYPES = List.of( + ); + @Resource + private BaseMapper scheduleMapper; + @Resource + private ScheduleManager scheduleManager; + @Resource + private ExtScheduleMapper extScheduleMapper; + + public void startEnableSchedules() { + long count = scheduleMapper.countByExample(new Schedule()); + long pages = (long) Math.ceil(count / 100.0); + + for (int i = 0; i < pages; i++) { + int start = i * 100; + List schedules = extScheduleMapper.getScheduleByLimit(start, 100); + doHandleSchedule(schedules); + } + } + + private void doHandleSchedule(List schedules) { + schedules.forEach(schedule -> { + try { + if (schedule.getEnable()) { + if (RESOURCE_TYPES.contains(schedule.getResourceType())) { + removeJob(schedule); // 删除关闭的job + } + log.info("初始化任务:{}", JSON.toJSONString(schedule)); + scheduleManager.addOrUpdateCronJob( + new JobKey(schedule.getKey(), schedule.getJob()), + new TriggerKey(schedule.getKey(), schedule.getJob()), + Class.forName(schedule.getJob()), + schedule.getValue(), + scheduleManager.getDefaultJobDataMap(schedule, schedule.getValue(), schedule.getCreateUser()) + ); + } else { + removeJob(schedule); // 删除关闭的job + } + } catch (ClassNotFoundException e) { + log.error("任务类未找到:{}", schedule.getJob(), e); + } catch (Exception e) { + log.error("初始化任务失败", e); + } + }); + } + + private void removeJob(Schedule schedule) { + scheduleManager.removeJob( + new JobKey(schedule.getKey(), schedule.getJob()), + new TriggerKey(schedule.getKey(), schedule.getJob()) + ); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/FileCommonService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/FileCommonService.java new file mode 100644 index 0000000..75e26e8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/FileCommonService.java @@ -0,0 +1,138 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.file.engine.FileCenter; +import cn.cordys.file.engine.FileCopyRequest; +import cn.cordys.file.engine.FileRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.util.List; + +/** + * @author song-cc-rock + */ +@Service +@Slf4j +public class FileCommonService { + + /** + * 清除临时文件资源 + * + * @param request 文件请求参数 + */ + public void cleanTempResource(FileRequest request) { + List folderFiles = getFolderFiles(request); + if (CollectionUtils.isEmpty(folderFiles)) { + return; + } + folderFiles.forEach(File::deleteOnExit); + } + + /** + * 上传文件 + * + * @param file 文件 + * @param request 请求参数 + */ + public void upload(MultipartFile file, FileRequest request) { + try { + FileCenter.getRepository(request.getStorage()).saveFile(file, request); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + + /** + * 上传文件 + * + * @param file + * @param request + */ + public void upload(File file, FileRequest request) { + try { + FileInputStream inputStream = new FileInputStream(file); + FileCenter.getRepository(request.getStorage()).saveFile(inputStream, request); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + + /** + * 获取目录下的所有文件 + * + * @param request 文件请求参数 + * + * @return 文件列表 + */ + public List getFolderFiles(FileRequest request) { + try { + return FileCenter.getRepository(request.getStorage()).getFolderFiles(request); + } catch (Exception e) { + log.error(e.getMessage(), e); + return null; + } + } + + /** + * 复制文件 + * + * @param request 复制文件参数 + * @param storage 存储类型 + */ + public void copyFile(FileCopyRequest request, String storage) { + try { + FileCenter.getRepository(storage).copyFile(request); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + + /** + * 获取文件输入流 + * + * @param request 文件请求参数 + * + * @return 文件输入流 + */ + public InputStream getFileInputStream(FileRequest request) { + try { + return FileCenter.getRepository(request.getStorage()).getFileAsStream(request); + } catch (Exception e) { + log.error(e.getMessage(), e); + return null; + } + } + + /** + * 删除目录 + * + * @param request 文件请求参数 + */ + public void deleteFolder(FileRequest request, boolean onlyDir) { + try { + FileCenter.getRepository(request.getStorage()).deleteFolder(request, onlyDir); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + + /** + * 下载文件 + * + * @param request 文件请求信息,包含待下载的文件标识符或路径。 + * + * @throws Exception 如果下载文件过程中发生错误,抛出异常。 + */ + public ResponseEntity download(FileRequest request) throws Exception { + return FileCenter.getRepository(request.getStorage()).downloadFile(request); + } + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/IntegrationConfigService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/IntegrationConfigService.java new file mode 100644 index 0000000..a6b0d1d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/IntegrationConfigService.java @@ -0,0 +1,1397 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.ThirdConfigTypeConstants; +import cn.cordys.common.constants.ThirdDetailType; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.integration.common.dto.ThirdConfigBaseDTO; +import cn.cordys.crm.integration.common.request.*; +import cn.cordys.crm.integration.dataease.DataEaseClient; +import cn.cordys.crm.integration.sso.service.TokenService; +import cn.cordys.crm.integration.sync.dto.ThirdSwitchLogDTO; +import cn.cordys.crm.integration.tender.constant.TenderApiPaths; +import cn.cordys.crm.integration.tender.dto.TenderDetailDTO; +import cn.cordys.crm.system.constants.OrganizationConfigConstants; +import cn.cordys.crm.system.domain.OrganizationConfig; +import cn.cordys.crm.system.domain.OrganizationConfigDetail; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigDetailMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigMapper; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.security.SessionUtils; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class IntegrationConfigService { + + @Resource + private ExtOrganizationConfigMapper extOrganizationConfigMapper; + + @Resource + private ExtOrganizationConfigDetailMapper extOrganizationConfigDetailMapper; + + @Resource + private BaseMapper organizationConfigDetailBaseMapper; + + @Resource + private BaseMapper organizationConfigBaseMapper; + + @Resource + private TokenService tokenService; + + /** + * 获取同步的组织配置 + * + * @param organizationId 组织ID + * @return 第三方配置列表 + */ + public List> getThirdConfig(String organizationId) { + List organizationConfigDetails = initConfig(organizationId, SessionUtils.getUserId()); + + // 构建第三方配置列表 + List> configDTOs = new ArrayList<>(); + buildDetailData(organizationConfigDetails, configDTOs); + + return configDTOs; + } + + /** + * 编辑配置 + * + * @param configDTO 配置DTO + * @param organizationId 组织ID + * @param userId 用户ID + */ + @OperationLog(module = LogModule.SYSTEM_BUSINESS_THIRD, type = LogType.UPDATE, operator = "{#userId}") + public void editThirdConfig(ThirdConfigBaseDTO configDTO, String organizationId, String userId) { + // 获取或创建组织配置 + OrganizationConfig organizationConfig = getOrCreateOrganizationConfig(organizationId, userId); + + // 获取当前平台对应类型和启用状态 + List types = getDetailTypes(configDTO.getType()); + Map typeEnableMap = getTypeEnableMap(configDTO); + + // 获取当前类型下的配置详情 + List existingDetails = extOrganizationConfigDetailMapper + .getOrgConfigDetailByType(organizationConfig.getId(), null, types); + + // 获取验证所需的token + String token = getToken(configDTO); + + //这里检查一下最近同步的来源是否和当前修改的一致,如果不一致,且当前平台开启同步按钮,则关闭其他平台按钮 + String lastSyncType = getLastSyncType(organizationConfig.getId()); + if (lastSyncType != null && !Strings.CI.equals(lastSyncType, configDTO.getType()) && getEnable(configDTO)) { + // 关闭其他平台按钮 + List detailTypes = getDetailTypes(lastSyncType); + detailTypes.forEach(detailType -> extOrganizationConfigDetailMapper.updateStatus( + false, detailType, organizationConfig.getId() + )); + } + + if (CollectionUtils.isEmpty(existingDetails)) { + // 没有配置详情,创建新的 + handleNewConfigDetails(configDTO, userId, token, types, organizationConfig, typeEnableMap); + } else { + // 更新已有配置 + handleExistingConfigDetails(configDTO, userId, token, types, organizationConfig, existingDetails, typeEnableMap); + } + } + + /** + * 测试连接 + * + * @param configDTO 配置DTO + * @return 是否连接成功 + */ + public boolean testConnection(ThirdConfigBaseDTO configDTO, String orgId, String userId) { + String token = getToken(configDTO); + if (ThirdConfigTypeConstants.WECOM.name().equals(configDTO.getType()) && StringUtils.isNotBlank(token)) { + WecomThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), WecomThirdConfigRequest.class); + boolean weComAgent = tokenService.checkWeComAgentAvailable(token, config.getAgentId()); + if (!weComAgent) { + token = null; + } + } + boolean verify = StringUtils.isNotBlank(token); + editThirdConfig((ThirdConfigBaseDTO) configDTO, orgId, userId); + + return verify; + } + + /** + * 获取同步状态 + * + * @param orgId 组织ID + * @param type 类型 + * @param syncResource 同步资源 + * @return 是否同步 + */ + public boolean getSyncStatus(String orgId, String type, String syncResource) { + OrganizationConfig syncStatus = extOrganizationConfigMapper.getSyncStatus(orgId, type, syncResource); + return syncStatus != null && BooleanUtils.isTrue(syncStatus.isSync()); + } + + /** + * 根据类型获取第三方配置 + * + * @param type 类型 + * @param orgId 组织ID + * @return 配置DTO + */ + public ThirdConfigBaseDTO getThirdConfigForPublic(String type, String orgId) { + // 确定配置类型和组织ID + String configType = OrganizationConfigConstants.ConfigType.THIRD.name(); + + // 获取组织配置 + OrganizationConfig config = extOrganizationConfigMapper.getOrganizationConfig( + orgId, configType + ); + + if (config == null) { + throw new GenericException(Translator.get("third.config.not.exist")); + } + + // 获取配置详情 + List details = extOrganizationConfigDetailMapper + .getOrganizationConfigDetails(config.getId(), null); + + if (CollectionUtils.isEmpty(details)) { + throw new GenericException(Translator.get("third.config.not.exist")); + } + + return getConfigurationByType(type, details); + } + + /** + * 获取第三方类型列表 + * + * @param orgId 组织ID + * @return 选项列表 + */ + public List getThirdTypeList(String orgId) { + // 获取组织配置 + OrganizationConfig config = extOrganizationConfigMapper.getOrganizationConfig( + orgId, OrganizationConfigConstants.ConfigType.THIRD.name() + ); + + if (config == null) { + return new ArrayList<>(); + } + + // 获取CODE类型的配置详情 + List codeTypes = List.of( + ThirdDetailType.WECOM_SYNC.name(), + ThirdDetailType.DINGTALK_SYNC.name(), + ThirdDetailType.LARK_SYNC.name() + ); + + List details = extOrganizationConfigDetailMapper + .getOrgConfigDetailByType(config.getId(), null, codeTypes); + + if (CollectionUtils.isEmpty(details)) { + return new ArrayList<>(); + } + + // 构建选项列表 + return details.stream() + .map(this::getOptionDTO) + .sorted(Comparator.comparing(OptionDTO::getId).reversed()) + .toList(); + } + + /** + * 切换第三方设置 + * + * @param type 类型 + * @param organizationId 组织ID + */ + @OperationLog(module = LogModule.SYSTEM_BUSINESS_THIRD, type = LogType.UPDATE, operator = "{#userId}") + public void switchThirdPartySetting(String type, String organizationId) { + OrganizationConfig organizationConfig = extOrganizationConfigMapper.getOrganizationConfig(organizationId, OrganizationConfigConstants.ConfigType.THIRD.name()); + if (organizationConfig == null) { + return; + } + String oldType = organizationConfig.getSyncResource(); + + if (type.equals(oldType)) { + return; + } + ThirdSwitchLogDTO oldLog = new ThirdSwitchLogDTO(); + oldLog.setThirdType(oldType); + ThirdSwitchLogDTO newLog = new ThirdSwitchLogDTO(); + newLog.setThirdType(type); + //这里检查一下最近同步的来源是否和当前修改的一致,如果不一致,则关闭其他平台按钮 + // 关闭其他平台按钮 + List detailTypes = getDetailTypes(oldType); + detailTypes.forEach(detailType -> extOrganizationConfigDetailMapper.updateStatus( + false, detailType, organizationConfig.getId() + )); + extOrganizationConfigMapper.updateSyncFlag(organizationId, type, OrganizationConfigConstants.ConfigType.THIRD.name(), false); + OperationLogContext.setContext(LogContextInfo.builder() + .resourceName(Translator.get("third.setting")) + .resourceId(organizationConfig.getId()) + .originalValue(oldLog) + .modifiedValue(newLog) + .build()); + } + + /** + * 获取最近同步资源 + * + * @param organizationId 组织ID + * @return 组织配置 + */ + public OrganizationConfig getLatestSyncResource(String organizationId) { + return extOrganizationConfigMapper.getOrganizationConfig(organizationId, OrganizationConfigConstants.ConfigType.THIRD.name()); + } + + /** + * 获取应用配置 + * + * @param organizationId 组织ID + * @param userId 用户ID + * @param type 类型 + * @return 配置DTO + */ + public ThirdConfigBaseDTO getApplicationConfig(String organizationId, String userId, String type) { + List organizationConfigDetails = initConfig(organizationId, userId); + return getThirdConfigurationDTOByType(organizationConfigDetails, type); + } + + /** + * 构建详情数据 + * + * @param details 配置详情列表 + * @param configDTOs 配置DTO列表 + */ + private void buildDetailData(List details, + List> configDTOs) { + + ThirdConfigBaseDTO sqlBotConfig = new ThirdConfigBaseDTO<>(); + SqlBotThirdConfigRequest sqlBotDTO = new SqlBotThirdConfigRequest(); + + for (OrganizationConfigDetail detail : details) { + String type = detail.getType(); + String content = new String(detail.getContent()); + + if (Strings.CI.equals(type, ThirdDetailType.WECOM_SYNC.name())) { + ThirdConfigBaseDTO dto = buildDto( + content, + WecomThirdConfigRequest.class, + cfg -> cfg.setStartEnable(detail.getEnable()), + ThirdConfigTypeConstants.WECOM.name() + ); + configDTOs.add(dto); + + } else if (Strings.CI.equals(type, ThirdDetailType.DINGTALK_SYNC.name())) { + ThirdConfigBaseDTO dto = buildDto( + content, + DingTalkThirdConfigRequest.class, + cfg -> cfg.setStartEnable(detail.getEnable()), + ThirdConfigTypeConstants.DINGTALK.name() + ); + configDTOs.add(dto); + + } else if (Strings.CI.equals(type, ThirdDetailType.LARK_SYNC.name())) { + ThirdConfigBaseDTO dto = buildDto( + content, + LarkThirdConfigRequest.class, + cfg -> cfg.setStartEnable(detail.getEnable()), + ThirdConfigTypeConstants.LARK.name() + ); + configDTOs.add(dto); + + } else if (Strings.CI.equals(type, ThirdDetailType.DE_BOARD.name())) { + ThirdConfigBaseDTO dto = buildDto( + content, + DeThirdConfigRequest.class, + cfg -> cfg.setDeBoardEnable(detail.getEnable()), + ThirdConfigTypeConstants.DE.name() + ); + configDTOs.add(dto); + + } else if (Strings.CI.equals(type, ThirdDetailType.SQLBOT_CHAT.name()) + || Strings.CI.equals(type, ThirdDetailType.SQLBOT_BOARD.name())) { + + ThirdConfigBaseDTO dto = JSON.parseObject(content, ThirdConfigBaseDTO.class); + SqlBotThirdConfigRequest cfg = buildConfig(dto, content, SqlBotThirdConfigRequest.class); + + if (Strings.CI.equals(type, ThirdDetailType.SQLBOT_CHAT.name())) { + sqlBotDTO.setSqlBotChatEnable(detail.getEnable()); + } else { + sqlBotDTO.setSqlBotBoardEnable(detail.getEnable()); + } + + sqlBotDTO.setAppSecret(cfg.getAppSecret()); + sqlBotConfig.setType(ThirdConfigTypeConstants.SQLBOT.name()); + sqlBotConfig.setVerify(dto.getVerify()); + + } else if (Strings.CI.equals(type, ThirdDetailType.MAXKB.name())) { + ThirdConfigBaseDTO dto = buildDto( + content, + MaxKBThirdConfigRequest.class, + cfg -> cfg.setMkEnable(detail.getEnable()), + ThirdConfigTypeConstants.MAXKB.name() + ); + configDTOs.add(dto); + + } else if (Strings.CI.equals(type, ThirdDetailType.TENDER.name())) { + ThirdConfigBaseDTO dto = buildDto( + content, + TenderThirdConfigRequest.class, + cfg -> cfg.setTenderEnable(detail.getEnable()), + ThirdConfigTypeConstants.TENDER.name() + ); + configDTOs.add(dto); + + } else if (Strings.CI.equals(type, ThirdDetailType.QCC.name())) { + ThirdConfigBaseDTO dto = buildDto( + content, + QccThirdConfigRequest.class, + cfg -> cfg.setQccEnable(detail.getEnable()), + ThirdConfigTypeConstants.QCC.name() + ); + configDTOs.add(dto); + } + } + + if (sqlBotConfig.getType() != null) { + sqlBotConfig.setConfig(sqlBotDTO); + configDTOs.add(sqlBotConfig); + } + } + + /** + * 构建DTO + * + * @param content 内容 + * @param configClass 配置类 + * @param enableSetter 启用设置器 + * @param type 类型 + * @param 配置类型 + * @return 配置DTO + */ + private ThirdConfigBaseDTO buildDto(String content, Class configClass, Consumer enableSetter, String type) { + + var dto = JSON.parseObject(content, ThirdConfigBaseDTO.class); + T config = buildConfig(dto, content, configClass); + + enableSetter.accept(config); + dto.setType(type); + dto.setConfig(config); + return dto; + } + + /** + * 构建配置 + * + * @param dto DTO + * @param content 内容 + * @param configClass 配置类 + * @param 配置类型 + * @return 配置对象 + */ + private T buildConfig(ThirdConfigBaseDTO dto, String content, Class configClass) { + + return dto.getConfig() == null + ? JSON.parseObject(content, configClass) + : JSON.MAPPER.convertValue(dto.getConfig(), configClass); + } + + /** + * 初始化配置 + * + * @param organizationId 组织ID + * @param userId 用户ID + * @return 配置详情列表 + */ + private List initConfig(String organizationId, String userId) { + // 获取或创建组织配置 + OrganizationConfig organizationConfig = getOrCreateOrganizationConfig(organizationId, userId); + + // 检查当前类型下是否还有数据 + List organizationConfigDetails = extOrganizationConfigDetailMapper + .getOrganizationConfigDetails(organizationConfig.getId(), null); + + OrganizationConfigDetail tenderConfig = organizationConfigDetails.stream().filter(detail -> Strings.CI.contains(detail.getType(), ThirdConfigTypeConstants.TENDER.name())) + .findFirst().orElse(null); + if (tenderConfig == null) { + initTender(userId, organizationConfig); + } + + organizationConfigDetails = extOrganizationConfigDetailMapper + .getOrganizationConfigDetails(organizationConfig.getId(), null); + return organizationConfigDetails; + } + + /** + * 初始化招标配置 + * + * @param userId 用户ID + * @param organizationConfig 组织配置 + */ + private void initTender(String userId, OrganizationConfig organizationConfig) { + TenderDetailDTO tenderConfig = new TenderDetailDTO(); + tenderConfig.setTenderAddress(TenderApiPaths.TENDER_API); + tenderConfig.setVerify(true); + OrganizationConfigDetail detail = createConfigDetail(userId, organizationConfig, JSON.toJSONString(tenderConfig)); + detail.setType(ThirdConfigTypeConstants.TENDER.name()); + detail.setEnable(true); + detail.setName(Translator.get("third.setting")); + organizationConfigDetailBaseMapper.insert(detail); + } + + /** + * 根据类型获取第三方配置DTO + * + * @param organizationConfigDetails 配置详情列表 + * @param type 类型 + * @return 配置DTO + */ + private ThirdConfigBaseDTO getThirdConfigurationDTOByType( + List organizationConfigDetails, String type) { + + if (Strings.CI.equals(ThirdConfigTypeConstants.DE.name(), type)) { + type = ThirdDetailType.DE_BOARD.name(); + } + + String finalType = type; + List detailList = organizationConfigDetails.stream() + .filter(t -> t.getType().contains(finalType)) + .toList(); + + List> configDTOs = new ArrayList<>(); + buildDetailData(detailList, configDTOs); + + if (CollectionUtils.isNotEmpty(configDTOs)) { + return configDTOs.getFirst(); + } + return new ThirdConfigBaseDTO<>(); + } + + /** + * 获取或创建组织配置 + * + * @param organizationId 组织ID + * @param userId 用户ID + * @return 组织配置 + */ + private OrganizationConfig getOrCreateOrganizationConfig(String organizationId, String userId) { + OrganizationConfig config = extOrganizationConfigMapper + .getOrganizationConfig(organizationId, OrganizationConfigConstants.ConfigType.THIRD.name()); + + if (config == null) { + config = createNewOrganizationConfig(organizationId, userId); + } + + return config; + } + + /** + * 处理新建配置详情 + * + * @param configDTO 配置DTO + * @param userId 用户ID + * @param token Token + * @param types 类型列表 + * @param organizationConfig 组织配置 + * @param typeEnableMap 类型启用映射 + */ + private void handleNewConfigDetails( + ThirdConfigBaseDTO configDTO, + String userId, + String token, + List types, + OrganizationConfig organizationConfig, + Map typeEnableMap) { + + addIntegrationDetail(configDTO, userId, token, types, organizationConfig, typeEnableMap); + } + + /** + * 添加集成详情 + * + * @param configDTO 配置DTO + * @param userId 用户ID + * @param token Token + * @param types 类型列表 + * @param organizationConfig 组织配置 + * @param typeEnableMap 类型启用映射 + */ + private void addIntegrationDetail(ThirdConfigBaseDTO configDTO, String userId, String token, List types, OrganizationConfig organizationConfig, Map typeEnableMap) { + String jsonContent = null; + Boolean verify = false; + ThirdConfigTypeConstants typeConstants = ThirdConfigTypeConstants.fromString(configDTO.getType()); + if (typeConstants == null) { + throw new GenericException("unsupported.third.type"); + } + switch (typeConstants) { + case WECOM -> { + WecomThirdConfigRequest wecomConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), WecomThirdConfigRequest.class); + if (wecomConfig.getStartEnable()) { + verifyWeCom(wecomConfig.getAgentId(), token, configDTO); + } + configDTO.setConfig(wecomConfig); + jsonContent = JSON.toJSONString(configDTO); + verify = configDTO.getVerify(); + addLog(new HashMap<>(), configDTO, null, JSON.parseToMap(JSON.toJSONString(wecomConfig))); + } + case DINGTALK -> { + DingTalkThirdConfigRequest dingTalkConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), DingTalkThirdConfigRequest.class); + if (dingTalkConfig.getStartEnable()) { + verifyDingTalk(token, configDTO); + } + configDTO.setConfig(dingTalkConfig); + jsonContent = JSON.toJSONString(configDTO); + verify = configDTO.getVerify(); + addLog(new HashMap<>(), configDTO, null, JSON.parseToMap(JSON.toJSONString(dingTalkConfig))); + } + case LARK -> { + LarkThirdConfigRequest larkConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), LarkThirdConfigRequest.class); + if (larkConfig.getStartEnable()) { + verifyLark(token, configDTO); + } + configDTO.setConfig(larkConfig); + jsonContent = JSON.toJSONString(configDTO); + verify = configDTO.getVerify(); + addLog(new HashMap<>(), configDTO, null, JSON.parseToMap(JSON.toJSONString(larkConfig))); + } + case DE -> { + DeThirdConfigRequest deConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), DeThirdConfigRequest.class); + if (BooleanUtils.isTrue(deConfig.getDeBoardEnable())) { + verifyDe(token, configDTO); + } + configDTO.setConfig(deConfig); + jsonContent = JSON.toJSONString(configDTO); + verify = configDTO.getVerify(); + addLog(new HashMap<>(), configDTO, null, JSON.parseToMap(JSON.toJSONString(deConfig))); + } + case SQLBOT -> { + SqlBotThirdConfigRequest sqlBotConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), SqlBotThirdConfigRequest.class); + if (sqlBotConfig.getSqlBotBoardEnable() || sqlBotConfig.getSqlBotChatEnable()) { + verifyToken(token, configDTO); + } + configDTO.setConfig(sqlBotConfig); + jsonContent = JSON.toJSONString(configDTO); + verify = configDTO.getVerify(); + addLog(new HashMap<>(), configDTO, null, JSON.parseToMap(JSON.toJSONString(sqlBotConfig))); + } + case MAXKB -> { + MaxKBThirdConfigRequest mkConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), MaxKBThirdConfigRequest.class); + if (mkConfig.getMkEnable()) { + verifyToken(token, configDTO); + } + configDTO.setConfig(mkConfig); + jsonContent = JSON.toJSONString(configDTO); + verify = configDTO.getVerify(); + addLog(new HashMap<>(), configDTO, null, JSON.parseToMap(JSON.toJSONString(mkConfig))); + } + case TENDER -> { + TenderThirdConfigRequest tenderConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), TenderThirdConfigRequest.class); + tenderConfig.setTenderAddress(TenderApiPaths.TENDER_API); + if (tenderConfig.getTenderEnable()) { + verifyToken(token, configDTO); + } + configDTO.setConfig(tenderConfig); + jsonContent = JSON.toJSONString(configDTO); + verify = configDTO.getVerify(); + addLog(new HashMap<>(), configDTO, null, JSON.parseToMap(JSON.toJSONString(tenderConfig))); + } + case QCC -> { + QccThirdConfigRequest qccConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), QccThirdConfigRequest.class); + if (qccConfig.getQccEnable()) { + verifyToken(token, configDTO); + } + configDTO.setConfig(qccConfig); + jsonContent = JSON.toJSONString(configDTO); + verify = configDTO.getVerify(); + addLog(new HashMap<>(), configDTO, null, JSON.parseToMap(JSON.toJSONString(qccConfig))); + } + } + + saveDetail(userId, organizationConfig, types, typeEnableMap, jsonContent, verify); + } + + /** + * 处理已存在的配置详情 + * + * @param configDTO 配置DTO + * @param userId 用户ID + * @param token Token + * @param types 类型列表 + * @param organizationConfig 组织配置 + * @param existingDetails 现有详情列表 + * @param typeEnableMap 类型启用映射 + */ + private void handleExistingConfigDetails( + ThirdConfigBaseDTO configDTO, + String userId, + String token, + List types, + OrganizationConfig organizationConfig, + List existingDetails, + Map typeEnableMap) { + + Map existDetailTypeMap = existingDetails.stream() + .collect(Collectors.toMap( + OrganizationConfigDetail::getType, + Function.identity(), + (left, right) -> left)); + + for (String type : types) { + OrganizationConfigDetail detail = existDetailTypeMap.get(type); + if (detail == null) { + addIntegrationDetail(configDTO, userId, token, List.of(type), organizationConfig, typeEnableMap); + continue; + } + + boolean needResetSync = BooleanUtils.isTrue(organizationConfig.isSync()) + && StringUtils.isNotBlank(organizationConfig.getSyncResource()) + && Strings.CI.equals(organizationConfig.getSyncResource(), configDTO.getType()) + && syncCorpId(detail, configDTO); + + if (needResetSync) { + extOrganizationConfigMapper.updateSyncFlag( + organizationConfig.getOrganizationId(), + organizationConfig.getSyncResource(), + organizationConfig.getType(), + false); + } + + updateExistingDetail(configDTO, userId, token, detail, typeEnableMap.get(type), organizationConfig.getId()); + } + } + + /** + * 更新已存在的配置详情 + * + * @param configDTO 配置DTO + * @param userId 用户ID + * @param token Token + * @param detail 配置详情 + * @param enable 是否启用 + * @param id ID + */ + private void updateExistingDetail( + ThirdConfigBaseDTO configDTO, + String userId, + String token, + OrganizationConfigDetail detail, + Boolean enable, + String id) { + + updateIntegrationDetail(configDTO, userId, token, detail, enable, id); + } + + /** + * 更新集成详情 + * + * @param configDTO 配置DTO + * @param userId 用户ID + * @param token Token + * @param detail 配置详情 + * @param enable 是否启用 + * @param id ID + */ + private void updateIntegrationDetail( + ThirdConfigBaseDTO configDTO, + String userId, + String token, + OrganizationConfigDetail detail, + Boolean enable, + String id) { + + String jsonContent = null; + boolean openEnable = false; + + ThirdConfigTypeConstants typeConstants = + ThirdConfigTypeConstants.fromString(configDTO.getType()); + + if (typeConstants == null) { + throw new GenericException("unsupported.third.type"); + } + + switch (typeConstants) { + case WECOM -> { + WecomThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), WecomThirdConfigRequest.class); + if (config.getStartEnable()) { + verifyWeCom(config.getAgentId(), token, configDTO); + } + + configDTO.setConfig(config); + jsonContent = JSON.toJSONString(configDTO); + + boolean isVerified = Boolean.TRUE.equals(configDTO.getVerify()); + openEnable = isVerified && enable; + + WecomThirdConfigRequest oldConfig = parseOldConfig(detail, WecomThirdConfigRequest.class); + + addConfigLog(oldConfig, configDTO, id, config); + } + + case DINGTALK -> { + DingTalkThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), DingTalkThirdConfigRequest.class); + + if (config.getStartEnable()) { + verifyDingTalk(token, configDTO); + } + + configDTO.setConfig(config); + jsonContent = JSON.toJSONString(configDTO); + + boolean isVerified = Boolean.TRUE.equals(configDTO.getVerify()); + openEnable = isVerified && enable; + + DingTalkThirdConfigRequest oldConfig = parseOldConfig(detail, DingTalkThirdConfigRequest.class); + + addConfigLog(oldConfig, configDTO, id, config); + } + + case LARK -> { + LarkThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), LarkThirdConfigRequest.class); + + if (config.getStartEnable()) { + verifyLark(token, configDTO); + } + + configDTO.setConfig(config); + jsonContent = JSON.toJSONString(configDTO); + + boolean isVerified = Boolean.TRUE.equals(configDTO.getVerify()); + openEnable = isVerified && enable; + + LarkThirdConfigRequest oldConfig = parseOldConfig(detail, LarkThirdConfigRequest.class); + + addConfigLog(oldConfig, configDTO, id, config); + } + + case DE -> { + DeThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), DeThirdConfigRequest.class); + + if (BooleanUtils.isTrue(config.getDeBoardEnable())) { + verifyDe(token, configDTO); + } + + configDTO.setConfig(config); + jsonContent = JSON.toJSONString(configDTO); + openEnable = enable; + + DeThirdConfigRequest oldConfig = parseOldConfig(detail, DeThirdConfigRequest.class); + + addConfigLog(oldConfig, configDTO, id, config); + } + + case SQLBOT -> { + SqlBotThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), SqlBotThirdConfigRequest.class); + + if (config.getSqlBotBoardEnable() || config.getSqlBotChatEnable()) { + verifyToken(token, configDTO); + } + + configDTO.setConfig(config); + jsonContent = JSON.toJSONString(configDTO); + + boolean isVerified = Boolean.TRUE.equals(configDTO.getVerify()); + openEnable = isVerified && enable; + + SqlBotThirdConfigRequest oldConfig = parseOldConfig(detail, SqlBotThirdConfigRequest.class); + + addConfigLog(oldConfig, configDTO, id, config); + } + + case MAXKB -> { + MaxKBThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), MaxKBThirdConfigRequest.class); + + if (config.getMkEnable()) { + verifyToken(token, configDTO); + } + + configDTO.setConfig(config); + jsonContent = JSON.toJSONString(configDTO); + openEnable = enable; + + MaxKBThirdConfigRequest oldConfig = parseOldConfig(detail, MaxKBThirdConfigRequest.class); + + addConfigLog(oldConfig, configDTO, id, config); + } + + case TENDER -> { + TenderThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), TenderThirdConfigRequest.class); + + config.setTenderAddress(TenderApiPaths.TENDER_API); + + if (config.getTenderEnable()) { + verifyToken(token, configDTO); + } + + configDTO.setConfig(config); + jsonContent = JSON.toJSONString(configDTO); + openEnable = enable; + + TenderThirdConfigRequest oldConfig = parseOldConfig(detail, TenderThirdConfigRequest.class); + + addConfigLog(oldConfig, configDTO, id, config); + } + + case QCC -> { + QccThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), QccThirdConfigRequest.class); + if (config.getQccEnable()) { + verifyToken(token, configDTO); + } + + configDTO.setConfig(config); + jsonContent = JSON.toJSONString(configDTO); + openEnable = enable; + + QccThirdConfigRequest oldConfig = parseOldConfig(detail, QccThirdConfigRequest.class); + + addConfigLog(oldConfig, configDTO, id, config); + } + } + + if (StringUtils.isNotBlank(jsonContent)) { + updateOrganizationConfigDetail(jsonContent, userId, detail, openEnable); + } + } + + /** + * 解析旧配置 + * + * @param detail 配置详情 + * @param clazz 类 + * @param 类型 + * @return 配置对象 + */ + private T parseOldConfig(OrganizationConfigDetail detail, Class clazz) { + ThirdConfigBaseDTO oldDto = + JSON.parseObject(new String(detail.getContent()), ThirdConfigBaseDTO.class); + + if (oldDto.getConfig() == null) { + return JSON.parseObject(new String(detail.getContent()), clazz); + } + return JSON.MAPPER.convertValue(oldDto.getConfig(), clazz); + } + + /** + * 添加配置日志 + * + * @param oldConfig 旧配置 + * @param newDto 新DTO + * @param id ID + * @param newConfig 新配置 + */ + private void addConfigLog(Object oldConfig, + ThirdConfigBaseDTO newDto, + String id, + Object newConfig) { + addLog( + JSON.parseToMap(JSON.toJSONString(oldConfig)), + newDto, + id, + JSON.parseToMap(JSON.toJSONString(newConfig)) + ); + } + + /** + * 添加日志 + * + * @param oldMap 旧映射 + * @param configDTO 配置DTO + * @param id ID + * @param newMap 新映射 + */ + private void addLog(Map oldMap, ThirdConfigBaseDTO configDTO, String id, Map newMap) { + oldMap.put("type", configDTO.getType()); + oldMap.put("verify", configDTO.getVerify()); + newMap.put("type", configDTO.getType()); + newMap.put("verify", configDTO.getVerify()); + OperationLogContext.setContext(LogContextInfo.builder() + .resourceName(Translator.get("third.setting")) + .resourceId(id) + .originalValue(oldMap) + .modifiedValue(newMap) + .build()); + } + + /** + * 验证De配置 + * + * @param token Token + * @param deConfig 配置DTO + */ + private void verifyDe(String token, ThirdConfigBaseDTO deConfig) { + deConfig.setVerify(StringUtils.isNotBlank(token) && Strings.CI.equals(token, "true")); + } + + /** + * 保存详情 + * + * @param userId 用户ID + * @param organizationConfig 组织配置 + * @param types 类型列表 + * @param typeEnableMap 类型启用映射 + * @param jsonString JSON字符串 + * @param verify 是否验证 + */ + private void saveDetail(String userId, OrganizationConfig organizationConfig, List types, Map typeEnableMap, String jsonString, Boolean verify) { + for (String type : types) { + OrganizationConfigDetail detail = createConfigDetail(userId, organizationConfig, jsonString); + detail.setType(type); + + // 设置启用状态 + if (verify != null) { + detail.setEnable(verify && typeEnableMap.get(type)); + } else { + detail.setEnable(false); + } + + detail.setName(Translator.get("third.setting")); + organizationConfigDetailBaseMapper.insert(detail); + } + } + + /** + * 创建配置详情 + * + * @param userId 用户ID + * @param organizationConfig 组织配置 + * @param jsonString JSON字符串 + * @return 配置详情 + */ + private OrganizationConfigDetail createConfigDetail(String userId, OrganizationConfig organizationConfig, String jsonString) { + OrganizationConfigDetail detail = new OrganizationConfigDetail(); + detail.setId(IDGenerator.nextStr()); + detail.setContent(jsonString.getBytes()); + detail.setCreateTime(System.currentTimeMillis()); + detail.setUpdateTime(System.currentTimeMillis()); + detail.setCreateUser(userId); + detail.setUpdateUser(userId); + detail.setConfigId(organizationConfig.getId()); + return detail; + } + + /** + * 验证WeCom + * + * @param agentId 代理ID + * @param token Token + * @param weComConfig 配置DTO + */ + private void verifyWeCom(String agentId, String token, ThirdConfigBaseDTO weComConfig) { + if (StringUtils.isNotBlank(token)) { + // 验证应用ID + Boolean weComAgent = tokenService.checkWeComAgentAvailable(token, agentId); + weComConfig.setVerify(weComAgent); + } else { + weComConfig.setVerify(false); + } + } + + /** + * 验证DingTalk + * + * @param token Token + * @param dingTalkConfigDetailDTO 配置DTO + */ + private void verifyDingTalk(String token, ThirdConfigBaseDTO dingTalkConfigDetailDTO) { + dingTalkConfigDetailDTO.setVerify(StringUtils.isNotBlank(token)); + } + + /** + * 验证Lark + * + * @param token Token + * @param larkConfigDetailDTO 配置DTO + */ + private void verifyLark(String token, ThirdConfigBaseDTO larkConfigDetailDTO) { + larkConfigDetailDTO.setVerify(StringUtils.isNotBlank(token)); + } + + /** + * 验证Token + * + * @param token Token + * @param config 配置DTO + */ + private void verifyToken(String token, ThirdConfigBaseDTO config) { + config.setVerify(StringUtils.isNotBlank(token) && Strings.CI.equals(token, "true")); + } + + /** + * 根据配置类型获取详情类型列表 + * + * @param type 类型 + * @return 类型列表 + */ + private List getDetailTypes(String type) { + ThirdConfigTypeConstants typeConstants = ThirdConfigTypeConstants.fromString(type); + if (typeConstants == null) { + return Collections.emptyList(); + } + return switch (typeConstants) { + case WECOM -> List.of(ThirdDetailType.WECOM_SYNC.toString()); + case DINGTALK -> List.of(ThirdDetailType.DINGTALK_SYNC.name()); + case LARK -> List.of(ThirdDetailType.LARK_SYNC.name()); + case DE -> List.of(ThirdDetailType.DE_BOARD.name()); + case SQLBOT -> List.of( + ThirdDetailType.SQLBOT_CHAT.name(), + ThirdDetailType.SQLBOT_BOARD.name() + ); + case MAXKB -> List.of(ThirdDetailType.MAXKB.name()); + case TENDER -> List.of(ThirdDetailType.TENDER.name()); + case QCC -> List.of(ThirdDetailType.QCC.name()); + default -> Collections.emptyList(); + }; + } + + /** + * 获取类型启用状态映射 + * + * @param configDTO 配置DTO + * @return 类型启用映射 + */ + private Map getTypeEnableMap(ThirdConfigBaseDTO configDTO) { + Map map = new HashMap<>(); + ThirdConfigTypeConstants type = ThirdConfigTypeConstants.fromString(configDTO.getType()); + if (type == null) { + return Map.of(); + } + switch (type) { + case WECOM -> { + WecomThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), WecomThirdConfigRequest.class); + map.put(ThirdDetailType.WECOM_SYNC.name(), config.getStartEnable()); + } + case DINGTALK -> { + DingTalkThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), DingTalkThirdConfigRequest.class); + map.put(ThirdDetailType.DINGTALK_SYNC.name(), config.getStartEnable()); + } + case LARK -> { + LarkThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), LarkThirdConfigRequest.class); + map.put(ThirdDetailType.LARK_SYNC.name(), config.getStartEnable()); + } + case DE -> { + DeThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), DeThirdConfigRequest.class); + map.put(ThirdDetailType.DE_BOARD.name(), config.getDeBoardEnable()); + } + case SQLBOT -> { + SqlBotThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), SqlBotThirdConfigRequest.class); + map.put(ThirdDetailType.SQLBOT_CHAT.name(), config.getSqlBotChatEnable()); + map.put(ThirdDetailType.SQLBOT_BOARD.name(), config.getSqlBotBoardEnable()); + } + case MAXKB -> { + MaxKBThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), MaxKBThirdConfigRequest.class); + map.put(ThirdDetailType.MAXKB.name(), config.getMkEnable()); + } + case TENDER -> { + TenderThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), TenderThirdConfigRequest.class); + map.put(ThirdDetailType.TENDER.name(), config.getTenderEnable()); + } + case QCC -> { + QccThirdConfigRequest config = JSON.MAPPER.convertValue(configDTO.getConfig(), QccThirdConfigRequest.class); + map.put(ThirdDetailType.QCC.name(), config.getQccEnable()); + } + } + + return map; + } + + /** + * 获取Token + * + * @param configDTO 配置DTO + * @return Token + */ + private String getToken(ThirdConfigBaseDTO configDTO) { + ThirdConfigTypeConstants type = ThirdConfigTypeConstants.fromString(configDTO.getType()); + if (type == null) { + return null; + } + + switch (type) { + case WECOM -> { + WecomThirdConfigRequest weComConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), WecomThirdConfigRequest.class); + return tokenService.getAssessToken(weComConfig.getCorpId(), weComConfig.getAppSecret()); + } + case DINGTALK -> { + DingTalkThirdConfigRequest dingTalkConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), DingTalkThirdConfigRequest.class); + return tokenService.getDingTalkToken(dingTalkConfig.getAgentId(), dingTalkConfig.getAppSecret()); + } + case LARK -> { + LarkThirdConfigRequest larkConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), LarkThirdConfigRequest.class); + return tokenService.getLarkToken(larkConfig.getAgentId(), larkConfig.getAppSecret()); + } + case DE -> { + DeThirdConfigRequest deConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), DeThirdConfigRequest.class); + boolean verify = validDeConfig(deConfig); + return verify ? "true" : null; + } + case SQLBOT -> { + SqlBotThirdConfigRequest sqlBotConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), SqlBotThirdConfigRequest.class); + return tokenService.getSqlBotSrc(sqlBotConfig.getAppSecret()) ? "true" : null; + } + case MAXKB -> { + MaxKBThirdConfigRequest mkConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), MaxKBThirdConfigRequest.class); + return tokenService.getMaxKBToken(mkConfig.getMkAddress(), mkConfig.getAppSecret()) ? "true" : null; + } + case TENDER -> { + return tokenService.getTender() ? "true" : null; + } + case QCC -> { + QccThirdConfigRequest qccConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), QccThirdConfigRequest.class); + return tokenService.getQcc(qccConfig.getQccAddress(), qccConfig.getQccAccessKey(), qccConfig.getQccSecretKey()) ? "true" : null; + } + default -> { + return null; + } + } + } + + /** + * 验证De配置 + * + * @param configDTO 配置请求 + * @return 是否有效 + */ + private boolean validDeConfig(DeThirdConfigRequest configDTO) { + // 校验url + boolean verify = tokenService.pingDeUrl(configDTO.getRedirectUrl()); + DataEaseClient dataEaseClient = new DataEaseClient(configDTO); + if (StringUtils.isNotBlank(configDTO.getDeAccessKey()) + && StringUtils.isNotBlank(configDTO.getDeSecretKey()) + && StringUtils.isNotBlank(configDTO.getRedirectUrl())) { + // 校验 ak,sk + verify = verify && dataEaseClient.validate(); + } + return verify; + } + + /** + * 创建新的组织配置 + * + * @param organizationId 组织ID + * @param userId 用户ID + * @return 组织配置 + */ + private OrganizationConfig createNewOrganizationConfig(String organizationId, String userId) { + OrganizationConfig config = new OrganizationConfig(); + config.setId(IDGenerator.nextStr()); + config.setOrganizationId(organizationId); + config.setType(OrganizationConfigConstants.ConfigType.THIRD.name()); + config.setSyncResource(ThirdConfigTypeConstants.WECOM.name()); + config.setCreateTime(System.currentTimeMillis()); + config.setUpdateTime(System.currentTimeMillis()); + config.setCreateUser(userId); + config.setUpdateUser(userId); + organizationConfigBaseMapper.insert(config); + return config; + } + + /** + * 更新组织配置详情 + * + * @param jsonString JSON字符串 + * @param userId 用户ID + * @param detail 配置详情 + * @param enable 是否启用 + */ + private void updateOrganizationConfigDetail(String jsonString, String userId, OrganizationConfigDetail detail, Boolean enable) { + detail.setContent(jsonString.getBytes()); + detail.setUpdateTime(System.currentTimeMillis()); + detail.setUpdateUser(userId); + detail.setEnable(enable); + organizationConfigDetailBaseMapper.update(detail); + } + + /** + * 根据类型获取配置 + * + * @param type 类型 + * @param details 配置详情列表 + * @return 配置DTO + */ + private ThirdConfigBaseDTO getConfigurationByType(String type, List details) { + return getNormalConfiguration(type, details); + } + + /** + * 获取普通配置 + * + * @param type 类型 + * @param details 配置详情列表 + * @return 配置DTO + */ + private ThirdConfigBaseDTO getNormalConfiguration(String type, List details) { + ThirdConfigBaseDTO configDTO = getThirdConfigurationDTOByType(details, type); + if (configDTO == null) { + throw new GenericException(Translator.get("third.config.not.exist")); + } + + return configDTO; + } + + /** + * 获取选项DTO + * + * @param detail 配置详情 + * @return 选项DTO + */ + private OptionDTO getOptionDTO(OrganizationConfigDetail detail) { + OptionDTO option = new OptionDTO(); + String type = detail.getType(); + + if (type.contains(ThirdConfigTypeConstants.WECOM.name())) { + option.setId(ThirdConfigTypeConstants.WECOM.name()); + } else if (type.contains(ThirdConfigTypeConstants.DINGTALK.name())) { + option.setId(ThirdConfigTypeConstants.DINGTALK.name()); + } else if (type.contains(ThirdConfigTypeConstants.LARK.name())) { + option.setId(ThirdConfigTypeConstants.LARK.name()); + } + + option.setName(detail.getEnable().toString()); + return option; + } + + /** + * 获取启用状态 + * + * @param configDTO 配置DTO + * @return 是否启用 + */ + private boolean getEnable(ThirdConfigBaseDTO configDTO) { + ThirdConfigTypeConstants typeConstants = ThirdConfigTypeConstants.fromString(configDTO.getType()); + if (typeConstants == null) { + throw new GenericException("unsupported.third.type"); + } + switch (typeConstants) { + case WECOM -> { + WecomThirdConfigRequest weComConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), WecomThirdConfigRequest.class); + return weComConfig.getStartEnable(); + } + case DINGTALK -> { + DingTalkThirdConfigRequest dingTalkConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), DingTalkThirdConfigRequest.class); + return dingTalkConfig.getStartEnable(); + } + case LARK -> { + LarkThirdConfigRequest larkConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), LarkThirdConfigRequest.class); + return larkConfig.getStartEnable(); + } + default -> { + return false; + } + } + } + + /** + * 获取最近同步类型 + * + * @param id ID + * @return 同步类型 + */ + private String getLastSyncType(String id) { + OrganizationConfig organizationConfig = organizationConfigBaseMapper.selectByPrimaryKey(id); + if (organizationConfig != null && organizationConfig.isSync() && StringUtils.isNotBlank(organizationConfig.getSyncResource())) { + return organizationConfig.getSyncResource(); + } else { + return null; + } + } + + /** + * 同步CorpId + * + * @param detail 内容 + * @param configDTO 配置DTO + * @return 是否同步 + */ + private boolean syncCorpId(OrganizationConfigDetail detail, ThirdConfigBaseDTO configDTO) { + ThirdConfigTypeConstants typeConstants = ThirdConfigTypeConstants.fromString(configDTO.getType()); + if (typeConstants == null) { + throw new GenericException("unsupported.third.type"); + } + switch (typeConstants) { + case WECOM -> { + WecomThirdConfigRequest oldConfig = parseOldConfig(detail, WecomThirdConfigRequest.class); + WecomThirdConfigRequest weComConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), WecomThirdConfigRequest.class); + return !Strings.CI.equals(oldConfig.getCorpId(), weComConfig.getCorpId()); + } + case DINGTALK -> { + DingTalkThirdConfigRequest oldConfig = parseOldConfig(detail, DingTalkThirdConfigRequest.class); + DingTalkThirdConfigRequest dingTalkConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), DingTalkThirdConfigRequest.class); + return !Strings.CI.equals(oldConfig.getCorpId(), dingTalkConfig.getCorpId()); + } + case LARK -> { + LarkThirdConfigRequest oldConfig = parseOldConfig(detail, LarkThirdConfigRequest.class); + LarkThirdConfigRequest larkConfig = JSON.MAPPER.convertValue(configDTO.getConfig(), LarkThirdConfigRequest.class); + return !Strings.CI.equals(oldConfig.getCorpId(), larkConfig.getCorpId()); + } + default -> { + return false; + } + } + } + + public void handleData(ThirdConfigBaseDTO thirdConfig) { + if (thirdConfig.getType() == null) { + return; + } + ThirdConfigTypeConstants typeConstants = ThirdConfigTypeConstants.fromString(thirdConfig.getType()); + if (typeConstants == null) { + throw new GenericException("unsupported.third.type"); + } + switch (typeConstants) { + case WECOM -> { + WecomThirdConfigRequest weComConfig = JSON.MAPPER.convertValue(thirdConfig.getConfig(), WecomThirdConfigRequest.class); + weComConfig.setAppSecret(StringUtils.EMPTY); + ((ThirdConfigBaseDTO) thirdConfig).setConfig(weComConfig); + } + case DINGTALK -> { + DingTalkThirdConfigRequest dingTalkConfig = JSON.MAPPER.convertValue(thirdConfig.getConfig(), DingTalkThirdConfigRequest.class); + dingTalkConfig.setAppSecret(StringUtils.EMPTY); + ((ThirdConfigBaseDTO) thirdConfig).setConfig(dingTalkConfig); + } + case LARK -> { + LarkThirdConfigRequest larkConfig = JSON.MAPPER.convertValue(thirdConfig.getConfig(), LarkThirdConfigRequest.class); + larkConfig.setAppSecret(StringUtils.EMPTY); + ((ThirdConfigBaseDTO) thirdConfig).setConfig(larkConfig); + } + case DE -> { + DeThirdConfigRequest deConfig = JSON.MAPPER.convertValue(thirdConfig.getConfig(), DeThirdConfigRequest.class); + deConfig.setAppSecret(StringUtils.EMPTY); + deConfig.setDeSecretKey(StringUtils.EMPTY); + ((ThirdConfigBaseDTO) thirdConfig).setConfig(deConfig); + } + case MAXKB -> { + MaxKBThirdConfigRequest maxKBConfig = JSON.MAPPER.convertValue(thirdConfig.getConfig(), MaxKBThirdConfigRequest.class); + maxKBConfig.setAppSecret(StringUtils.EMPTY); + ((ThirdConfigBaseDTO) thirdConfig).setConfig(maxKBConfig); + } + case QCC -> { + QccThirdConfigRequest qccConfig = JSON.MAPPER.convertValue(thirdConfig.getConfig(), QccThirdConfigRequest.class); + qccConfig.setQccSecretKey(StringUtils.EMPTY); + ((ThirdConfigBaseDTO) thirdConfig).setConfig(qccConfig); + } + default -> { + + } + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/LicenseService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/LicenseService.java new file mode 100644 index 0000000..ce8532f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/LicenseService.java @@ -0,0 +1,122 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.constants.LicenseStatus; +import cn.cordys.crm.system.domain.License; +import cn.cordys.crm.system.dto.LicenseDTO; +import cn.cordys.crm.system.dto.log.LicenseLogDTO; +import cn.cordys.crm.system.mapper.ExtLicenseMapper; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@Transactional(rollbackFor = Exception.class) +public class LicenseService { + + @Resource + private BaseMapper licenseBaseMapper; + + @Resource + private ExtLicenseMapper extLicenseMapper; + + @Cacheable(value = "license_cache", key = "'CORDYS-LICENSE'", unless = "#result == null") + public LicenseDTO validate() { + var code = Optional.ofNullable(extLicenseMapper.get()) + .map(License::getLicenseCode) + .orElse(""); + return validate(code); + } + + @OperationLog(module = LogModule.SYSTEM, type = LogType.ADD) + @CachePut(value = "license_cache", key = "'CORDYS-LICENSE'", unless = "#result == null") + public LicenseDTO add(String code, String userId) { + LicenseDTO licenseDTO = validate(code); + + if (licenseDTO == null || StringUtils.isBlank(licenseDTO.getProduct())) { + throw new GenericException(Translator.get("license_valid_license_error")); + } + License license = extLicenseMapper.get(); + if (license == null) { + license = new License(); + license.setId(IDGenerator.nextStr()); + license.setLicenseCode(code); + license.setCreateTime(System.currentTimeMillis()); + license.setUpdateTime(System.currentTimeMillis()); + license.setCreateUser(userId); + license.setUpdateUser(userId); + licenseBaseMapper.insert(license); + LicenseLogDTO licenseLogDTO = new LicenseLogDTO(); + BeanUtils.copyBean(licenseLogDTO, license); + OperationLogContext.setContext( + LogContextInfo.builder() + .originalValue(null) + .resourceId(license.getId()) + .resourceName("license") + .modifiedValue(licenseLogDTO) + .build() + + + ); + } else { + LicenseLogDTO oldLogDTO = new LicenseLogDTO(); + BeanUtils.copyBean(oldLogDTO, license); + license.setLicenseCode(code); + license.setUpdateTime(System.currentTimeMillis()); + license.setUpdateUser(userId); + licenseBaseMapper.update(license); + LicenseLogDTO updateDTO = new LicenseLogDTO(); + BeanUtils.copyBean(updateDTO, license); + // 添加日志上下文 + OperationLogContext.setContext( + LogContextInfo.builder() + .originalValue(oldLogDTO) + .resourceId(license.getId()) + .resourceName("license") + .modifiedValue(updateDTO) + .build() + ); + } + return licenseDTO; + } + + public LicenseDTO validate(String code) { + boolean isValid = CommonBeanFactory.packageExists(); + LicenseDTO licenseDTO = new LicenseDTO(); + if (isValid && StringUtils.isNotBlank(code)) { + Object license = CommonBeanFactory.invoke("extLicenseService", + clazz -> { + try { + return clazz.getMethod("command", String.class); + } catch (NoSuchMethodException e) { + return null; + } + }, code); + if (license != null) { + return (LicenseDTO) license; + } + } + + if (isValid) { + licenseDTO.setStatus(LicenseStatus.INVALID.getName()); + } + + return licenseDTO; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/LocalLanguageService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/LocalLanguageService.java new file mode 100644 index 0000000..cef54de --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/LocalLanguageService.java @@ -0,0 +1,22 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.request.LocaleLanguageRequest; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +@Service +public class LocalLanguageService { + + @Resource + private BaseMapper userMapper; + + + public void localeLanguageChange(LocaleLanguageRequest request, String userId) { + User user = new User(); + user.setId(userId); + user.setLanguage(request.getLanguage()); + userMapper.update(user); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/LogService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/LogService.java new file mode 100644 index 0000000..0de3c4d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/LogService.java @@ -0,0 +1,183 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.aspectj.handler.OperationLogHandler; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.domain.OperationLog; +import cn.cordys.crm.system.domain.OperationLogBlob; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.security.SessionUtils; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +/** + * 操作日志服务类 + * 提供单条和批量操作日志的存储方法。 + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class LogService implements OperationLogHandler { + + @Resource + private BaseMapper operationLogMapper; + + @Resource + private BaseMapper operationLogBlobMapper; + + /** + * 根据 LogDTO 创建一个 OperationLogBlob 实体对象。 + * + * @param log 日志数据传输对象 + * + * @return OperationLogBlob + */ + private OperationLogBlob getBlob(LogDTO log) { + OperationLogBlob blob = new OperationLogBlob(); + blob.setId(log.getId()); + + LogContextInfo extra = OperationLogContext.getContext(); + + if (extra != null) { + if (ObjectUtils.isNotEmpty(extra.getOriginalValue())) { + blob.setOriginalValue(JSON.toJSONBytes(extra.getOriginalValue())); + } + if (ObjectUtils.isNotEmpty(extra.getModifiedValue())) { + blob.setModifiedValue(JSON.toJSONBytes(extra.getModifiedValue())); + } + } + + if (ObjectUtils.isNotEmpty(log.getOriginalValue())) { + blob.setOriginalValue(JSON.toJSONBytes(log.getOriginalValue())); + } + if (ObjectUtils.isNotEmpty(log.getModifiedValue())) { + blob.setModifiedValue(JSON.toJSONBytes(log.getModifiedValue())); + } + + return blob; + } + + /** + * 截断日志内容,确保其不超过500个字符。 + * + * @param content 日志内容 + * + * @return 截取后的日志内容 + */ + private String subStrContent(String content) { + if (StringUtils.isNotBlank(content) && content.length() > 500) { + return content.substring(0, 499); + } + return content; + } + + private String subStrResourceName(String name) { + if (StringUtils.isNotBlank(name) && name.length() > 255) { + return name.substring(0, 255); + } + return name; + } + + /** + * 添加单条操作日志 + * + * @param log 日志数据传输对象 + */ + @Async + public void add(LogDTO log) { + if (StringUtils.isBlank(log.getOrganizationId())) { + log.setOrganizationId(OrganizationContext.getOrganizationId()); + } + + // 如果创建用户为空,设置为“admin” + if (StringUtils.isBlank(log.getCreateUser())) { + if (StringUtils.isBlank(SessionUtils.getUserId())) { + log.setCreateUser(InternalUser.ADMIN.getValue()); + } else { + log.setCreateUser(SessionUtils.getUserId()); + } + } + + // 插入操作日志和日志Blob数据 + log.setId(IDGenerator.nextStr()); + + LogContextInfo extra = OperationLogContext.getContext(); + + if (extra != null) { + // 如果注解中没有设置 sourceId ,则使用 extra 中的 resourceId + if (StringUtils.isBlank(log.getResourceId()) && ObjectUtils.isNotEmpty(extra.getResourceId())) { + log.setResourceId(extra.getResourceId()); + } + + // 如果注解中没有设置 resourceName ,则使用 extra 中的 resourceName + if (StringUtils.isBlank(log.getResourceName()) && ObjectUtils.isNotEmpty(extra.getResourceName())) { + log.setResourceName(subStrResourceName(extra.getResourceName())); + } + } + + // 截断日志内容 + log.setDetail(subStrContent(log.getDetail())); + log.setMethod(StringUtils.defaultIfBlank(log.getMethod(), "GET")); + OperationLog operationLog = BeanUtils.copyBean(new OperationLog(), log); + operationLog.setResourceName(subStrResourceName(log.getResourceName())); + operationLog.setDetail(log.getDetail()); + operationLogMapper.insert(operationLog); + + OperationLogBlob blob = getBlob(log); + if (blob.getOriginalValue() != null || blob.getModifiedValue() != null) { + operationLogBlobMapper.insert(blob); + } + } + + /** + * 批量添加操作日志 + * + * @param logs 日志数据传输对象列表 + */ + @Async + public void batchAdd(List logs) { + // 如果日志列表为空,直接返回 + if (CollectionUtils.isEmpty(logs)) { + return; + } + + var currentTimeMillis = System.currentTimeMillis(); + List items = new ArrayList<>(); + // 使用流处理,构建操作日志和Blob列表 + var blobs = logs.stream() + .peek(log -> { + log.setId(IDGenerator.nextStr()); + log.setResourceName(subStrResourceName(log.getResourceName())); + log.setDetail(subStrContent(log.getDetail())); + log.setCreateTime(currentTimeMillis); + OperationLog item = new OperationLog(); + BeanUtils.copyBean(item, log); + item.setMethod(StringUtils.defaultIfBlank(log.getMethod(), "GET")); + items.add(item); + }) + .map(this::getBlob) + .filter(blob -> blob.getOriginalValue() != null || blob.getModifiedValue() != null) + .toList(); + // 批量插入操作日志和日志Blob数据 + operationLogMapper.batchInsert(items); + operationLogBlobMapper.batchInsert(blobs); + } + + @Override + public void handleLog(LogDTO operationLog) { + add(operationLog); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/MessageNotificationService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/MessageNotificationService.java new file mode 100644 index 0000000..4d5f89a --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/MessageNotificationService.java @@ -0,0 +1,490 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.domain.MessageTask; +import cn.cordys.crm.system.domain.MessageTaskConfig; +import cn.cordys.crm.system.dto.MessageTaskConfigDTO; +import cn.cordys.crm.system.dto.MessageTaskConfigWithNameDTO; +import cn.cordys.crm.system.dto.TimeDTO; +import cn.cordys.crm.system.dto.log.MessageTaskLogDTO; +import cn.cordys.crm.system.dto.request.MessageTaskBatchRequest; +import cn.cordys.crm.system.dto.request.MessageTaskRequest; +import cn.cordys.crm.system.dto.response.MessageTaskDTO; +import cn.cordys.crm.system.dto.response.MessageTaskDetailDTO; +import cn.cordys.crm.system.mapper.ExtMessageTaskMapper; +import cn.cordys.crm.system.mapper.ExtRoleMapper; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.crm.system.utils.MessageTemplateUtils; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class MessageNotificationService { + + @Resource + private ExtMessageTaskMapper extMessageTaskMapper; + @Resource + private BaseMapper messageTaskMapper; + @Resource + private BaseMapper messageTaskConfigMapper; + @Resource + private ExtUserMapper extUserMapper; + @Resource + private ExtRoleMapper extRoleMapper; + @Resource + private LogService logService; + + private List templateMessageTasks; + + private static final Map TIME_UNIT_MAP = Map.of( + "SECOND", "time.second", + "MINUTE", "time.minute", + "HOUR", "time.hour", + "DAY", "time.day", + "WEEK", "time.week", + "MONTH", "time.month", + "YEAR", "time.year" + ); + + @PostConstruct + public void init() { + this.templateMessageTasks = loadTemplateMessageTasks(); + } + + public MessageTask saveMessageTask(MessageTaskRequest request, String userId, String organizationId) { + + Map eventMap = MessageTemplateUtils.getEventMap(); + + MessageTask existTask = extMessageTaskMapper.getMessageByModuleAndEvent( + request.getModule(), request.getEvent(), organizationId); + + if (existTask != null) { + return updateMessageTasks(request, userId, existTask, eventMap, organizationId); + } + + MessageTask messageTask = buildMessageTask(request, organizationId, userId); + + String template = MessageTemplateUtils.getTemplate(request.getEvent()); + messageTask.setTemplate(template.getBytes(StandardCharsets.UTF_8)); + + messageTaskMapper.insert(messageTask); + saveConfigIfNeeded(request, organizationId); + + addLog( + organizationId, + messageTask.getId(), + userId, + eventMap.get(messageTask.getEvent()), + null, + buildLogDTO(messageTask, null) + ); + + return messageTask; + } + + public MessageTask updateMessageTasks( + MessageTaskRequest request, + String userId, + MessageTask oldTask, + Map eventMap, + String organizationId) { + + MessageTask updateTask = new MessageTask(); + + updateTask.setId(oldTask.getId()); + updateTask.setEmailEnable(request.isEmailEnable()); + updateTask.setSysEnable(request.isSysEnable()); + updateTask.setWeComEnable(request.isWeComEnable()); + updateTask.setDingTalkEnable(request.isDingTalkEnable()); + updateTask.setLarkEnable(request.isLarkEnable()); + updateTask.setUpdateUser(userId); + updateTask.setUpdateTime(System.currentTimeMillis()); + + messageTaskMapper.update(updateTask); + + MessageTaskConfigWithNameDTO oldConfig = + getMessageConfig(request.getModule(), request.getEvent(), organizationId); + + saveOrUpdateConfig(request, organizationId); + + MessageTaskConfigWithNameDTO newConfig = + buildLogMessageTaskConfigWithNameDTO(request.getConfig()); + + addLog( + organizationId, + oldTask.getId(), + userId, + eventMap.get(request.getEvent()), + buildLogDTO(oldTask, oldConfig), + buildLogDTO(updateTaskToLogTask(oldTask, request), newConfig) + ); + + return updateTask; + } + + public void batchSaveMessageTask(MessageTaskBatchRequest request, String organizationId, String userId) { + + List oldList = extMessageTaskMapper.getMessageTaskList(organizationId); + extMessageTaskMapper.updateMessageTask(request, organizationId); + + Map eventMap = MessageTemplateUtils.getEventMap(); + + List logs = oldList.stream() + .map(task -> buildBatchLog(request, organizationId, userId, task, eventMap)) + .collect(Collectors.toList()); + + logService.batchAdd(logs); + } + + public List getMessageList(String organizationId) { + + List result = + JSON.parseArray(JSON.toJSONString(templateMessageTasks), MessageTaskDTO.class); + + List messageTasks = extMessageTaskMapper.getMessageTaskList(organizationId); + + if (CollectionUtils.isEmpty(messageTasks)) return result; + + Map moduleMap = MessageTemplateUtils.getModuleMap(); + Map eventMap = MessageTemplateUtils.getEventMap(); + + Map messageMap = + messageTasks.stream().collect(Collectors.toMap(MessageTask::getEvent, Function.identity(), (a, b) -> b)); + + for (MessageTaskDTO dto : result) { + + dto.setModuleName(moduleMap.get(dto.getModule())); + + for (MessageTaskDetailDTO detail : dto.getMessageTaskDetailDTOList()) { + + detail.setEventName(eventMap.get(detail.getEvent())); + + MessageTask task = messageMap.get(detail.getEvent()); + if (task == null) continue; + + detail.setEmailEnable(task.getEmailEnable()); + detail.setSysEnable(task.getSysEnable()); + detail.setWeComEnable(task.getWeComEnable()); + detail.setDingTalkEnable(task.getDingTalkEnable()); + detail.setLarkEnable(task.getLarkEnable()); + } + } + + return result; + } + + @Transactional(readOnly = true) + public MessageTaskConfigWithNameDTO getMessageConfig(String module, String event, String organizationId) { + + List configs = + messageTaskConfigMapper.selectListByLambda( + new LambdaQueryWrapper() + .eq(MessageTaskConfig::getOrganizationId, organizationId) + .eq(MessageTaskConfig::getTaskType, module) + .eq(MessageTaskConfig::getEvent, event) + ); + + if (CollectionUtils.isEmpty(configs)) return null; + + MessageTaskConfig config = configs.getFirst(); + + MessageTaskConfigWithNameDTO dto = + JSON.parseObject(config.getValue(), MessageTaskConfigWithNameDTO.class); + + setUserNames(dto, dto.getUserIds(), dto.getRoleIds()); + + return dto; + } + + private MessageTask buildMessageTask(MessageTaskRequest request, String organizationId, String userId) { + + MessageTask task = new MessageTask(); + + task.setId(IDGenerator.nextStr()); + task.setOrganizationId(organizationId); + task.setTaskType(request.getModule()); + task.setEvent(request.getEvent()); + + task.setCreateUser(userId); + task.setCreateTime(System.currentTimeMillis()); + task.setUpdateUser(userId); + task.setUpdateTime(System.currentTimeMillis()); + + task.setEmailEnable(request.isEmailEnable()); + task.setSysEnable(request.isSysEnable()); + task.setWeComEnable(request.isWeComEnable()); + task.setDingTalkEnable(request.isDingTalkEnable()); + task.setLarkEnable(request.isLarkEnable()); + + return task; + } + + private void saveConfigIfNeeded(MessageTaskRequest request, String organizationId) { + if (request.getConfig() != null) saveConfig(request, organizationId); + } + + private void saveOrUpdateConfig(MessageTaskRequest request, String organizationId) { + + if (request.getConfig() == null) return; + + List configs = + messageTaskConfigMapper.selectListByLambda( + new LambdaQueryWrapper() + .eq(MessageTaskConfig::getOrganizationId, organizationId) + .eq(MessageTaskConfig::getTaskType, request.getModule()) + .eq(MessageTaskConfig::getEvent, request.getEvent()) + ); + + checkTimeList(request); + + if (CollectionUtils.isNotEmpty(configs)) { + MessageTaskConfig config = configs.getFirst(); + config.setValue(JSON.toJSONString(request.getConfig())); + messageTaskConfigMapper.update(config); + return; + } + + saveConfig(request, organizationId); + } + + private void saveConfig(MessageTaskRequest request, String organizationId) { + + checkTimeList(request); + + MessageTaskConfig config = new MessageTaskConfig(); + + config.setId(IDGenerator.nextStr()); + config.setOrganizationId(organizationId); + config.setTaskType(request.getModule()); + config.setEvent(request.getEvent()); + config.setValue(JSON.toJSONString(request.getConfig())); + + messageTaskConfigMapper.insert(config); + } + + private void checkTimeList(MessageTaskRequest request) { + + if (request.getConfig() == null || request.getConfig().getTimeList() == null) return; + + List timeList = request.getConfig().getTimeList().stream() + .filter(Objects::nonNull) + .filter(t -> t.getTimeValue() != null) + .collect(Collectors.toList()); + + request.getConfig().setTimeList(timeList); + } + + private MessageTaskConfigWithNameDTO buildLogMessageTaskConfigWithNameDTO(MessageTaskConfigDTO config) { + + if (config == null) return null; + + MessageTaskConfigWithNameDTO dto = new MessageTaskConfigWithNameDTO(); + + BeanUtils.copyBean(dto, config); + + setUserNames(dto, config.getUserIds(), config.getRoleIds()); + + return dto; + } + + private void setUserNames(MessageTaskConfigWithNameDTO dto, List userIds, List roleIds) { + + if (CollectionUtils.isNotEmpty(userIds)) { + + List users = extUserMapper.selectUserOptionByIds(userIds); + + if (userIds.contains("OWNER")) users.addFirst(new OptionDTO("OWNER", Translator.get("message.owner"))); + if (userIds.contains("CREATE_USER")) + users.addFirst(new OptionDTO("CREATE_USER", Translator.get("message.create_user"))); + + dto.setUserIdNames(users); + } + + if (CollectionUtils.isEmpty(roleIds)) return; + + Set internalRoleSet = new HashSet<>(extRoleMapper.getInternalRoleIds()); + + List internalRoles = roleIds.stream().filter(internalRoleSet::contains).toList(); + List externalRoles = roleIds.stream().filter(id -> !internalRoleSet.contains(id)).toList(); + + Map roleNameMap = new HashMap<>(); + + if (CollectionUtils.isNotEmpty(externalRoles)) { + extRoleMapper.getIdNameByIds(externalRoles) + .forEach(role -> roleNameMap.put(role.getId(), role.getName())); + } + + internalRoles.forEach(role -> + roleNameMap.put(role, Translator.get("role." + role, role))); + + List roleNames = roleIds.stream() + .map(id -> { + String name = roleNameMap.get(id); + return name == null ? null : new OptionDTO(id, name); + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + dto.setRoleIdNames(roleNames); + } + + private void addLog(String organizationId, String targetId, String userId, + String eventName, Object oldValue, Object newValue) { + + LogDTO logDTO = new LogDTO( + organizationId, + targetId, + userId, + LogType.UPDATE, + LogModule.SYSTEM_MESSAGE_MESSAGE, + eventName + ); + + logDTO.setOriginalValue(oldValue); + logDTO.setModifiedValue(newValue); + + logService.add(logDTO); + } + + private LogDTO buildBatchLog( + MessageTaskBatchRequest request, + String organizationId, + String userId, + MessageTask task, + Map eventMap) { + + MessageTaskLogDTO oldDTO = buildLogDTO(task, null); + + MessageTask newTask = new MessageTask(); + BeanUtils.copyBean(newTask, task); + + if (request.getEmailEnable() != null) newTask.setEmailEnable(request.getEmailEnable()); + if (request.getSysEnable() != null) newTask.setSysEnable(request.getSysEnable()); + if (request.getWeComEnable() != null) newTask.setWeComEnable(request.getWeComEnable()); + if (request.getDingTalkEnable() != null) newTask.setDingTalkEnable(request.getDingTalkEnable()); + if (request.getLarkEnable() != null) newTask.setLarkEnable(request.getLarkEnable()); + + MessageTaskLogDTO newDTO = buildLogDTO(newTask, null); + + LogDTO logDTO = new LogDTO( + organizationId, + task.getId(), + userId, + LogType.UPDATE, + LogModule.SYSTEM_MESSAGE_MESSAGE, + eventMap.get(task.getEvent()) + ); + + logDTO.setOriginalValue(oldDTO); + logDTO.setModifiedValue(newDTO); + + return logDTO; + } + + private static MessageTaskLogDTO buildLogDTO(MessageTask task, MessageTaskConfigWithNameDTO config) { + + MessageTaskLogDTO dto = new MessageTaskLogDTO(); + + dto.setEmailEnable(toEnableText(task.getEmailEnable())); + dto.setSysEnable(toEnableText(task.getSysEnable())); + dto.setWeComEnable(toEnableText(task.getWeComEnable())); + dto.setDingTalkEnable(toEnableText(task.getDingTalkEnable())); + dto.setLarkEnable(toEnableText(task.getLarkEnable())); + dto.setEvent(MessageTemplateUtils.getEventMap().get(task.getEvent())); + + if (config == null) return dto; + + dto.setOwnerEnable(toEnableText(config.isOwnerEnable())); + dto.setOwnerLevel(config.getOwnerLevel()); + dto.setRoleEnable(toEnableText(config.isRoleEnable())); + dto.setUserIdNames(extractNames(config.getUserIdNames())); + dto.setRoleIdNames(extractNames(config.getRoleIdNames())); + dto.setTimes(buildTimes(config.getTimeList())); + + return dto; + } + + private static String toEnableText(Boolean enable) { + return Boolean.TRUE.equals(enable) + ? Translator.get("log.enable.true") + : Translator.get("log.enable.false"); + } + + private static List extractNames(List options) { + if (CollectionUtils.isEmpty(options)) return Collections.emptyList(); + return options.stream().map(OptionDTO::getName).collect(Collectors.toList()); + } + + private static List buildTimes(List timeList) { + if (CollectionUtils.isEmpty(timeList)) return Collections.emptyList(); + return timeList.stream() + .map(MessageNotificationService::buildTimeText) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + private static String buildTimeText(TimeDTO dto) { + + String key = TIME_UNIT_MAP.get(dto.getTimeUnit().toUpperCase()); + if (key == null) return null; + + return dto.getTimeValue() + Translator.get(key); + } + + private MessageTask updateTaskToLogTask(MessageTask oldTask, MessageTaskRequest request) { + + MessageTask task = new MessageTask(); + + BeanUtils.copyBean(task, oldTask); + + task.setEmailEnable(request.isEmailEnable()); + task.setSysEnable(request.isSysEnable()); + task.setWeComEnable(request.isWeComEnable()); + task.setDingTalkEnable(request.isDingTalkEnable()); + task.setLarkEnable(request.isLarkEnable()); + + return task; + } + + private List loadTemplateMessageTasks() { + + StringBuilder json = new StringBuilder(); + + try (InputStream inputStream = getClass().getResourceAsStream("/task/message_task.json")) { + + Objects.requireNonNull(inputStream); + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + String line; + while ((line = reader.readLine()) != null) json.append(line); + } + + } catch (IOException e) { + throw new RuntimeException(e); + } + + return JSON.parseArray(json.toString(), MessageTaskDTO.class); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleFieldExtService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleFieldExtService.java new file mode 100644 index 0000000..834aa40 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleFieldExtService.java @@ -0,0 +1,361 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.util.JSON; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.domain.ModuleField; +import cn.cordys.crm.system.domain.ModuleFieldBlob; +import cn.cordys.crm.system.domain.ModuleForm; +import cn.cordys.crm.system.dto.field.DatasourceField; +import cn.cordys.crm.system.dto.field.DateTimeField; +import cn.cordys.crm.system.dto.field.FormulaField; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.HasOption; +import cn.cordys.crm.system.dto.field.base.OptionProp; +import cn.cordys.crm.system.dto.field.base.SubField; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 字段操作的扩展逻辑 + * @author song-cc-rock + */ +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class ModuleFieldExtService { + + @Resource + private BaseMapper formMapper; + @Resource + private BaseMapper fieldMapper; + @Resource + private BaseMapper fieldBlobMapper; + @Resource + private ModuleFormService moduleFormService; + + public static final String DEFAULT_OPTION_SOURCE = "custom"; + + /** + * 设置选项字段的默认选项来源 + */ + public void setDefaultOptionSource() { + List fieldBlobs = getOptionFieldsBlob(); + fieldBlobs.forEach(fb -> { + BaseField field = JSON.parseObject(fb.getProp(), BaseField.class); + if (field instanceof HasOption of) { + of.setOptionSource(DEFAULT_OPTION_SOURCE); + } + fb.setProp(JSON.toJSONString(field)); + fieldBlobMapper.updateById(fb); + }); + } + + /** + * 刷新回款计划表单字段位置 + */ + public void refreshPlanFieldPos() { + ModuleForm example = new ModuleForm(); + example.setFormKey(FormKey.CONTRACT_PAYMENT_PLAN.getKey()); + example.setOrganizationId(OrganizationContext.DEFAULT_ORGANIZATION_ID); + ModuleForm moduleForm = formMapper.selectOne(example); + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.eq(ModuleField::getFormId, moduleForm.getId()); + List fields = fieldMapper.selectListByLambda(lambdaQueryWrapper); + fields.forEach(field -> { + if (Strings.CS.equals(field.getInternalKey(), BusinessModuleField.CONTRACT_PAYMENT_PLAN_NAME.getKey())) { + field.setPos(1L); + } else { + field.setPos(field.getPos() + 1); + } + fieldMapper.updateById(field); + }); + } + /** + * 获取表单附件字段ID集合 + * @param formKey 表单Key + * @param orgId 组织ID + * @return 字段ID集合 + */ + public List getFieldIdsOfForm(String formKey, String orgId) { + List allFields = moduleFormService.getAllFields(formKey, orgId); + if (CollectionUtils.isEmpty(allFields)) { + return List.of(); + } + return allFields.stream().filter(BaseField::isAttachment) + .map(BaseField::getId) + .toList(); + } + + /** + * 获取字段选项 + * @param formKey 表单Key + * @param currentOrg 组织ID + * @param internalKey 字段内置Key + * @return 字段选项 + */ + public List getFieldOptions(String formKey, String currentOrg, String internalKey) { + ModuleFieldBlob blob = getFieldBlobByKey(formKey, currentOrg, internalKey); + if (blob == null) { + return new ArrayList<>(); + } + BaseField field = JSON.parseObject(blob.getProp(), BaseField.class); + if (field instanceof HasOption of) { + return of.getOptions(); + } + return new ArrayList<>(); + } + + /** + * 获取日期字段类型 + * @param formKey 表单Key + * @param currentOrg 组织ID + * @param internalKey 内部Key + * @return 日期字段类型 + */ + public String getDateFieldType(String formKey, String currentOrg, String internalKey) { + ModuleFieldBlob blob = getFieldBlobByKey(formKey, currentOrg, internalKey); + if (blob == null) { + return "datetime"; + } + DateTimeField dateField = JSON.parseObject(blob.getProp(), DateTimeField.class); + return dateField.getDateType(); + } + + /** + * 获取选项字段的扩展信息 + * @return 选项字段列表 + */ + private List getOptionFieldsBlob() { + LambdaQueryWrapper fieldWrapper = new LambdaQueryWrapper<>(); + fieldWrapper.in(ModuleField::getType, List.of(FieldType.SELECT.name(), FieldType.SELECT_MULTIPLE.name(), FieldType.RADIO.name(), FieldType.CHECKBOX.name())); + List fields = fieldMapper.selectListByLambda(fieldWrapper); + List fIds = fields.stream().map(ModuleField::getId).toList(); + return fieldBlobMapper.selectByIds(fIds); + } + + /** + * 根据字段Key获取额外信息 + * @param formKey 表单Key + * @param orgId 组织ID + * @param internalKey 字段内置Key + * @return 字段大文本 + */ + private ModuleFieldBlob getFieldBlobByKey(String formKey, String orgId, String internalKey) { + ModuleForm example = new ModuleForm(); + example.setFormKey(formKey); + example.setOrganizationId(orgId); + ModuleForm moduleForm = formMapper.selectOne(example); + ModuleField fieldExample = new ModuleField(); + fieldExample.setFormId(moduleForm.getId()); + fieldExample.setInternalKey(internalKey); + ModuleField moduleField = fieldMapper.selectOne(fieldExample); + if (moduleField == null) { + return null; + } + return fieldBlobMapper.selectByPrimaryKey(moduleField.getId()); + } + + /** + * 修改报价单产品字段的汇总列(兼容旧版本数据) + */ + public void modifySubProductSumColumn() { + LambdaQueryWrapper fieldWrapper = new LambdaQueryWrapper<>(); + fieldWrapper.like(ModuleField::getType, "SUB_"); + List fields = fieldMapper.selectListByLambda(fieldWrapper); + if (CollectionUtils.isEmpty(fields)) { + return; + } + List ids = fields.stream().map(ModuleField::getId).toList(); + List fbs = fieldBlobMapper.selectByIds(ids); + if (CollectionUtils.isEmpty(fbs)) { + return; + } + for (ModuleFieldBlob fb : fbs) { + SubField subField = JSON.parseObject(fb.getProp(), SubField.class); + if (subField == null || CollectionUtils.isEmpty(subField.getSumColumns())) { + continue; + } + List sumColumns = new ArrayList<>(); + subField.getSumColumns().forEach(col -> { + if (Strings.CS.equals(col, BusinessModuleField.QUOTATION_TOTAL_AMOUNT.getBusinessKey())) { + sumColumns.add("sumAmount"); + } else if (Strings.CS.contains(col, "_ref_")) { + sumColumns.add(col.split("ref_")[1]); + } else { + sumColumns.add(col); + } + }); + subField.setSumColumns(sumColumns); + fb.setProp(JSON.toJSONString(subField)); + fieldBlobMapper.updateById(fb); + } + } + + public void modifyInternalSubSumColumn() { + LambdaQueryWrapper fieldWrapper = new LambdaQueryWrapper<>(); + fieldWrapper.in(ModuleField::getInternalKey, List.of("contractProducts", "quotationProducts")); + List fields = fieldMapper.selectListByLambda(fieldWrapper); + List moduleFieldBlobs = fieldBlobMapper.selectByIds(fields.stream().map(ModuleField::getId).toList()); + for (ModuleFieldBlob moduleFieldBlob : moduleFieldBlobs) { + SubField subField = JSON.parseObject(moduleFieldBlob.getProp(), SubField.class); + if (subField == null || CollectionUtils.isEmpty(subField.getSumColumns())) { + return; + } + Map fieldKeyMap = subField.getSubFields().stream().filter(f -> StringUtils.isNotEmpty(f.getInternalKey())) + .collect(Collectors.toMap(BaseField::getInternalKey, BaseField::getId)); + List sumColumns = new ArrayList<>(); + subField.getSumColumns().forEach(col -> { + if (Strings.CS.equals(col, "sumAmount")) { + if (fieldKeyMap.containsKey("quotationAmount")) { + sumColumns.add(fieldKeyMap.get("quotationAmount")); + } else { + sumColumns.add(fieldKeyMap.get("contractProductSumAmount")); + } + } else if (Strings.CS.equals(col, "price")) { + sumColumns.add(fieldKeyMap.get("contractProductAmount")); + } else { + sumColumns.add(col); + } + }); + subField.setSumColumns(sumColumns); + moduleFieldBlob.setProp(JSON.toJSONString(subField)); + fieldBlobMapper.updateById(moduleFieldBlob); + } + } + + public void modifyInternalSubCalcFormula() { + LambdaQueryWrapper fieldWrapper = new LambdaQueryWrapper<>(); + fieldWrapper.in(ModuleField::getInternalKey, List.of("contractProducts", "quotationProducts")); + List fields = fieldMapper.selectListByLambda(fieldWrapper); + List moduleFieldBlobs = fieldBlobMapper.selectByIds(fields.stream().map(ModuleField::getId).toList()); + String quotationAmountId = null; + for (ModuleFieldBlob moduleFieldBlob : moduleFieldBlobs) { + SubField subField = JSON.parseObject(moduleFieldBlob.getProp(), SubField.class); + if (subField == null) { + return; + } + Map fieldKeyMap = subField.getSubFields().stream().filter(f -> StringUtils.isNotEmpty(f.getInternalKey())) + .collect(Collectors.toMap(BaseField::getInternalKey, BaseField::getId)); + if (fieldKeyMap.containsKey("quotationAmount")) { + quotationAmountId = fieldKeyMap.get("quotationAmount"); + } + subField.getSubFields().forEach(f -> { + if (f instanceof FormulaField formulaField && StringUtils.isNotEmpty(formulaField.getFormula())) { + if (fieldKeyMap.containsKey("quotationAmount")) { + formulaField.setFormula(formulaField.getFormula().replace("sumAmount", fieldKeyMap.get("quotationAmount"))); + } else if (fieldKeyMap.containsKey("contractProductAmount")){ + formulaField.setFormula(formulaField.getFormula().replace("price", fieldKeyMap.get("contractProductAmount"))); + } else if (fieldKeyMap.containsKey("contractProductSumAmount")) { + formulaField.setFormula(formulaField.getFormula().replace("sumAmount", fieldKeyMap.get("contractProductSumAmount"))); + } + + } + }); + moduleFieldBlob.setProp(JSON.toJSONString(subField)); + fieldBlobMapper.updateById(moduleFieldBlob); + } + + LambdaQueryWrapper totalFieldWrapper = new LambdaQueryWrapper<>(); + totalFieldWrapper.eq(ModuleField::getInternalKey, "quotationTotalAmount"); + List totalFields = fieldMapper.selectListByLambda(totalFieldWrapper); + ModuleFieldBlob moduleFieldBlob = fieldBlobMapper.selectByPrimaryKey(totalFields.getFirst().getId()); + FormulaField formulaField = JSON.parseObject(moduleFieldBlob.getProp(), FormulaField.class); + if (formulaField != null && StringUtils.isNotEmpty(formulaField.getFormula()) && StringUtils.isNotEmpty(quotationAmountId)) { + formulaField.setFormula(formulaField.getFormula().replace("sumAmount", quotationAmountId)); + } + moduleFieldBlob.setProp(JSON.toJSONString(formulaField)); + fieldBlobMapper.updateById(moduleFieldBlob); + } + + public void refreshFormulaOldReferencedId() { + List allForms = formMapper.selectAll(null); + allForms.forEach(form -> { + Map oldToNewMap = new HashMap<>(8); + + // 外层数据源 + LambdaQueryWrapper fieldWrapper = new LambdaQueryWrapper<>(); + fieldWrapper.eq(ModuleField::getType, FieldType.DATA_SOURCE.name()).eq(ModuleField::getFormId, form.getId()); + List sourceFields = fieldMapper.selectListByLambda(fieldWrapper); + List moduleFieldBlobs = fieldBlobMapper.selectByIds(sourceFields.stream().map(ModuleField::getId).toList()); + for (ModuleFieldBlob fieldBlob : moduleFieldBlobs) { + DatasourceField sourceField = JSON.parseObject(fieldBlob.getProp(), DatasourceField.class); + if (CollectionUtils.isEmpty(sourceField.getShowFields())) { + continue; + } + sourceField.getShowFields().forEach(sf -> oldToNewMap.put(sf, sourceField.getId() + "_ref_" + sf)); + } + + // 子表格数据源 + LambdaQueryWrapper subFieldWrapper = new LambdaQueryWrapper<>(); + subFieldWrapper.like(ModuleField::getType, "SUB_").eq(ModuleField::getFormId, form.getId()); + List subFields = fieldMapper.selectListByLambda(subFieldWrapper); + List subFieldBlobs = fieldBlobMapper.selectByIds(subFields.stream().map(ModuleField::getId).toList()); + for (ModuleFieldBlob fieldBlob : subFieldBlobs) { + SubField subField = JSON.parseObject(fieldBlob.getProp(), SubField.class); + if (CollectionUtils.isEmpty(subField.getSubFields())) { + continue; + } + subField.getSubFields().forEach(sbf -> { + if (sbf instanceof DatasourceField sourceField && CollectionUtils.isNotEmpty(sourceField.getShowFields())) { + sourceField.getShowFields().forEach(sf -> oldToNewMap.put(sf, sourceField.getId() + "_ref_" + sf)); + } + }); + } + + /* + * 替换公式字段中的旧引用ID (外层计算字段 & 子表格计算字段) + */ + LambdaQueryWrapper formulaFieldWrapper = new LambdaQueryWrapper<>(); + formulaFieldWrapper.eq(ModuleField::getType, FieldType.FORMULA.name()).eq(ModuleField::getFormId, form.getId()); + List formulaFields = fieldMapper.selectListByLambda(formulaFieldWrapper); + List formulaFieldBlobs = fieldBlobMapper.selectByIds(formulaFields.stream().map(ModuleField::getId).toList()); + for (ModuleFieldBlob fieldBlob : formulaFieldBlobs) { + FormulaField formulaField = JSON.parseObject(fieldBlob.getProp(), FormulaField.class); + if (StringUtils.isEmpty(formulaField.getFormula())) { + continue; + } + oldToNewMap.keySet().forEach(oldId -> { + if (formulaField.getFormula().contains(oldId)) { + formulaField.setFormula(formulaField.getFormula().replace(oldId, oldToNewMap.get(oldId))); + } + }); + fieldBlob.setProp(JSON.toJSONString(formulaField)); + fieldBlobMapper.updateById(fieldBlob); + } + + for (ModuleFieldBlob fieldBlob : subFieldBlobs) { + SubField subField = JSON.parseObject(fieldBlob.getProp(), SubField.class); + if (CollectionUtils.isEmpty(subField.getSubFields())) { + continue; + } + subField.getSubFields().forEach(sbf -> { + if (sbf instanceof FormulaField formulaField && StringUtils.isNotEmpty(formulaField.getFormula())) { + oldToNewMap.keySet().forEach(oldId -> { + if (formulaField.getFormula().contains(oldId)) { + formulaField.setFormula(formulaField.getFormula().replace(oldId, oldToNewMap.get(oldId))); + } + }); + } + }); + fieldBlob.setProp(JSON.toJSONString(subField)); + fieldBlobMapper.updateById(fieldBlob); + } + }); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleFieldService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleFieldService.java new file mode 100644 index 0000000..25e0d77 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleFieldService.java @@ -0,0 +1,435 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.mapper.CommonMapper; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.contract.service.BusinessTitleService; +import cn.cordys.crm.product.service.ProductPriceService; +import cn.cordys.crm.product.service.ProductService; +import cn.cordys.crm.system.constants.FieldSourceType; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.domain.ModuleField; +import cn.cordys.crm.system.domain.ModuleFieldBlob; +import cn.cordys.crm.system.dto.DatasourceRefDTO; +import cn.cordys.crm.system.dto.field.DatasourceField; +import cn.cordys.crm.system.dto.field.DateTimeField; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.SubField; +import cn.cordys.crm.system.dto.request.DatasourceRefQueryRequest; +import cn.cordys.crm.system.dto.request.FieldRepeatCheckRequest; +import cn.cordys.crm.system.dto.response.FieldRepeatCheckResponse; +import cn.cordys.crm.system.mapper.ExtModuleFieldMapper; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @author song-cc-rock + */ +@Service +@Slf4j +public class ModuleFieldService { + + /** + * 表单表格映射 + */ + private static final Map FORM_TABLE = new HashMap<>(8); + + private static final Map> SOURCE_REF_CLASS = new HashMap<>(); + + static { + FORM_TABLE.put(FormKey.CLUE.getKey(), "clue"); + FORM_TABLE.put(FormKey.CUSTOMER.getKey(), "customer"); + FORM_TABLE.put(FormKey.CONTACT.getKey(), "customer_contact"); + FORM_TABLE.put(FormKey.OPPORTUNITY.getKey(), "opportunity"); + FORM_TABLE.put(FormKey.QUOTATION.getKey(), "opportunity_quotation"); + FORM_TABLE.put(FormKey.PRODUCT.getKey(), "product"); + FORM_TABLE.put(FormKey.PRICE.getKey(), "product_price"); + FORM_TABLE.put(FormKey.FOLLOW_RECORD.getKey(), "follow_up_record"); + FORM_TABLE.put(FormKey.FOLLOW_PLAN.getKey(), "follow_up_plan"); + FORM_TABLE.put(FormKey.CONTRACT.getKey(), "contract"); + FORM_TABLE.put(FormKey.CONTRACT_PAYMENT_PLAN.getKey(), "contract_payment_plan"); + FORM_TABLE.put(FormKey.CONTRACT_PAYMENT_RECORD.getKey(), "contract_payment_record"); + FORM_TABLE.put(FormKey.INVOICE.getKey(), "contract_invoice"); + FORM_TABLE.put(FormKey.ORDER.getKey(), "sales_order"); + + SOURCE_REF_CLASS.put(FieldSourceType.PRODUCT.name(), ProductService.class); + SOURCE_REF_CLASS.put(FieldSourceType.PRICE.name(), ProductPriceService.class); + SOURCE_REF_CLASS.put(FieldSourceType.BUSINESS_TITLE.name(), BusinessTitleService.class); + } + + @Resource + private BaseMapper moduleFieldMapper; + @Resource + private DepartmentService departmentService; + @Resource + private BaseMapper moduleFieldBlobMapper; + @Resource + private ExtModuleFieldMapper extModuleFieldMapper; + @Resource + private CommonMapper commonMapper; + + /** + * 获取不带用户的信息的部门树 + * + * @return 部门树 + */ + public List getDeptTree(String orgId) { + return departmentService.getTree(orgId); + } + + /** + * 修改日期时间类型的字段部分属性 + */ + public void modifyDateProp() { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(ModuleField::getType, FieldType.DATE_TIME.name()); + List moduleFields = moduleFieldMapper.selectListByLambda(queryWrapper); + List fieldIds = moduleFields.stream().map(ModuleField::getId).toList(); + if (CollectionUtils.isNotEmpty(fieldIds)) { + LambdaQueryWrapper blobWrapper = new LambdaQueryWrapper<>(); + blobWrapper.in(ModuleFieldBlob::getId, fieldIds); + List moduleFieldBlobs = moduleFieldBlobMapper.selectListByLambda(blobWrapper); + for (ModuleFieldBlob blob : moduleFieldBlobs) { + DateTimeField dateTimeField = JSON.parseObject(blob.getProp(), DateTimeField.class); + if (StringUtils.isEmpty(dateTimeField.getDateDefaultType())) { + dateTimeField.setDateDefaultType("custom"); + } + blob.setProp(JSON.toJSONString(dateTimeField)); + moduleFieldBlobMapper.updateById(blob); + } + } + } + + /** + * 校验字段值是否唯一 + * + * @param request 请求参数 + * @return 是否唯一 + */ + public FieldRepeatCheckResponse checkRepeat(FieldRepeatCheckRequest request, String currentOrg) { + ModuleField field = moduleFieldMapper.selectByPrimaryKey(request.getId()); + if (field == null) { + throw new GenericException(Translator.get("module.field.not_exist")); + } + String tableName = FORM_TABLE.get(request.getFormKey()); + if (StringUtils.isBlank(tableName)) { + tableName = "custom_form_data"; + } + String value = request.getValue(); + if (Strings.CI.equals(field.getType(), FieldType.PHONE.toString())) { + value = StringUtils.deleteWhitespace(value); + } + + BusinessModuleField businessField = BusinessModuleField.ofKey(field.getInternalKey()); + String repeatName; + if (businessField != null) { + // 业务字段 + repeatName = commonMapper.checkInternalRepeatName(tableName, businessField.getBusinessKey(), value, currentOrg); + } else { + repeatName = commonMapper.checkFieldRepeatName(tableName, tableName + "_field", request.getId(), value, currentOrg); + } + return FieldRepeatCheckResponse.builder().name(repeatName).repeat(StringUtils.isNotBlank(repeatName)).build(); + } + + @SuppressWarnings("unchecked") + public List getSourceRefDetail(DatasourceRefQueryRequest request) { + List sourceIds = request.getSourceIds().stream().distinct().toList(); + Class sourceClass = SOURCE_REF_CLASS.get(request.getDataSourceType()); + if (sourceClass == null) { + return new ArrayList<>(); + } + + Object service = CommonBeanFactory.getBean(sourceClass); + List result = new ArrayList<>(); + try { + Method executeMethod = sourceClass.getMethod("get", String.class); + for (String id : sourceIds) { + Object res = executeMethod.invoke(service, id); + if (res == null) { + continue; + } + DatasourceRefDTO dto = new DatasourceRefDTO(); + if (Strings.CI.equals(request.getDataSourceType(), FieldSourceType.BUSINESS_TITLE.name())) { + BeanUtils.copyBean(dto, res); + } else { + dto.setId(id); + dto.setName(getField(res, "name", String.class)); + dto.setModuleFields((List) getField(res, "moduleFields", List.class)); + dto.setProducts((List>) getField(res, "products", List.class)); + dto.setOptionMap((Map>) getField(res, "optionMap", Map.class)); + dto.setStatus(getField(res, "status", String.class)); + + if (Strings.CI.equals(request.getDataSourceType(), FieldSourceType.PRODUCT.name())) { + // 产品需要获取系统价格字段 + dto.setPrice(getField(res, "price", BigDecimal.class)); + } + } + result.add(dto); + } + } catch (Exception e) { + log.error("获取数据源引用详细失败, {}", e.getMessage()); + return new ArrayList<>(); + } + + return result; + } + + public void modifyInvoiceShowFields() { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(ModuleField::getInternalKey, BusinessModuleField.INVOICE_CONTRACT_ID.getKey()); + ModuleField example = new ModuleField(); + example.setInternalKey(BusinessModuleField.INVOICE_CONTRACT_ID.getKey()); + ModuleField contractField = moduleFieldMapper.selectOne(example); + if (contractField != null) { + ModuleFieldBlob contractFieldBlob = moduleFieldBlobMapper.selectByPrimaryKey(contractField.getId()); + String prop = contractFieldBlob.getProp(); + DatasourceField datasourceField = JSON.parseObject(prop, DatasourceField.class); + List showFields = datasourceField.getShowFields(); + if (CollectionUtils.isNotEmpty(showFields)) { + for (int i = 0; i < showFields.size(); i++) { + // 合同总金额字段初始化之后,显示字段替换成字段ID + if (Strings.CI.equalsAny(showFields.get(i), + "contractProductSumAmount", + BusinessModuleField.CONTRACT_TOTAL_AMOUNT.getKey())) { + ModuleField contractNameField = selectFieldByInternalKey(BusinessModuleField.CONTRACT_TOTAL_AMOUNT.getKey()); + if (contractNameField != null) { + showFields.set(i, contractNameField.getId()); + contractFieldBlob.setProp(JSON.toJSONString(datasourceField)); + moduleFieldBlobMapper.updateById(contractFieldBlob); + break; + } + } + } + } + } + } + + public List selectFieldsByInternalKeys(List internalKeys) { + return moduleFieldMapper.selectListByLambda(new LambdaQueryWrapper() + .in(ModuleField::getInternalKey, internalKeys)); + } + + public ModuleField selectFieldByInternalKey(String internalKey) { + ModuleField field = new ModuleField(); + field.setInternalKey(internalKey); + return moduleFieldMapper.selectOne(field); + } + + @SuppressWarnings("unchecked") + private T getField(Object obj, String fieldName, Class type) { + Class clazz = obj.getClass(); + + while (clazz != null) { + try { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + + Object value = field.get(obj); + if (type.isInstance(value)) { + return (T) value; + } + + return null; + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } catch (IllegalAccessException e) { + return null; + } + } + + return null; + } + + /** + * 订单模块字段初始化 + */ + public void initOrderFields() { + initOrderContractFilter(); + initOrderProducts(); + } + + /** + * 订单产品字段增加金额计算公式,订单增加订单金额计算公式 + */ + @SuppressWarnings("unchecked") + private void initOrderProducts() { + ModuleField orderProducts = selectFieldByInternalKey("orderProducts"); + ModuleFieldBlob orderProductsFieldBlob = moduleFieldBlobMapper.selectByPrimaryKey(orderProducts.getId()); + Map orderProductsField = JSON.parseMap(orderProductsFieldBlob.getProp()); + + List> subFields = (List>) orderProductsField.get("subFields"); + Map orderProductAmount = new HashMap<>(); + String orderProductPriceId = null; + String orderProductNumberId = null; + for (Map subField : subFields) { + String subFieldInternalKey = (String) subField.get("internalKey"); + if (Strings.CI.equals(subFieldInternalKey, "orderProductPrice")) { + orderProductPriceId = (String) subField.get("id"); + } + if (Strings.CI.equals(subFieldInternalKey, "orderProductNumber")) { + orderProductNumberId = (String) subField.get("id"); + } + if (Strings.CI.equals(subFieldInternalKey, "orderProductAmount")) { + orderProductAmount = subField; + } + } + orderProductAmount.put("formula", getOrderProductAmountFormula(orderProductPriceId, orderProductNumberId)); + + orderProductsFieldBlob.setProp(JSON.toJSONString(orderProductsField)); + moduleFieldBlobMapper.updateById(orderProductsFieldBlob); + + ModuleField orderAmount = selectFieldByInternalKey("orderAmount"); + String orderAmountFormulaId = orderProductsField.get("id").toString() + "." + orderProductAmount.get("id").toString(); + ModuleFieldBlob orderAmountFieldBlob = moduleFieldBlobMapper.selectByPrimaryKey(orderAmount.getId()); + Map orderAmountField = JSON.parseMap(orderAmountFieldBlob.getProp()); + orderAmountField.put("formula", getOrderAmountFormula(orderAmountFormulaId)); + orderAmountFieldBlob.setProp(JSON.toJSONString(orderAmountField)); + moduleFieldBlobMapper.updateById(orderAmountFieldBlob); + } + + private String getOrderAmountFormula(String orderAmountFormulaId) { + return String.format(""" + { + "source": "SUM(${%s})", + "display": "SUM(产品明细.金额)", + "fields": [ + { + "fieldId": "%s", + "fieldType": "FORMULA", + "numberType": "number" + } + ], + "ir": { + "type": "function", + "name": "SUM", + "args": [ + { + "type": "field", + "fieldId": "%s", + "name": "产品明细.金额", + "fieldType": "FORMULA", + "numberType": "number", + "startTokenIndex": 2, + "endTokenIndex": 2 + } + ] + } + }""", orderAmountFormulaId, orderAmountFormulaId, orderAmountFormulaId); + } + + private String getOrderProductAmountFormula(String orderProductPriceId, String orderProductNumberId) { + return String.format(""" + { + "source": "${%s} * ${%s}", + "display": "产品单价 * 数量", + "fields": [ + { + "fieldId": "%s", + "fieldType": "INPUT_NUMBER", + "numberType": "number" + }, + { + "fieldId": "%s", + "fieldType": "INPUT_NUMBER", + "numberType": "number" + } + ], + "ir": { + "type": "binary", + "operator": "*", + "left": { + "type": "field", + "fieldId": "%s", + "name": "产品单价", + "fieldType": "INPUT_NUMBER", + "numberType": "number", + "startTokenIndex": 0, + "endTokenIndex": 0 + }, + "right": { + "type": "field", + "fieldId": "%s", + "name": "数量", + "fieldType": "INPUT_NUMBER", + "numberType": "number", + "startTokenIndex": 2, + "endTokenIndex": 2 + } + } + }""", orderProductPriceId, orderProductNumberId, orderProductPriceId, orderProductNumberId, orderProductPriceId, orderProductNumberId); + } + + /** + * 订单合同字段增加订单客户过滤条件 + */ + private void initOrderContractFilter() { + ModuleField orderContract = selectFieldByInternalKey(BusinessModuleField.ORDER_CONTRACT.getKey()); + ModuleField orderCustomer = selectFieldByInternalKey(BusinessModuleField.ORDER_CUSTOMER.getKey()); + ModuleFieldBlob orderContractFieldBlob = moduleFieldBlobMapper.selectByPrimaryKey(orderContract.getId()); + DatasourceField datasourceField = JSON.parseObject(orderContractFieldBlob.getProp(), DatasourceField.class); + String customerConditionJson = String.format(""" + { + "searchMode": "OR", + "conditions": [ + { + "leftFieldId": "customerId", + "leftFieldType": "DATA_SOURCE", + "operator": "IN", + "rightFieldId": "%s", + "rightFieldCustom": false, + "rightFieldCustomValue": "", + "rightFieldType": "INPUT" + } + ] + }""", orderCustomer.getId()); + datasourceField.setCombineSearch(JSON.parseToMap(customerConditionJson)); + orderContractFieldBlob.setProp(JSON.toJSONString(datasourceField)); + moduleFieldBlobMapper.updateById(orderContractFieldBlob); + } + + /** + * 获取指定数据源的子表字段集合 (目前只有价格表类型的子表字段支持引用) + * + * @param sourceType 数据源类型 + * @return 子表字段集合 + */ + @Cacheable(value = "sub_fields_cache", key = "#sourceType", unless = "#result == null") + public List getSubFieldsBySourceType(String sourceType) { + List subFields = new ArrayList<>(); + if (Strings.CS.equals(sourceType, FieldSourceType.PRICE.name())) { + subFields = extModuleFieldMapper.getFormSubFields(FormKey.PRICE.getKey()); + } + if (CollectionUtils.isEmpty(subFields)) { + return new ArrayList<>(); + } + List subFieldList = new ArrayList<>(subFields.size()); + for (ModuleFieldBlob fieldBlob : subFields) { + SubField subField = JSON.parseObject(fieldBlob.getProp(), SubField.class); + subFieldList.addAll(subField.getSubFields()); + } + return subFieldList; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleFormCacheService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleFormCacheService.java new file mode 100644 index 0000000..29f156b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleFormCacheService.java @@ -0,0 +1,99 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.crm.system.constants.FieldSourceType; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.request.ModuleFormSaveRequest; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import jakarta.annotation.Resource; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * @author song-cc-rock + */ +@Service +public class ModuleFormCacheService { + + @Resource + private ModuleFormService moduleFormService; + @Resource + private ModuleFieldService moduleFieldService; + + /** + * 保存表单配置并更新缓存 + * + * @param saveParam 保存参数 + * @param currentUserId 当前用户ID + * + * @return 表单配置 + */ + @CachePut(value = "form_cache", key = "#currentOrgId + ':' + #saveParam.formKey", unless = "#result == null") + @CacheEvict(value = "field_cache", key = "#currentOrgId + ':' + #saveParam.formKey") + public ModuleFormConfigDTO save(ModuleFormSaveRequest saveParam, String currentUserId, String currentOrgId) { + return moduleFormService.save(saveParam, currentUserId, currentOrgId); + } + + /** + * 保存表单配置并更新缓存 + * + * @param saveParam 保存参数 + * @param currentUserId 当前用户ID + * + * @return 表单配置 + */ + @CachePut(value = "form_cache", key = "#currentOrgId + ':' + #saveParam.formKey", unless = "#result == null") + @CacheEvict(value = "field_cache", key = "#currentOrgId + ':' + #saveParam.formKey") + public ModuleFormConfigDTO saveWithoutLog(ModuleFormSaveRequest saveParam, String currentUserId, String currentOrgId) { + return moduleFormService.saveWithoutLog(saveParam, currentUserId, currentOrgId); + } + + /** + * 获取表单配置(缓存) + * + * @param formKey 表单Key + * @param currentOrgId 当前组织ID + * + * @return 表单配置 + */ + @Cacheable(value = "form_cache", key = "#currentOrgId + ':' + #formKey", unless = "#result == null") + public ModuleFormConfigDTO getConfig(String formKey, String currentOrgId) { + return moduleFormService.getConfig(formKey, currentOrgId); + } + + /** + * 获取业务表单配置 + * + * @param formKey 表单Key + * @param organizationId 组织ID + * + * @return 表单配置 + */ + public ModuleFormConfigDTO getBusinessFormConfig(String formKey, String organizationId) { + ModuleFormConfigDTO config = Objects.requireNonNull(CommonBeanFactory.getBean(this.getClass())).getConfig(formKey, organizationId); + ModuleFormConfigDTO businessModuleFormConfig = new ModuleFormConfigDTO(); + businessModuleFormConfig.setFormProp(config.getFormProp()); + + // 提前加载价格表子表格字段作为引用集合 + List subFields = moduleFieldService.getSubFieldsBySourceType(FieldSourceType.PRICE.name()); + Map refPriceSubFieldMap = subFields.stream().collect(Collectors.toMap(BaseField::getId, Function.identity(), (p, n) -> p)); + + // 处理字段信息 + List flattenFields = moduleFormService.flattenSourceRefFields(config.getFields(), refPriceSubFieldMap); + businessModuleFormConfig.setFields(flattenFields.stream() + .peek(moduleFormService::setFieldRefOption) + .peek(moduleFormService::setFieldBusinessParam) + .peek(field -> moduleFormService.reloadPropOfSubRefFields(field, refPriceSubFieldMap)) + .collect(Collectors.toList()) + ); + return businessModuleFormConfig; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleFormService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleFormService.java new file mode 100644 index 0000000..9a1dfd8 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleFormService.java @@ -0,0 +1,2430 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.LinkScenarioKey; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.domain.BaseResourceField; +import cn.cordys.common.dto.ExportHeadDTO; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.resolver.field.AbstractModuleFieldResolver; +import cn.cordys.common.resolver.field.ModuleFieldResolverFactory; +import cn.cordys.common.resolver.field.TextMultipleResolver; +import cn.cordys.common.resolver.field.TextResolver; +import cn.cordys.common.service.BaseResourceFieldService; +import cn.cordys.common.service.FieldSourceServiceProvider; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.uid.SerialNumGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.contract.constants.BusinessTitleConstants; +import cn.cordys.crm.contract.constants.SystemFieldConstants; +import cn.cordys.crm.form.service.CustomFormDataFieldService; +import cn.cordys.crm.system.constants.FieldSourceType; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.domain.*; +import cn.cordys.crm.system.dto.TransformSourceApplyDTO; +import cn.cordys.crm.system.dto.field.*; +import cn.cordys.crm.system.dto.field.base.*; +import cn.cordys.crm.system.dto.form.FormLinkFill; +import cn.cordys.crm.system.dto.form.FormProp; +import cn.cordys.crm.system.dto.form.base.LinkField; +import cn.cordys.crm.system.dto.form.base.LinkScenario; +import cn.cordys.crm.system.dto.request.ModuleFormSaveRequest; +import cn.cordys.crm.system.dto.response.FormPropLogDTO; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.dto.response.ModuleFormConfigLogDTO; +import cn.cordys.crm.system.mapper.ExtModuleFieldMapper; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.ListUtils; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.data.util.ReflectionUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * @author song-cc-rock + */ +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class ModuleFormService { + + public static final Map TYPE_SOURCE_MAP; + private static final String DEFAULT_ORGANIZATION_ID = "100001"; + private static final String CONTROL_RULES_KEY = "showControlRules"; + private static final String SHOW_FIELD_KEY = "showFields"; + private static final String SUB_FIELDS = "subFields"; + public static final String SUM_PREFIX = "sum_"; + public static final String EXPORT_SYSTEM_TYPE = "system"; + private static final String PRICE_SUB_ROW_KEY = "price_sub"; + private static final String OPTION_DEFAULT_SOURCE = "custom"; + private static final String UPGRADE_EXT_FIELD = "ext_ver"; + public static final String SLASH = "/"; + private static final String REF_UNDERLINE = "_ref_"; + + static { + TYPE_SOURCE_MAP = Map.ofEntries( + Map.entry(FieldType.MEMBER.name(), "sys_user"), + Map.entry(FieldType.DEPARTMENT.name(), "sys_department"), + Map.entry(FieldSourceType.CUSTOMER.name(), "customer"), + Map.entry(FieldSourceType.CLUE.name(), "clue"), + Map.entry(FieldSourceType.CONTACT.name(), "customer_contact"), + Map.entry(FieldSourceType.OPPORTUNITY.name(), "opportunity"), + Map.entry(FieldSourceType.PRODUCT.name(), "product"), + Map.entry(FieldSourceType.QUOTATION.name(), "opportunity_quotation"), + Map.entry(FieldSourceType.PRICE.name(), "product_price"), + Map.entry(FieldSourceType.CONTRACT.name(), "contract"), + Map.entry(FieldSourceType.PAYMENT_PLAN.name(), "contract_payment_plan"), + Map.entry(FieldSourceType.BUSINESS_TITLE.name(), "business_title"), + Map.entry(FieldSourceType.CONTRACT_PAYMENT_RECORD.name(), "contract_payment_record"), + Map.entry(FieldSourceType.ORDER.name(), "sales_order") + ); + } + + @Value("classpath:form/form.json") + private org.springframework.core.io.Resource formResource; + @Value("classpath:form/field.json") + private org.springframework.core.io.Resource fieldResource; + @Resource + private BaseMapper moduleFormMapper; + @Resource + private BaseMapper moduleFormBlobMapper; + @Resource + private BaseMapper moduleFieldMapper; + @Resource + private BaseMapper moduleFieldBlobMapper; + @Resource + private ExtModuleFieldMapper extModuleFieldMapper; + @Resource + private UserExtendService userExtendService; + @Resource + private DepartmentService departmentService; + @Resource + private BaseMapper attachmentMapper; + @Resource + private SerialNumGenerator serialNumGenerator; + @Lazy + @Resource + private FieldSourceServiceProvider fieldSourceServiceProvider; + @Resource + private ModuleFieldService moduleFieldService; + + /** + * 获取模块表单配置 + * + * @param formKey 表单Key + * @param currentOrgId 当前组织ID + * @return 字段集合 + */ + public ModuleFormConfigDTO getConfig(String formKey, String currentOrgId) { + ModuleFormConfigDTO formConfig = new ModuleFormConfigDTO(); + // set form + ModuleForm form = getModuleFormByKey(formKey, currentOrgId); + + ModuleFormBlob formBlob = moduleFormBlobMapper.selectByPrimaryKey(form.getId()); + formConfig.setFormProp(JSON.parseObject(formBlob.getProp(), FormProp.class)); + // set fields + formConfig.setFields(getAllFields(form.getId())); + return formConfig; + } + + /** + * 获取业务表单配置 + * + * @param formKey 表单Key + * @param organizationId 组织ID + * @return 表单配置 + */ + public ModuleFormConfigDTO getBusinessFormConfig(String formKey, String organizationId) { + ModuleFormConfigDTO config = getConfig(formKey, organizationId); + ModuleFormConfigDTO businessModuleFormConfig = new ModuleFormConfigDTO(); + businessModuleFormConfig.setFormProp(config.getFormProp()); + + // 提前加载价格表子表格字段作为引用集合 + List subFields = moduleFieldService.getSubFieldsBySourceType(FieldSourceType.PRICE.name()); + Map refPriceSubFieldMap = subFields.stream().collect(Collectors.toMap(BaseField::getId, Function.identity(), (p, n) -> p)); + + // 设置业务字段参数 + List flattenFields = flattenSourceRefFields(config.getFields(), refPriceSubFieldMap); + businessModuleFormConfig.setFields(flattenFields.stream() + .peek(this::setFieldRefOption) + .peek(this::setFieldBusinessParam) + .peek(field -> reloadPropOfSubRefFields(field, refPriceSubFieldMap)) + .collect(Collectors.toList()) + ); + return businessModuleFormConfig; + } + + public ModuleFormConfigDTO getSourceDisplayFields(String formKey, String organizationId) { + ModuleFormConfigDTO config = getConfig(formKey, organizationId); + ModuleFormConfigDTO businessModuleFormConfig = new ModuleFormConfigDTO(); + businessModuleFormConfig.setFormProp(config.getFormProp()); + + List flattenFields = flattenFormAllFieldsWithSubId(config); + // 设置业务字段参数 + businessModuleFormConfig.setFields(flattenFields.stream() + .filter(BaseField::canDisplay) + .filter(f -> StringUtils.isEmpty(f.getResourceFieldId())) + .peek(this::setFieldBusinessParam) + .collect(Collectors.toList()) + ); + return businessModuleFormConfig; + } + + /** + * 保存表单配置 + * + * @param saveParam 保存参数 + * @return 表单配置 + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE, resourceId = "{#saveParam.formKey}") + public ModuleFormConfigDTO save(ModuleFormSaveRequest saveParam, String currentUserId, String currentOrgId) { + // 设置表单日志上下文 + OperationLogContext.setContext(getModuleFormChangeLogContext(saveParam.getFormKey(), currentOrgId, saveParam)); + // 返回表单配置 + return saveWithoutLog(saveParam, currentUserId, currentOrgId); + } + + /** + * 保存表单配置(不记录日志) + * + * @param saveParam 保存参数 + * @param currentUserId 当前用户ID + * @param currentOrgId 当前组织ID + * @return 表单配置 + */ + public ModuleFormConfigDTO saveWithoutLog(ModuleFormSaveRequest saveParam, String currentUserId, String currentOrgId) { + // 处理表单 + ModuleForm form = getModuleFormByKey(saveParam.getFormKey(), currentOrgId); + form.setUpdateUser(currentUserId); + form.setUpdateTime(System.currentTimeMillis()); + moduleFormMapper.updateById(form); + + if (saveParam.getFormProp() != null) { + ModuleFormBlob formBlob = new ModuleFormBlob(); + formBlob.setId(form.getId()); + formBlob.setProp(JSON.toJSONString(saveParam.getFormProp())); + moduleFormBlobMapper.updateById(formBlob); + } + + if (saveParam.getFields() != null) { + // 字段合规校验 + preCheckForFieldSave(saveParam.getFormKey(), saveParam.getFields()); + + // 处理字段 (删除&&新增) + LambdaQueryWrapper fieldWrapper = new LambdaQueryWrapper<>(); + fieldWrapper.eq(ModuleField::getFormId, form.getId()); + List fields = moduleFieldMapper.selectListByLambda(fieldWrapper); + // 重置流水号 + resetSerial(fields, saveParam.getFields(), saveParam.getFormKey(), currentOrgId); + if (CollectionUtils.isNotEmpty(fields)) { + List fieldIds = fields.stream().map(ModuleField::getId).toList(); + extModuleFieldMapper.deleteByIds(fieldIds); + extModuleFieldMapper.deletePropByIds(fieldIds); + } + if (CollectionUtils.isNotEmpty(saveParam.getFields())) { + saveFields(saveParam.getFields(), form.getId(), currentUserId); + } + } + + // 返回表单配置 + return getConfig(form.getFormKey(), currentOrgId); + } + + /** + * 获取字段变更日志信息(供外部调用方合并日志使用) + * + * @param formKey 表单Key + * @param currentOrgId 当前组织ID + * @param saveParam 保存参数 + * @return 字段变更日志信息 + */ + public LogContextInfo getModuleFormChangeLogContext(String formKey, String currentOrgId, ModuleFormSaveRequest saveParam) { + ModuleForm form = getModuleFormByKey(formKey, currentOrgId); + ModuleFormConfigDTO oldConfig = getModuleFormConfigDTO(formKey, form.getId(), currentOrgId); + + ModuleFormConfigDTO newConfig = new ModuleFormConfigDTO(); + newConfig.setFields(saveParam.getFields()); + newConfig.setFormProp(saveParam.getFormProp()); + + return LogContextInfo.builder() + .resourceName(Translator.get(formKey) + Translator.get("module.form.setting")) + .originalValue(buildModuleFormLogDTO(oldConfig)) + .modifiedValue(buildModuleFormLogDTO(newConfig)) + .build(); + } + + private ModuleFormConfigDTO getModuleFormConfigDTO(String formKey, String formId, String orgId) { + ModuleFormConfigDTO oldConfig = new ModuleFormConfigDTO(); + oldConfig.setFields(getAllFields(formKey, orgId)); + ModuleFormBlob moduleFormBlob = moduleFormBlobMapper.selectByPrimaryKey(formId); + if (moduleFormBlob != null && StringUtils.isNotEmpty(moduleFormBlob.getProp())) { + oldConfig.setFormProp(JSON.parseObject(moduleFormBlob.getProp(), FormProp.class)); + } + return oldConfig; + } + + private ModuleForm getModuleFormByKey(String forKey, String currentOrgId) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(ModuleForm::getFormKey, forKey).eq(ModuleForm::getOrganizationId, currentOrgId); + List forms = moduleFormMapper.selectListByLambda(queryWrapper); + if (CollectionUtils.isEmpty(forms)) { + throw new GenericException(Translator.get("module.form.not_exist")); + } + return forms.getFirst(); + } + + /** + * 重置流水号 + * + * @param oldFields 旧的字段集合 + * @param saveFields 保存的字段集合 + */ + private void resetSerial(List oldFields, List saveFields, String formKey, String orgId) { + Optional serialOld = oldFields.stream().filter(f -> Strings.CS.equals(f.getType(), FieldType.SERIAL_NUMBER.name())).findAny(); + Optional serialNew = saveFields.stream().filter(BaseField::isSerialNumber).findAny(); + if (serialOld.isEmpty()) { + return; + } + ModuleFieldBlob oldFieldBlob = moduleFieldBlobMapper.selectByPrimaryKey(serialOld.get().getId()); + List oldRules = JSON.parseObject(oldFieldBlob.getProp(), SerialNumberField.class).getSerialNumberRules(); + if (serialNew.isEmpty()) { + serialNumGenerator.resetKey(oldRules.get(2), formKey, orgId); + return; + } + List newRules = ((SerialNumberField) serialNew.get()).getSerialNumberRules(); + if (serialNumGenerator.sameRule(oldRules, newRules)) { + return; + } + serialNumGenerator.resetKey(oldRules.get(2), formKey, orgId); + } + + /** + * 保存所有字段集合 + * + * @param saveFields 字段集合 + * @param saveFormId 表单ID + * @param currentUserId 当前用户ID + */ + public void saveFields(List saveFields, String saveFormId, String currentUserId) { + if (CollectionUtils.isEmpty(saveFields)) { + return; + } + // 剔除引用字段&&并保留到数据源引用字段 + List fieldToSave = new ArrayList<>(saveFields); + AtomicLong pos = new AtomicLong(0L); + List refFields = fieldToSave.stream().peek(f -> f.setPos(pos.getAndIncrement())) + .filter(f -> StringUtils.isNotEmpty(f.getResourceFieldId())) + .collect(Collectors.toMap(BaseField::getId, Function.identity(), (a, b) -> a)).values().stream() + .toList(); + fieldToSave.removeAll(refFields); + + List addFields = new ArrayList<>(); + List addFieldBlobs = new ArrayList<>(); + fieldToSave.forEach(field -> { + ModuleField moduleField = new ModuleField(); + moduleField.setId(field.getId()); + moduleField.setFormId(saveFormId); + moduleField.setMobile(field.getMobile() != null && field.getMobile()); + moduleField.setInternalKey(field.getInternalKey()); + moduleField.setType(field.getType()); + moduleField.setName(field.getName()); + moduleField.setPos(field.getPos()); + moduleField.setCreateTime(System.currentTimeMillis()); + moduleField.setCreateUser(currentUserId); + moduleField.setUpdateTime(System.currentTimeMillis()); + moduleField.setUpdateUser(currentUserId); + addFields.add(moduleField); + ModuleFieldBlob fieldBlob = new ModuleFieldBlob(); + fieldBlob.setId(field.getId()); + if (field instanceof DatasourceField sourceField && CollectionUtils.isNotEmpty(sourceField.getShowFields())) { + List sourceRefFields = refFields.stream().filter(rf -> { + String actualId = rf.getId().replace(sourceField.getId() + REF_UNDERLINE, StringUtils.EMPTY); + return sourceField.getShowFields().contains(actualId); + }).toList(); + sourceField.setRefFields(sourceRefFields); + } + fieldBlob.setProp(JSON.toJSONString(field)); + addFieldBlobs.add(fieldBlob); + }); + if (CollectionUtils.isNotEmpty(addFields)) { + moduleFieldMapper.batchInsert(addFields); + } + if (CollectionUtils.isNotEmpty(addFieldBlobs)) { + moduleFieldBlobMapper.batchInsert(addFieldBlobs); + } + } + + public ModuleFormConfigLogDTO buildModuleFormLogDTO(ModuleFormConfigDTO config) { + ModuleFormConfigLogDTO logDTO = new ModuleFormConfigLogDTO(); + FormPropLogDTO formPropLog = BeanUtils.copyBean(new FormPropLogDTO(), config.getFormProp()); + BeanUtils.copyBean(logDTO, config); + logDTO.setFormProp(formPropLog); + // 目前只处理联动字段方便日志详情解析 + Map> parseLinkFieldMap = new HashMap<>(8); + Map> linkProp = config.getFormProp().getLinkProp(); + if (linkProp != null && !linkProp.isEmpty()) { + linkProp.forEach((key, value) -> value.forEach(scenario -> parseLinkFieldMap.put(key + "-" + scenario.getKey(), scenario.getLinkFields()))); + } + logDTO.getFormProp().setLinkProp(parseLinkFieldMap); + return logDTO; + } + + public List getAllFields(String formKey, String orgId) { + ModuleForm example = new ModuleForm(); + example.setFormKey(formKey); + example.setOrganizationId(orgId); + ModuleForm moduleForm = moduleFormMapper.selectOne(example); + List allFields = getAllFields(moduleForm.getId()); + + // 提前加载价格表子表格字段作为引用集合 + List subFields = moduleFieldService.getSubFieldsBySourceType(FieldSourceType.PRICE.name()); + Map refPriceSubFieldMap = subFields.stream().collect(Collectors.toMap(BaseField::getId, Function.identity(), (p, n) -> p)); + // 处理字段信息 + List flattenFields = flattenSourceRefFields(allFields, refPriceSubFieldMap); + return flattenFields.stream() + .peek(this::setFieldRefOption) + .peek(this::setFieldBusinessParam) + .peek(field -> reloadPropOfSubRefFields(field, refPriceSubFieldMap)) + .collect(Collectors.toList()); + } + + /** + * 获取表单所有字段及其属性集合 + * + * @param formId 表单ID + * @return 字段集合 + */ + public List getAllFields(String formId) { + // set field + List fieldDTOList = new ArrayList<>(); + LambdaQueryWrapper fieldWrapper = new LambdaQueryWrapper<>(); + fieldWrapper.eq(ModuleField::getFormId, formId); + List fields = moduleFieldMapper.selectListByLambda(fieldWrapper); + + if (CollectionUtils.isNotEmpty(fields)) { + fields.sort(Comparator.comparing(ModuleField::getPos)); + List fieldIds = fields.stream().map(ModuleField::getId).toList(); + LambdaQueryWrapper blobWrapper = new LambdaQueryWrapper<>(); + blobWrapper.in(ModuleFieldBlob::getId, fieldIds); + List fieldBlobs = moduleFieldBlobMapper.selectListByLambda(blobWrapper); + Map fieldBlobMap = fieldBlobs.stream().collect(Collectors.toMap(ModuleFieldBlob::getId, ModuleFieldBlob::getProp)); + fields.forEach(field -> { + BaseField baseField = JSON.parseObject(fieldBlobMap.get(field.getId()), BaseField.class); + baseField.setPos(field.getPos()); + baseField.setType(field.getType()); + baseField.setMobile(field.getMobile()); + baseField.setInternalKey(field.getInternalKey()); + if (baseField.needInitialOptions()) { + handleInitialOption(baseField); + } + // 文本字段默认值格式 || 流水号前缀固定字符格式 + if (baseField instanceof SerialNumberField serialField && StringUtils.isEmpty(serialField.getPrefixType())) { + serialField.setPrefixType(OPTION_DEFAULT_SOURCE); + } + if (baseField instanceof InputField inputField && StringUtils.isEmpty(inputField.getDefaultValueType())) { + inputField.setDefaultValueType(OPTION_DEFAULT_SOURCE); + } + fieldDTOList.add(baseField); + }); + } + return fieldDTOList; + } + + /** + * 获取字段选项集合 + * + * @param formConfig 表单配置 + * @param allDataFields 所有数据字段 + * @return 字段选项集合 + */ + public Map> getOptionMap(ModuleFormConfigDTO formConfig, List allDataFields) { + var optionMap = new HashMap>(4); + var optionMeta = collectOptionMetadata(formConfig); + optionMap.putAll(optionMeta.staticOptions()); + if (CollectionUtils.isEmpty(allDataFields)) { + return optionMap; + } + var allFieldValues = flattenSubFieldValues(formConfig, allDataFields); + var typeIdsMap = collectOptionIds(allFieldValues, optionMeta.idTypeMap()); + + // 按照sourceType聚合id, 以便批量查询 + Map> sourceTypeIdsMap = new HashMap<>(); + Map fieldIdSourceTypeMap = new HashMap<>(); + + typeIdsMap.forEach((fieldId, ids) -> { + var sourceType = optionMeta.idTypeMap().get(getFieldIdForSubFieldId(fieldId)); + if (CollectionUtils.isEmpty(ids)) { + return; + } + fieldIdSourceTypeMap.put(fieldId, sourceType); + // 去重 + sourceTypeIdsMap.computeIfAbsent(sourceType, k -> new HashSet<>()).addAll(ids); + }); + + // 按照类型, 整体查询 + Map> sourceTypeOptionsMap = new HashMap<>(); + sourceTypeIdsMap.forEach((sourceType, ids) -> { + List options; + String tableName = TYPE_SOURCE_MAP.get(sourceType); + if (StringUtils.isBlank(tableName)) { + options = extModuleFieldMapper.getCustomFormOptionsByIds(new ArrayList<>(ids)); + } else { + options = extModuleFieldMapper.getSourceOptionsByIds(tableName, new ArrayList<>(ids)); + } + if (CollectionUtils.isNotEmpty(options)) { + sourceTypeOptionsMap.put(sourceType, options); + } + }); + + // 按照fieldId, 分配选项 + typeIdsMap.forEach((fieldId, ids) -> { + var sourceType = fieldIdSourceTypeMap.get(fieldId); + if (sourceType == null) { + return; + } + var allOptions = sourceTypeOptionsMap.get(sourceType); + if (CollectionUtils.isEmpty(allOptions)) { + return; + } + // 只保留当前 fieldId 需要的 + var optionList = allOptions.stream().filter(opt -> ids.contains(opt.getId())).toList(); + if (CollectionUtils.isNotEmpty(optionList)) { + optionMap.put(fieldId, optionList); + } + }); + + return optionMap; + } + + private OptionMetadata collectOptionMetadata(ModuleFormConfigDTO formConfig) { + var allFields = flattenFormAllFields(formConfig); + var staticOptions = new HashMap>(4); + var idTypeMap = new HashMap(8); + for (var field : allFields) { + putOptionMap(staticOptions, idTypeMap, field, field.getId()); + if (StringUtils.isNotBlank(field.getBusinessKey())) { + putOptionMap(staticOptions, idTypeMap, field, field.getBusinessKey()); + } + } + return new OptionMetadata(staticOptions, idTypeMap); + } + + private void putOptionMap(HashMap> staticOptions, HashMap idTypeMap, BaseField field, String key) { + switch (field) { + case RadioField radioField when Strings.CS.equals(field.getType(), FieldType.RADIO.name()) -> + staticOptions.put(key, optionPropToDto(radioField.getOptions())); + case CheckBoxField checkBoxField when Strings.CS.equals(field.getType(), FieldType.CHECKBOX.name()) -> + staticOptions.put(key, optionPropToDto(checkBoxField.getOptions())); + case HasOption optionField when Strings.CS.equalsAny(field.getType(), FieldType.SELECT.name(), FieldType.SELECT_MULTIPLE.name()) -> + staticOptions.put(key, optionPropToDto(optionField.getOptions())); + default -> { + } + } + if (Strings.CS.equalsAny(field.getType(), FieldType.DATA_SOURCE.name(), FieldType.DATA_SOURCE_MULTIPLE.name()) && field instanceof DatasourceField sourceField) { + idTypeMap.put(key, sourceField.getDataSourceType()); + } + if (Strings.CS.equalsAny(field.getType(), FieldType.MEMBER.name(), FieldType.MEMBER_MULTIPLE.name())) { + idTypeMap.put(key, FieldType.MEMBER.name()); + } + if (Strings.CS.equalsAny(field.getType(), FieldType.DEPARTMENT.name(), FieldType.DEPARTMENT_MULTIPLE.name())) { + idTypeMap.put(key, FieldType.DEPARTMENT.name()); + } + } + + private Map> collectOptionIds(List allFieldValues, Map idTypeMap) { + var typeIdsMap = new HashMap>(8); + allFieldValues.stream() + .filter(fv -> { + String fieldId = getFieldIdForSubFieldId(fv.getFieldId()); + return idTypeMap.containsKey(fieldId); + }) + .forEach(fv -> { + typeIdsMap.putIfAbsent(fv.getFieldId(), new ArrayList<>()); + var value = fv.getFieldValue(); + if (value == null) { + return; + } + if (value instanceof List listValue) { + typeIdsMap.get(fv.getFieldId()).addAll(JSON.parseArray(JSON.toJSONString(listValue), String.class)); + } else { + typeIdsMap.get(fv.getFieldId()).add(value.toString()); + } + }); + return typeIdsMap; + } + + private String getFieldIdForSubFieldId(String fieldId) { + if (fieldId.contains(".")) { + String[] split = fieldId.split("\\."); + if (split.length > 1) { + return split[1]; + } + } + return fieldId; + } + + private record OptionMetadata(Map> staticOptions, Map idTypeMap) { + } + + /** + * 平铺列表字段(子表格) + * + * @param formConfig 表单配置 + * @return 字段集合 + */ + public List flattenFormAllFields(ModuleFormConfigDTO formConfig) { + List toFlattenFields = new ArrayList<>(); + formConfig.getFields().stream().filter(f -> f instanceof SubField).map(f -> ((SubField) f).getSubFields()).forEach(toFlattenFields::addAll); + formConfig.getFields().addAll(toFlattenFields); + return formConfig.getFields(); + } + + public List flattenFormAllFieldsWithSubId(ModuleFormConfigDTO formConfig) { + List toFlattenFields = new ArrayList<>(); + formConfig.getFields().stream().filter(f -> f instanceof SubField).forEach(sf -> { + SubField subField = ((SubField) sf); + if (CollectionUtils.isEmpty(subField.getSubFields())) { + return; + } + subField.getSubFields().forEach(field -> field.setSubTableFieldId(subField.getId())); + toFlattenFields.addAll(subField.getSubFields()); + }); + formConfig.getFields().addAll(toFlattenFields); + return formConfig.getFields(); + } + + /** + * 获得所有平铺的字段 + * + * @param formKey 表单key + * @param orgId 组织ID + * @return 平铺的字段集合 + */ + @Cacheable(value = "field_cache", key = "#orgId + ':' + #formKey", unless = "#result == null or #orgId == null") + public List getFlattenFormFields(String formKey, String orgId) { + ModuleFormConfigDTO formConfig = getBusinessFormConfig(formKey, orgId); + return flattenFormAllFields(formConfig); + } + + /** + * 平铺子表字段值 + * + * @param formConfig 表单配置 + * @param allFieldValues 所有字段值 + * @return 平铺字段值集合 + */ + @SuppressWarnings("unchecked") + public List flattenSubFieldValues(ModuleFormConfigDTO formConfig, List allFieldValues) { + // 类型为子表的字段 + List subFields = formConfig.getFields().stream().filter(f -> f instanceof SubField).toList(); + Set subIdSet = subFields.stream().map(BaseField::getId).collect(Collectors.toSet()); + // 过滤出子表的字段值进行平铺 + List allFlattenFieldValues = new ArrayList<>(); + allFieldValues.stream().filter(fv -> subIdSet.contains(fv.getFieldId())).forEach(fv -> { + List> subRowList = (List>) fv.getFieldValue(); + if (CollectionUtils.isEmpty(subRowList)) { + return; + } + Map> subFieldIdValueMap = new HashMap<>(subRowList.getFirst().size()); + subRowList.forEach(subRow -> + subRow.forEach((k, v) -> { + if (v == null) { + return; + } + subFieldIdValueMap.putIfAbsent(k, new ArrayList<>()); + if (v instanceof List) { + subFieldIdValueMap.get(k).addAll(JSON.parseArray(JSON.toJSONString(v), String.class)); + } else { + subFieldIdValueMap.get(k).add(v.toString()); + } + })); + + subFieldIdValueMap.forEach((subFieldId, values) -> { + if (CollectionUtils.isNotEmpty(values)) { + BaseModuleFieldValue subFieldValue = new BaseModuleFieldValue(); + subFieldValue.setFieldId(subFieldId); + subFieldValue.setFieldValue(values); + allFlattenFieldValues.add(subFieldValue); + } + }); + }); + // 插入所有字段值集合中 + allFlattenFieldValues.addAll(allFieldValues); + return allFlattenFieldValues; + } + + public Map> getAttachmentMap(ModuleFormConfigDTO formConfig, List allDataFields) { + List attachmentFieldIds = formConfig.getFields() + .stream() + .filter(field -> Strings.CS.equalsAny(field.getType(), FieldType.ATTACHMENT.name())) + .map(BaseField::getId) + .toList(); + + if (CollectionUtils.isEmpty(attachmentFieldIds)) { + return null; + } + + Map> fieldAttachmentIds = new HashMap<>(attachmentFieldIds.size()); + allDataFields.stream() + .filter(field -> attachmentFieldIds.contains(field.getFieldId()) && field.getFieldValue() != null) + .forEach(field -> { + Object fieldValue = field.getFieldValue(); + List attachmentIds = new ArrayList<>(); + if (fieldValue instanceof List) { + attachmentIds.addAll(JSON.parseArray(JSON.toJSONString(fieldValue), String.class)); + } else { + attachmentIds.add(fieldValue.toString()); + } + fieldAttachmentIds.put(field.getFieldId(), attachmentIds); + }); + + List attachmentIds = fieldAttachmentIds.values() + .stream() + .flatMap(List::stream) + .distinct() + .toList(); + + if (CollectionUtils.isEmpty(attachmentIds)) { + return null; + } + + List attachments = attachmentMapper.selectByIds(attachmentIds); + List createUserIds = attachments.stream().map(Attachment::getCreateUser).toList(); + List updateUserIds = attachments.stream().map(Attachment::getUpdateUser).toList(); + + List users = userExtendService.getUserOptionByIds(ListUtils.union(createUserIds, updateUserIds)); + Map userMap = users.stream().collect(Collectors.toMap(User::getId, User::getName)); + attachments.forEach(attachment -> { + attachment.setCreateUser(userMap.get(attachment.getCreateUser())); + attachment.setUpdateUser(userMap.get(attachment.getUpdateUser())); + }); + + Map attachmentMap = attachments.stream().collect(Collectors.toMap(Attachment::getId, Function.identity())); + Map> attachmentMapResult = new HashMap<>(fieldAttachmentIds.size()); + for (Map.Entry> entry : fieldAttachmentIds.entrySet()) { + if (CollectionUtils.isEmpty(entry.getValue())) { + continue; + } + List fieldAttachments = new ArrayList<>(); + entry.getValue().forEach(attachmentId -> { + if (attachmentMap.containsKey(attachmentId)) { + fieldAttachments.add(attachmentMap.get(attachmentId)); + } + }); + attachmentMapResult.put(entry.getKey(), fieldAttachments); + } + return attachmentMapResult; + } + + public List getSourceOptionsByKeywords(String type, List nameList) { + if (CollectionUtils.isEmpty(nameList)) { + return new ArrayList<>(); + } + String tableName = TYPE_SOURCE_MAP.get(type); + if (StringUtils.isBlank(tableName)) { + extModuleFieldMapper.getCustomFormOptionsByKeywords(nameList); + } + return extModuleFieldMapper.getSourceOptionsByKeywords(tableName, nameList); + } + + /** + * 处理自定义字段中业务子表单值 + * + * @param resource 资源详情 + * @param fieldValues 自定义字段值 + * @param formConfig 表单配置 + */ + public void processBusinessFieldValues(Object resource, List fieldValues, ModuleFormConfigDTO formConfig) { + List subFields = formConfig.getFields() + .stream() + .filter(f -> f instanceof SubField && StringUtils.isNotEmpty(f.getBusinessKey())) + .toList(); + + Map subFieldBusinessMap = subFields.stream().collect(Collectors.toMap(BaseField::getId, BaseField::getBusinessKey)); + List businessFieldValues = fieldValues + .stream() + .filter(fv -> subFieldBusinessMap.containsKey(fv.getFieldId())) + .toList(); + + businessFieldValues.forEach(bfv -> { + String businessKey = subFieldBusinessMap.get(bfv.getFieldId()); + Field field = ReflectionUtils.findField(resource.getClass(), f -> Strings.CS.equals(f.getName(), businessKey)); + if (field == null) { + log.error("Cannot find field `{}`", businessKey); + return; + } + ReflectionUtils.setField(field, resource, bfv.getFieldValue()); + }); + + fieldValues = new ArrayList<>(fieldValues); + fieldValues.removeIf(fv -> subFieldBusinessMap.containsKey(fv.getFieldId())); + Field moduleFields = ReflectionUtils.findField(resource.getClass(), f -> Strings.CS.equals(f.getName(), "moduleFields")); + if (moduleFields == null) { + log.error("No such field `moduleFields` in resource"); + return; + } + ReflectionUtils.setField(moduleFields, resource, fieldValues); + } + + /** + * 替换字段引用选项 + * + * @param field 自定义字段 + */ + public void setFieldRefOption(BaseField field) { + if (field instanceof SubField subField) { + subField.getSubFields().forEach(this::setFieldRefOption); + } + if (!(field instanceof HasOption of)) { + return; + } + if (StringUtils.isEmpty(of.getOptionSource()) || Strings.CS.equals(of.getOptionSource(), OPTION_DEFAULT_SOURCE)) { + if (CollectionUtils.isEmpty(of.getCustomOptions())) { + of.setCustomOptions(of.getOptions()); + } else { + of.setOptions(of.getCustomOptions()); + } + } else { + // 引用字段选项, 清空再替换 + of.setOptions(new ArrayList<>()); + String refId = of.getRefId(); + ModuleFieldBlob fieldBlob = moduleFieldBlobMapper.selectByPrimaryKey(refId); + if (fieldBlob != null) { + BaseField refField = JSON.parseObject(fieldBlob.getProp(), BaseField.class); + if (refField instanceof HasOption refOption) { + of.setOptions(CollectionUtils.isNotEmpty(refOption.getOptions()) ? refOption.getOptions() : refOption.getCustomOptions()); + } + } + } + } + + /** + * 设置自定义字段业务参数 + * + * @param field 自定义字段 + */ + public void setFieldBusinessParam(BaseField field) { + Set businessTitleIdSet = Arrays.stream(BusinessTitleConstants.values()) + .map(BusinessTitleConstants::getId) + .collect(Collectors.toSet()); + if (StringUtils.isNotBlank(field.getResourceFieldId())) { + String actualFieldId = field.getId().replace((field.getResourceFieldId() + REF_UNDERLINE), StringUtils.EMPTY); + if (businessTitleIdSet.contains(actualFieldId)) { + return; + } + } + + // 获取特殊的业务字段 + Map businessModuleFieldMap = Arrays.stream(BusinessModuleField.values()). + collect(Collectors.toMap(BusinessModuleField::getKey, Function.identity())); + if (field instanceof SubField subField) { + subField.getSubFields().forEach(this::setFieldBusinessParam); + } + BusinessModuleField businessEnum = businessModuleFieldMap.get(field.getInternalKey()); + if (businessEnum != null) { + // 设置特殊的业务字段 key + field.setBusinessKey(businessEnum.getBusinessKey()); + field.setDisabledProps(businessEnum.getDisabledProps()); + } else { + field.setBusinessKey(null); + field.setDisabledProps(null); + } + } + + /** + * 重载子表引用字段最新的属性 + * + * @param field 自定义字段 + */ + public void reloadPropOfSubRefFields(BaseField field, Map priceSubFieldMap) { + if (field instanceof SubField subField && CollectionUtils.isNotEmpty(subField.getSubFields())) { + List subSourceField = subField.getSubFields().stream() + .filter(f -> f instanceof DatasourceField sourceField && CollectionUtils.isNotEmpty(sourceField.getShowFields())).toList(); + subSourceField.forEach(sf -> { + List oldRefIds = ((DatasourceField) sf).getShowFields().stream() + .map(splitRefId(sf.getId())).distinct().toList(); + List reloadFieldBlobs = moduleFieldBlobMapper.selectByIds(oldRefIds); + Map reloadFieldMap = reloadFieldBlobs.stream().collect(Collectors.toMap(ModuleFieldBlob::getId, + filedBlob -> JSON.parseObject(filedBlob.getProp(), BaseField.class))); + + // 补充一些内置字段信息 + getSystemExtendFields(((DatasourceField) sf).getDataSourceType()) + .forEach(extField -> reloadFieldMap.put(extField.getId(), extField)); + // 合并可能引用的字段属性 (数据源引用字段 & 价格表子表格字段) + reloadFieldMap.putAll(priceSubFieldMap); + + Function refIdSplitter = splitRefId(sf.getId()); + ListIterator it = subField.getSubFields().listIterator(); + while (it.hasNext()) { + BaseField oldField = it.next(); + // 只处理符合条件的引用字段 + if (StringUtils.isEmpty(oldField.getResourceFieldId()) || !Strings.CI.equals(oldField.getResourceFieldId(), sf.getId())) { + continue; + } + // 兼容旧引用字段 + String oldRefFieldId = refIdSplitter.apply(oldField.getId()); + BaseField refField = reloadFieldMap.get(oldRefFieldId); + if (refField == null) { + // 引用的字段已删 + it.remove(); + ((DatasourceField) sf).getShowFields().remove(oldRefFieldId); + continue; + } + BaseField combineField = combineFieldsProps(oldField, refField); + // 子表格的引用字段特殊属性 + BusinessModuleField businessField = BusinessModuleField.ofKey(combineField.getInternalKey()); + if (businessField != null) { + combineField.setBusinessKey(businessField.getBusinessKey()); + } + combineField.setSubTableFieldId(oldField.getSubTableFieldId()); + it.set(combineField); + } + }); + } + } + + /** + * 平铺数据源引用字段 + * @param fields 入库字段集合 + * @return 平铺后的字段集合 (数据源引用字段被平铺成普通字段, 并且属性被更新为最新引用字段属性) + */ + @SuppressWarnings("unchecked") + public List flattenSourceRefFields(List fields, Map priceSubFieldMap) { + List flatFields = new ArrayList<>(); + fields.forEach(field -> { + flatFields.add(field); + if (field instanceof DatasourceField sourceField && MapUtils.isNotEmpty(sourceField.getCombineSearch())) { + Object obj = sourceField.getCombineSearch().get("conditions"); + if (obj instanceof List list) { + for (Object item : list) { + if (item instanceof Map map) { + Map condition = (Map) map; + if (!condition.containsKey("matchType")) { + condition.put("matchType","MATCH_FIELD"); + } + } + } + } + } + + if (field instanceof DatasourceField sourceField && CollectionUtils.isNotEmpty(sourceField.getShowFields())) { + // 兼容新旧引用字段 + List oldRefIds = sourceField.getShowFields().stream().map(splitRefId(sourceField.getId())).distinct().toList(); + List reloadFieldBlobs = moduleFieldBlobMapper.selectByIds(oldRefIds); + Map reloadFieldMap = reloadFieldBlobs.stream().collect(Collectors.toMap(ModuleFieldBlob::getId, + filedBlob -> JSON.parseObject(filedBlob.getProp(), BaseField.class))); + + // 补充内置扩展的系统字段 + getSystemExtendFields(sourceField.getDataSourceType()) + .forEach(extField -> reloadFieldMap.put(extField.getId(), extField)); + + // 合并可能引用的字段属性 (数据源引用字段 & 价格表子表格字段) + reloadFieldMap.putAll(priceSubFieldMap); + + // 兼容处理旧版本引用字段没有refFields属性的情况,直接从showFields解析出引用字段并设置属性 + if (CollectionUtils.isEmpty(sourceField.getRefFields())) { + sourceField.setRefFields(new ArrayList<>()); + for (String showFieldKey : sourceField.getShowFields()) { + BaseField refField = reloadFieldMap.get(showFieldKey); + refField.setResourceFieldId(sourceField.getId()); + sourceField.getRefFields().add(refField); + } + } + + // 平铺引用字段 + sourceField.getRefFields().forEach(oldRefField -> { + // 兼容旧引用字段 + String oldRefFieldId = splitRefId(oldRefField.getResourceFieldId()).apply(oldRefField.getId()); + BaseField refField = reloadFieldMap.get(oldRefFieldId); + if (refField == null) { + // 引用的字段过期或已被删除 + sourceField.getShowFields().remove(oldRefFieldId); + return; + } + BaseField combineField = combineFieldsProps(oldRefField, refField); + combineField.setPos(oldRefField.getPos() == null ? sourceField.getPos() : oldRefField.getPos()); + flatFields.add(flatFields.size(), combineField); + }); + } + }); + // 按照pos排序保证数据源引用字段被平铺后顺序不变 + flatFields.sort(Comparator.comparing(BaseField::getPos)); + return flatFields; + } + + private List getSystemExtendFields(String dataSourceType) { + if (Strings.CI.equals(dataSourceType, FieldSourceType.BUSINESS_TITLE.name())) { + return initBusinessTitleFields(); + } + if (Strings.CI.equalsAny(dataSourceType, FieldSourceType.CONTRACT.name(), FieldSourceType.INVOICE.name(), FieldSourceType.ORDER.name(), FieldSourceType.QUOTATION.name())) { + // 目前只有这几种数据源支持系统字段 + return initSourceSystemFields(FieldSourceType.valueOf(dataSourceType)); + } + return List.of(); + } + + public List initBusinessTitleFields() { + List fields = new ArrayList<>(); + Locale locale = LocaleContextHolder.getLocale(); + BusinessTitleConstants[] values = BusinessTitleConstants.values(); + for (BusinessTitleConstants constant : values) { + InputField field = new InputField(); + field.setId(constant.getId()); + field.setBusinessKey(constant.getKey()); + if (Locale.US.toString().equalsIgnoreCase(locale.toString())) { + field.setName(constant.getUs()); + } else { + field.setName(constant.getCh()); + } + field.setInternalKey(constant.getKey()); + field.setBusinessKey(constant.getKey()); + field.setType(FieldType.INPUT.name()); + field.setShowLabel(true); + field.setReadable(true); + fields.add(field); + } + return fields; + } + + public List initSourceSystemFields(FieldSourceType sourceType) { + if (sourceType == null) { + return List.of(); + } + List fields = new ArrayList<>(); + for (SystemFieldConstants sf : SystemFieldConstants.values()) { + // 目前只有下拉类型系统字段, 后续可根据枚举扩展 + SelectField field = new SelectField(); + field.setId(sf.getKey()); + field.setSys(true); + fields.add(field); + } + return fields; + } + + /** + * OptionProp转OptionDTO + * + * @param options 选项集合 + * @return 选项DTO集合 + */ + public List optionPropToDto(List options) { + if (options == null || options.isEmpty()) { + return new ArrayList<>(); + } + return options.stream().map(option -> { + OptionDTO optionDTO = new OptionDTO(); + optionDTO.setName(option.getLabel()); + optionDTO.setId(option.getValue()); + return optionDTO; + }).toList(); + } + + /** + * 表单初始化 + */ + public void initForm() { + initFormAndFields(FormKey.allKeys()); + } + + /** + * 初始化升级表单 + */ + public void initUpgradeForm() { + List allKeys = FormKey.allKeys(); + LambdaQueryWrapper moduleFormWrapper = new LambdaQueryWrapper<>(); + moduleFormWrapper.in(ModuleForm::getFormKey, allKeys); + List oldForms = moduleFormMapper.selectListByLambda(moduleFormWrapper); + allKeys.removeAll(oldForms.stream().map(ModuleForm::getFormKey).toList()); + if (CollectionUtils.isEmpty(allKeys)) { + // 初始化完成, 无升级表单. + return; + } + initFormAndFields(allKeys); + } + + /** + * 初始化线索转联系人表单联动规则 + */ + @SuppressWarnings("unchecked") + public void initContactFormLinkRules() { + // 加载初始化的字段信息 + LambdaQueryWrapper fieldWrapper = new LambdaQueryWrapper<>(); + fieldWrapper.in(ModuleField::getInternalKey, List.of("contactName", "contactPhone", "clueContactName", "clueContactPhone")); + List fields = moduleFieldMapper.selectListByLambda(fieldWrapper); + if (CollectionUtils.isEmpty(fields) || fields.size() < 4) { + log.error("未找到对应的内置字段,无法初始化联动规则"); + return; + } + Map fieldMap = fields.stream().collect(Collectors.toMap(ModuleField::getInternalKey, ModuleField::getId)); + // 构建联动规则 + LinkField contactNameLink = new LinkField(); + contactNameLink.setCurrent(fieldMap.get("contactName")); + contactNameLink.setLink(fieldMap.get("clueContactName")); + contactNameLink.setEnable(true); + LinkField contactPhoneLink = new LinkField(); + contactPhoneLink.setCurrent(fieldMap.get("contactPhone")); + contactPhoneLink.setLink(fieldMap.get("clueContactPhone")); + contactPhoneLink.setEnable(true); + // 更新表单属性 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ModuleForm::getFormKey, FormKey.CONTACT.getKey()); + ModuleForm contactForm = moduleFormMapper.selectListByLambda(wrapper).getFirst(); + ModuleFormBlob formBlob = moduleFormBlobMapper.selectByPrimaryKey(contactForm.getId()); + Map propMap = JSON.parseMap(formBlob.getProp()); + List contactLinkProp = List.of(LinkScenario.builder().key(LinkScenarioKey.CLUE_TO_CONTACT.name()) + .linkFields(List.of(contactNameLink, contactPhoneLink)).build()); + propMap.put("linkProp", Map.of(FormKey.CLUE.getKey(), contactLinkProp)); + formBlob.setProp(JSON.toJSONString(propMap)); + moduleFormBlobMapper.updateById(formBlob); + } + + /** + * 初始化订单(合同)联动规则 + */ + @SuppressWarnings("unchecked") + public void initContractToOrderLinkScenario() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ModuleForm::getFormKey, FormKey.ORDER.getKey()); + ModuleForm orderForm = moduleFormMapper.selectListByLambda(wrapper).getFirst(); + ModuleFormBlob formBlob = moduleFormBlobMapper.selectByPrimaryKey(orderForm.getId()); + Map propMap = JSON.parseMap(formBlob.getProp()); + propMap.put("linkProp", Map.of(FormKey.CONTRACT.getKey(), List.of( + LinkScenario.builder().key(LinkScenarioKey.CONTRACT_TO_ORDER.name()).linkFields(new ArrayList<>())))); + formBlob.setProp(JSON.toJSONString(propMap)); + moduleFormBlobMapper.updateById(formBlob); + } + + @SuppressWarnings("unchecked") + public void initExtFieldsByVer(String version) { + try { + List fields = new ArrayList<>(); + List fieldBlobs = new ArrayList<>(); + Map>> fieldMap = JSON.parseObject(fieldResource.getInputStream(), Map.class); + fieldMap.forEach((formKey, formFields) -> { + boolean existExtVerField = formFields.stream().anyMatch(f -> f.containsKey(UPGRADE_EXT_FIELD) && Strings.CS.equals(version, f.get(UPGRADE_EXT_FIELD).toString())); + if (!existExtVerField) { + return; + } + ModuleForm example = new ModuleForm(); + example.setFormKey(formKey); + ModuleForm form = moduleFormMapper.selectOne(example); + if (form == null) { + log.error("未找到表单 {}, 无法初始化扩展字段", formKey); + return; + } + Long maxPos = extModuleFieldMapper.getMaxFieldPosByFormId(form.getId()); + List> extFields = formFields.stream().filter(f -> f.containsKey(UPGRADE_EXT_FIELD) && Strings.CS.equals(version, f.get(UPGRADE_EXT_FIELD).toString())).toList(); + AtomicLong pos = new AtomicLong(maxPos + 1); + extFields.forEach(initField -> { + ModuleField field = supplyFieldInfo(initField, form.getId(), pos.getAndIncrement(), new HashMap<>(2)); + initField.put("id", field.getId()); + fields.add(field); + ModuleFieldBlob fieldBlob = new ModuleFieldBlob(); + fieldBlob.setId(field.getId()); + fieldBlob.setProp(JSON.toJSONString(initField)); + fieldBlobs.add(fieldBlob); + }); + }); + if (CollectionUtils.isNotEmpty(fields)) { + moduleFieldMapper.batchInsert(fields); + } + if (CollectionUtils.isNotEmpty(fieldBlobs)) { + moduleFieldBlobMapper.batchInsert(fieldBlobs); + } + } catch (Exception e) { + log.error("表单扩展字段初始化失败", e); + throw new GenericException("表单扩展字段初始化失败", e); + } + } + + /** + * 表单及字段初始化 (升级) + * + * @param initKeys 初始化Key集合 + */ + private void initFormAndFields(List initKeys) { + Map formKeyMap = new HashMap<>(FormKey.values().length); + List forms = new ArrayList<>(); + List formBlobs = new ArrayList<>(); + initKeys.forEach(formKey -> { + ModuleForm form = new ModuleForm(); + form.setId(IDGenerator.nextStr()); + form.setFormKey(formKey); + form.setOrganizationId(DEFAULT_ORGANIZATION_ID); + form.setCreateUser(InternalUser.ADMIN.getValue()); + form.setCreateTime(System.currentTimeMillis()); + form.setUpdateUser(InternalUser.ADMIN.getValue()); + form.setUpdateTime(System.currentTimeMillis()); + forms.add(form); + formKeyMap.put(formKey, form.getId()); + ModuleFormBlob formBlob = new ModuleFormBlob(); + formBlob.setId(form.getId()); + try { + FormProp formProp = JSON.parseObject(formResource.getInputStream(), FormProp.class); + formBlob.setProp(JSON.toJSONString(formProp)); + } catch (IOException e) { + throw new GenericException("表单属性初始化失败", e); + } + formBlobs.add(formBlob); + }); + moduleFormMapper.batchInsert(forms); + moduleFormBlobMapper.batchInsert(formBlobs); + // init form fields + initFormFields(formKeyMap); + } + + /** + * 字段初始化 (静态json文件) + * + * @param formKeyMap 表单Key映射 + */ + @SuppressWarnings("unchecked") + public void initFormFields(Map formKeyMap) { + List fields = new ArrayList<>(); + List fieldBlobs = new ArrayList<>(); + try { + Map>> fieldMap = JSON.parseObject(fieldResource.getInputStream(), Map.class); + formKeyMap.keySet().forEach(key -> { + String formId = formKeyMap.get(key); + List> initFields = fieldMap.get(key); + AtomicLong pos = new AtomicLong(1L); + // 显隐规则Key-ID映射 + Map controlKeyPreMap = new HashMap<>(2); + initFields.forEach(initField -> { + if (initField.containsKey(UPGRADE_EXT_FIELD)) { + return; + } + ModuleField field = supplyFieldInfo(initField, formId, pos.getAndIncrement(), controlKeyPreMap); + initField.put("id", field.getId()); + fields.add(field); + if (initField.containsKey(CONTROL_RULES_KEY)) { + List controlRules = JSON.parseArray(JSON.toJSONString(initField.get(CONTROL_RULES_KEY)), ControlRuleProp.class); + controlRules.forEach(controlRule -> { + List showFieldIds = new ArrayList<>(); + controlRule.getFieldIds().forEach(fieldKey -> { + if (!controlKeyPreMap.containsKey(fieldKey)) { + controlKeyPreMap.put(fieldKey, IDGenerator.nextStr()); + } + showFieldIds.add(controlKeyPreMap.get(fieldKey)); + }); + controlRule.setFieldIds(showFieldIds); + }); + initField.put(CONTROL_RULES_KEY, controlRules); + } + handleShowFieldsInit(initField, fields); + if (initField.containsKey(SUB_FIELDS)) { + List subFields = JSON.parseArray(JSON.toJSONString(initField.get(SUB_FIELDS)), BaseField.class); + subFields.forEach(subField -> subField.setId(IDGenerator.nextStr())); + initField.put(SUB_FIELDS, subFields); + } + ModuleFieldBlob fieldBlob = new ModuleFieldBlob(); + fieldBlob.setId(field.getId()); + fieldBlob.setProp(JSON.toJSONString(initField)); + fieldBlobs.add(fieldBlob); + }); + }); + moduleFieldMapper.batchInsert(fields); + moduleFieldBlobMapper.batchInsert(fieldBlobs); + } catch (Exception e) { + log.error("表单字段初始化失败", e); + throw new GenericException("表单字段初始化失败", e); + } + } + + /** + * 处理显示字段初始化 + * + * @param initField 初始化字段 + * @param initFields 如果 initForm 初始化,数据库没有数据,需要从 initFields 中获取 + */ + @SuppressWarnings("unchecked") + private void handleShowFieldsInit(Map initField, List initFields) { + if (initField.containsKey(SHOW_FIELD_KEY)) { + List showFieldKeys = (List) initField.get(SHOW_FIELD_KEY); + List showFields = moduleFieldService.selectFieldsByInternalKeys(showFieldKeys); + + if (CollectionUtils.isEmpty(showFields)) { + // initForm 初始化,数据库没有数据,需要从 initFields 中获取 + showFields = initFields.stream() + .filter(f -> showFieldKeys.contains(f.getInternalKey())) + .collect(Collectors.toList()); + } + + if (CollectionUtils.isNotEmpty(showFieldKeys)) { + Set internalKeys = showFields.stream() + .map(ModuleField::getInternalKey) + .collect(Collectors.toSet()); + + // 添加表单字段 + List showFieldResult = new ArrayList<>(showFields.stream().map(ModuleField::getId).toList()); + // 添加表单中没有的系统字段 + List systemFieldKeys = showFieldKeys.stream() + .filter(fieldKey -> !internalKeys.contains(fieldKey)) + .toList(); + showFieldResult.addAll(systemFieldKeys); + initField.put(SHOW_FIELD_KEY, showFieldResult); + } + } + } + + + /** + * 组装字段基础信息 + * + * @param fieldMap 字段集合 + * @param formId 表单ID + * @param pos 字段位置 + * @param controlKeyPreMap 显隐规则Key-ID映射 + * @return 字段 + */ + private ModuleField supplyFieldInfo(Map fieldMap, String formId, Long pos, Map controlKeyPreMap) { + ModuleField field = new ModuleField(); + field.setInternalKey(fieldMap.get("internalKey").toString()); + field.setId(controlKeyPreMap.containsKey(field.getInternalKey()) ? controlKeyPreMap.get(field.getInternalKey()) : IDGenerator.nextStr()); + field.setFormId(formId); + field.setType(fieldMap.get("type").toString()); + field.setName(fieldMap.get("name").toString()); + field.setMobile((Boolean) fieldMap.getOrDefault("mobile", false)); + field.setPos(pos); + field.setCreateTime(System.currentTimeMillis()); + field.setCreateUser(InternalUser.ADMIN.getValue()); + field.setUpdateTime(System.currentTimeMillis()); + field.setUpdateUser(InternalUser.ADMIN.getValue()); + return field; + } + + /** + * 初始化数据类型-数据源映射 + * + * @return 集合 + */ + public Map initTypeSourceMap() { + Map typeSourceMap = new HashMap<>(8); + typeSourceMap.put(FieldType.MEMBER.name(), "sys_user"); + typeSourceMap.put(FieldType.DEPARTMENT.name(), "sys_department"); + typeSourceMap.put(FieldSourceType.CUSTOMER.name(), "customer"); + typeSourceMap.put(FieldSourceType.CLUE.name(), "clue"); + typeSourceMap.put(FieldSourceType.CONTACT.name(), "customer_contact"); + typeSourceMap.put(FieldSourceType.OPPORTUNITY.name(), "opportunity"); + typeSourceMap.put(FieldSourceType.PRODUCT.name(), "product"); + return typeSourceMap; + } + + /** + * 处理默认值初始化选项 + * + * @param field 基础字段 + */ + private void handleInitialOption(BaseField field) { + if (field instanceof MemberField memberField) { + memberField.setInitialOptions(userExtendService.getUserOptionById(memberField.getDefaultValue())); + } + if (field instanceof MemberMultipleField memberMultipleField) { + memberMultipleField.setInitialOptions(userExtendService.getUserOptionByIds(memberMultipleField.getDefaultValue())); + } + if (field instanceof DepartmentField departmentField) { + departmentField.setInitialOptions(departmentService.getDepartmentOptionsById(departmentField.getDefaultValue())); + } + if (field instanceof DepartmentMultipleField departmentMultipleField) { + departmentMultipleField.setInitialOptions(departmentService.getDepartmentOptionsByIds(departmentMultipleField.getDefaultValue())); + } + } + + /** + * 将业务字段选项放入optionMap + * + * @param list 列表数据 + * @param getOptionIdFunc 获取选项ID函数 + * @param getOptionNameFunc 获取选项名称函数 + * @param 实体 + */ + public List getBusinessFieldOption(List list, + Function getOptionIdFunc, + Function getOptionNameFunc) { + return list.stream() + .map(item -> { + OptionDTO optionDTO = new OptionDTO(); + optionDTO.setId(getOptionIdFunc.apply(item)); + optionDTO.setName(getOptionNameFunc.apply(item)); + return optionDTO; + }) + .distinct() + .filter(option -> StringUtils.isNotEmpty(option.getId())) + .toList(); + } + + public List getBusinessFieldOption(T item, + Function getOptionIdFunc, + Function getOptionNameFunc) { + return getBusinessFieldOption(List.of(item), getOptionIdFunc, getOptionNameFunc); + } + + public List getBaseModuleFieldValues(List list, Function> getModuleFieldFunc) { + // 处理自定义字段选项数据 + return list.stream() + .map(getModuleFieldFunc) + .filter(org.apache.commons.collections.CollectionUtils::isNotEmpty) + .flatMap(List::stream) + .collect(Collectors.toList()); + } + + /** + * 获取自定义表头集合 (包括引用显示字段) + * + * @param exportHeads 导出头 + * @param formKey 表单Key + * @param currentOrg 当前组织 + * @return 自定义导入表头集合 + */ + public List> getAllExportHeads(List exportHeads, String formKey, String currentOrg) { + Map fieldConfigMap = getFieldConfigMapByName(formKey, currentOrg); + List> heads = new ArrayList<>(); + exportHeads.forEach(exportHead -> { + if (Strings.CS.equals(exportHead.getColumnType(), EXPORT_SYSTEM_TYPE)) { + heads.add(new ArrayList<>(Collections.singletonList(exportHead.getTitle()))); + } + if (!fieldConfigMap.containsKey(exportHead.getTitle())) { + return; + } + BaseField field = fieldConfigMap.get(exportHead.getTitle()); + if (field instanceof SubField subField && CollectionUtils.isNotEmpty(subField.getSubFields())) { + Map subFieldMap = subField.getSubFields() + .stream() + .collect(Collectors.toMap(f -> + StringUtils.isNotBlank(f.getResourceFieldId()) ? f.getId() : + f.idOrBusinessKey(), BaseField::getName, (oldValue, newValue) -> oldValue)); + + subField.getSubFields().stream() + .filter(BaseField::canExport) + .forEach(f -> { + List head = new ArrayList<>(); + head.add(field.getName()); + head.add(f.getName()); + heads.add(head); + }); + + if (CollectionUtils.isNotEmpty(subField.getSumColumns())) { + subField.getSumColumns().forEach(sumColumn -> { + if (!subFieldMap.containsKey(sumColumn)) { + return; + } + heads.add(new ArrayList<>(Collections.singletonList(Translator.get("sum") + "-" + subFieldMap.get(sumColumn)))); + }); + } + } else { + heads.add(new ArrayList<>(Collections.singletonList(field.getName()))); + } + }); + return heads; + } + + /** + * 获取导出的合并头ID集合 + * + * @param formKey 表单Key + * @param currentOrg 当前组织 + * @param exportHeads 导出表头集合 + * @return 导出字段ID集合 + */ + public List getExportMergeHeads(String formKey, String currentOrg, List exportHeads) { + Map fieldConfigMap = getFieldConfigMapByName(formKey, currentOrg); + List heads = new ArrayList<>(); + exportHeads.forEach(exportHead -> { + if (Strings.CS.equals(exportHead.getColumnType(), EXPORT_SYSTEM_TYPE)) { + heads.add(exportHead.getKey()); + } + if (!fieldConfigMap.containsKey(exportHead.getTitle())) { + return; + } + + BaseField field = fieldConfigMap.get(exportHead.getTitle()); + if (field instanceof SubField subField && CollectionUtils.isNotEmpty(subField.getSubFields())) { + Map subFieldMap = subField.getSubFields() + .stream() + .collect(Collectors.toMap(f -> StringUtils.isNotBlank(f.getResourceFieldId()) ? f.getId() + : f.idOrBusinessKey(), Function.identity(), (oldValue, newValue) -> oldValue)); + + // 子表格的表头ID格式: 子表格ID|字段ID + subField.getSubFields().stream() + .filter(BaseField::canExport) + .map(BaseField::getId) + .forEach(bf -> heads.add(subField.getId() + SLASH + bf)); + + // 子表格汇总字段ID格式: sum_子表格ID|字段ID + if (CollectionUtils.isNotEmpty(subField.getSumColumns())) { + subField.getSumColumns().forEach(sumColumn -> { + if (!subFieldMap.containsKey(sumColumn)) { + return; + } + heads.add(SUM_PREFIX + subField.getId() + SLASH + subFieldMap.get(sumColumn).getId()); + }); + } + } else { + heads.add(field.getId()); + } + }); + return heads; + } + + /** + * 获取不含引用字段的表头集合 + * + * @param formKey 表单Key + * @param currentOrg 当前组织 + * @return 表头集合 + */ + public List> getCustomImportHeadsNoRef(String formKey, String currentOrg) { + List allFields = getAllFields(formKey, currentOrg); + if (CollectionUtils.isEmpty(allFields)) { + return null; + } + + List fields = allFields + .stream() + .filter(f -> StringUtils.isEmpty(f.getResourceFieldId()) && f.canImport()) + .toList(); + + List> heads = new ArrayList<>(); + fields.forEach(field -> { + if (field instanceof SubField subField && CollectionUtils.isNotEmpty(subField.getSubFields())) { + subField.getSubFields().forEach(f -> { + if (StringUtils.isNotEmpty(f.getResourceFieldId()) || !f.canImport()) { + return; + } + List head = new ArrayList<>(); + head.add(field.getName()); + head.add(f.getName()); + heads.add(head); + }); + } else { + heads.add(new ArrayList<>(Collections.singletonList(field.getName()))); + } + }); + return heads; + } + + /** + * 获取自定义导出字段集合 + * + * @param formKey 表单Key + * @param currentOrg 当前组织 + * @return 字段集合 + */ + public List getAllCustomImportFields(String formKey, String currentOrg) { + List allFields = getAllFields(formKey, currentOrg); + if (CollectionUtils.isEmpty(allFields)) { + return null; + } + return new ArrayList<>(allFields); + } + + /** + * 支持子表头 + * + * @param headFields 表头字段集合 + * @return 是否支持 + */ + public boolean supportSubHead(List headFields) { + if (CollectionUtils.isEmpty(headFields)) { + return false; + } + + return headFields + .stream() + .anyMatch(field -> field instanceof SubField subField && CollectionUtils.isNotEmpty(subField.getSubFields())); + } + + /** + * 字段保存预检查 + */ + public void preCheckForFieldSave(String formKey, List fields) { + boolean businessDeleted = BusinessModuleField.isBusinessDeleted(formKey, fields); + if (businessDeleted) { + throw new GenericException(Translator.get("module.form.business_field.deleted")); + } + boolean hasRepeatName = BusinessModuleField.hasRepeatName(fields); + if (hasRepeatName) { + throw new GenericException(Translator.get("module.form.fields.repeat")); + } + Optional repeatOptional = fields.stream().filter(field -> { + if (field instanceof HasOption optionField) { + List options = optionField.getOptions(); + return CollectionUtils.isNotEmpty(options) && hasRepeatOption(options); + } + return false; + }).findAny(); + if (repeatOptional.isPresent()) { + BaseField field = repeatOptional.get(); + throw new GenericException(Translator.getWithArgs("module.form.fields.option.repeat", field.getName())); + } + } + + /** + * 包含重复选项 + * + * @param options 选项 + * @return 是否重复选项 + */ + private boolean hasRepeatOption(List options) { + if (CollectionUtils.isEmpty(options)) { + return false; + } + return options.stream() + .collect(Collectors.groupingBy(OptionProp::getLabel, Collectors.counting())) + .values().stream() + .anyMatch(count -> count > 1); + } + + /** + * 判断内置字段是否包含唯一性校验 + * + * @param formKey 表单Key + * @param orgId 组织ID + * @return 是否唯一 + */ + public boolean hasFieldUniqueCheck(String formKey, String orgId, String internalKey) { + List allFields = getAllFields(formKey, orgId); + if (CollectionUtils.isEmpty(allFields)) { + return false; + } + Optional internalField = allFields.stream().filter(field -> Strings.CS.equals(field.getInternalKey(), internalKey)).findFirst(); + return internalField.isPresent() && internalField.get().needRepeatCheck(); + } + + /** + * 填充表单联动值 + * + * @param target 目标对象 + * @param source 源对象 + * @param targetFormConfig 目标表单配置 + * @param orgId 组织ID + * @param 实体类型 + * @param 数据来源类型 + * @param scenarioKey 场景Key + * @return 填充结果 + */ + public FormLinkFill fillFormLinkValue(T target, S source, ModuleFormConfigDTO targetFormConfig, + String orgId, String sourceFormKey, String scenarioKey) throws Exception { + FormLinkFill.FormLinkFillBuilder fillBuilder = FormLinkFill.builder(); + Map> linkProp = targetFormConfig.getFormProp().getLinkProp(); + if (linkProp == null || CollectionUtils.isEmpty(linkProp.get(sourceFormKey))) { + return fillBuilder.entity(target).build(); + } + // 未找到联动场景,直接返回目标对象 + Optional scenarioOptional = linkProp.get(sourceFormKey).stream().filter(scenario -> Strings.CS.equals(scenario.getKey(), scenarioKey)).findFirst(); + if (scenarioOptional.isEmpty()) { + return fillBuilder.entity(target).build(); + } + + ModuleFormConfigDTO sourceFormConfig = getBusinessFormConfig(sourceFormKey, orgId); + List sourceFields = sourceFormConfig.getFields(); + List targetFields = targetFormConfig.getFields(); + if (CollectionUtils.isEmpty(sourceFields) || CollectionUtils.isEmpty(targetFields)) { + return fillBuilder.entity(target).build(); + } + // 目标表单字段 + Map targetFieldMap = targetFields.stream().collect(Collectors.toMap(BaseField::getId, Function.identity())); + // 来源表单字段 + Map sourceFieldMap = sourceFields.stream().collect(Collectors.toMap(BaseField::getId, Function.identity())); + // 填充数据 + Class targetClass = target.getClass(); + Class sourceClass = source.getClass(); + List targetFieldVals = new ArrayList<>(); + @SuppressWarnings("unchecked") + List sourceFieldVals = (List) sourceClass.getMethod("getModuleFields").invoke(source); + for (LinkField linkField : scenarioOptional.get().getLinkFields()) { + if (!linkField.isEnable()) { + continue; + } + BaseField targetField = targetFieldMap.get(linkField.getCurrent()); + BaseField sourceField = sourceFieldMap.get(linkField.getLink()); + if (targetField == null || sourceField == null) { + continue; + } + // 从源对象字段取值 + TransformSourceApplyDTO sourceValue; + try { + sourceValue = applySourceValue(sourceField, sourceClass, source, sourceFieldVals); + } catch (Exception e) { + sourceValue = null; + log.error("Apply source value error", e); + } + + // 源对象中无值, 跳过取值 + if (sourceValue == null || sourceValue.getActualVal() == null) { + continue; + } + + // 放入目标对象字段 + putTargetFieldVal(targetField, sourceValue, targetClass, target, targetFieldVals); + } + return fillBuilder.entity(target).fields(targetFieldVals).build(); + } + + /** + * 属性转方法 (name -> setName) + * + * @param param 属性名 + * @return 方法名 + */ + private String capitalizeSetParam(String param) { + return "set" + param.substring(0, 1).toUpperCase() + param.substring(1); + } + + /** + * 属性转方法 (name -> getName) + * + * @param param 属性名 + * @return 方法名 + */ + private String capitalizeGetParam(String param) { + return "get" + param.substring(0, 1).toUpperCase() + param.substring(1); + } + + /** + * 选项值转文本 + * + * @param options 选项集合 + * @param value 值 + * @return 文本 + */ + private Object val2Text(List options, Object value) { + if (CollectionUtils.isEmpty(options) || value == null) { + return null; + } + Map optionMap = options.stream().collect(Collectors.toMap(OptionProp::getValue, OptionProp::getLabel)); + if (value instanceof List) { + return ((List) value).stream().map(v -> optionMap.get(v.toString())).toList(); + } else { + return optionMap.get(value.toString()); + } + } + + /** + * 选项文本转值 + * + * @param options 选项集合 + * @param text 文本 + * @return 值 + */ + private Object text2Val(List options, Object text) { + if (CollectionUtils.isEmpty(options) || text == null) { + return null; + } + Map optionMap = options.stream().collect(Collectors.toMap(OptionProp::getLabel, OptionProp::getValue)); + if (text instanceof List) { + return ((List) text).stream().map(v -> optionMap.get(v.toString())).filter(Objects::nonNull).toList(); + } else { + return optionMap.get(text.toString()); + } + } + + /** + * 从源对象取值 + * + * @param sourceField 来源字段 + * @param sourceClass 类对象 + * @param source 数据来源 + * @param sourceFieldVals 自定义数据来源 + * @return 值 + * @throws Exception 取值异常 + */ + private TransformSourceApplyDTO applySourceValue(BaseField sourceField, Class sourceClass, Object source, List sourceFieldVals) throws Exception { + // 来源字段取值 + TransformSourceApplyDTO sourceApply = new TransformSourceApplyDTO(); + Object tmpVal; + if (StringUtils.isNotEmpty(sourceField.getBusinessKey())) { + // 业务字段取值 + tmpVal = sourceClass.getMethod(capitalizeGetParam(sourceField.getBusinessKey())).invoke(source); + } else { + // 自定义字段取值 + Optional find = sourceFieldVals.stream().filter(fieldVal -> Strings.CS.equals(sourceField.getId(), fieldVal.getFieldId())).findFirst(); + tmpVal = find.map(BaseModuleFieldValue::getFieldValue).orElse(null); + } + sourceApply.setActualVal(tmpVal); + // 取展示值 + if (tmpVal == null) { + sourceApply.setDisplayVal(null); + } else { + sourceApply.setDisplayVal(displayOfType(sourceField, sourceApply.getActualVal())); + } + if (sourceField instanceof InputNumberField) { + sourceApply.setActualVal(sourceApply.getDisplayVal()); + } + return sourceApply; + } + + /** + * 放入目标对象字段值 + * + * @param targetField 字段 + * @param putVal 放入值 + * @param targetClass 目标类 + * @param target 目标实例 + * @param targetFieldVals 目标自定义字段值集合 + * @throws Exception 入值异常 + */ + private void putTargetFieldVal(BaseField targetField, TransformSourceApplyDTO putVal, Class targetClass, Object target, List targetFieldVals) throws Exception { + Object val = resolveTargetPutVal(targetField, putVal); + if (val == null) { + return; + } + if (StringUtils.isNotEmpty(targetField.getBusinessKey())) { + // 目标字段是业务字段 + Method method = targetClass.getMethod(capitalizeSetParam(targetField.getBusinessKey()), targetClass.getDeclaredField(targetField.getBusinessKey()).getType()); + method.invoke(target, val); + } else { + // 目标字段是自定义字段 + BaseModuleFieldValue targetFieldVal = new BaseModuleFieldValue(); + targetFieldVal.setFieldId(targetField.getId()); + targetFieldVal.setFieldValue(val); + targetFieldVals.add(targetFieldVal); + } + } + + /** + * 根据类型获取展示值 + * + * @param sourceField 来源字段 + * @param actualVal 实际值 + * @return 展示值 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private Object displayOfType(BaseField sourceField, Object actualVal) { + if (actualVal == null) { + return null; + } + if (sourceField instanceof HasOption fieldWithOption) { + return val2Text(fieldWithOption.getOptions(), actualVal); + } + AbstractModuleFieldResolver customFieldResolver = ModuleFieldResolverFactory.getResolver(sourceField.getType()); + // 将数据库中的字符串值,转换为对应的对象值 + return customFieldResolver.transformToValue(sourceField, actualVal instanceof List ? JSON.toJSONString(actualVal) : actualVal.toString()); + } + + /** + * 解析目标字段值 + * + * @param targetField 目标字段 + * @param sourceVal 来源值 + * @return 值 + */ + @SuppressWarnings({"unchecked"}) + public Object resolveTargetPutVal(BaseField targetField, TransformSourceApplyDTO sourceVal) { + if (targetField.multiple() && sourceVal.getActualVal() instanceof String) { + // 兼容处理: 单值映射多值的情况 + sourceVal.setActualVal(List.of(sourceVal.getActualVal())); + sourceVal.setDisplayVal(List.of(sourceVal.getDisplayVal())); + } + if (targetField instanceof InputField || targetField instanceof TextAreaField) { + // 兼容处理: [文本, 多行文本] 按照展示值处理即可. + Object displayVal = sourceVal.getDisplayVal(); + if (displayVal == null) { + return null; + } + String displayStr; + if (displayVal instanceof List) { + displayStr = String.join(",", (List) displayVal); + } else { + displayStr = displayVal.toString(); + } + if (targetField instanceof InputField) { + return new TextResolver().getCorrectInputString(displayStr); + } else { + return displayStr; + } + } + switch (targetField) { + case InputMultipleField ignored -> { + TextMultipleResolver textMultipleResolver = new TextMultipleResolver(); + return textMultipleResolver.getCorrectFormatInput( + sourceVal.getDisplayVal() instanceof List + ? (List) sourceVal.getDisplayVal() : List.of(sourceVal.getDisplayVal().toString().split(",")) + ); + } + case HasOption targetFieldWithOption -> { + return text2Val(targetFieldWithOption.getOptions(), sourceVal.getDisplayVal()); + } + case InputNumberField ignored -> { + String actualVal = sourceVal.getActualVal().toString(); + if (StringUtils.isEmpty(actualVal)) { + return null; + } + if (actualVal.contains("%") || actualVal.contains(",")) { + actualVal = actualVal.replace(",", "").replace("%", ""); + } + try { + return new BigDecimal(actualVal); + } catch (NumberFormatException e) { + log.error("Invalid source value: {}", actualVal, e); + return null; + } + } + default -> { + } + } + return sourceVal.getActualVal(); + } + + /** + * 表单联动处理(旧数据) + */ + @SuppressWarnings("unchecked") + public void modifyFormLinkProp() { + List moduleForms = moduleFormMapper.selectAll(null); + List formIds = moduleForms.stream().map(ModuleForm::getId).toList(); + List moduleFormBlobs = moduleFormBlobMapper.selectByIds(formIds); + for (ModuleFormBlob formBlob : moduleFormBlobs) { + Map propMap = JSON.parseMap(formBlob.getProp()); + Object linkProp = propMap.get("linkProp"); + if (linkProp == null) { + continue; + } + Map linkPropMap = (Map) linkProp; + if (linkPropMap.containsKey("formKey") && linkPropMap.containsKey("linkFields")) { + Map> dataMap = new HashMap<>(2); + String formKey = linkPropMap.get("formKey").toString(); + List linkFields = (List) linkPropMap.get("linkFields"); + dataMap.put(formKey, linkFields); + propMap.put("linkProp", dataMap); + formBlob.setProp(JSON.toJSONString(propMap)); + moduleFormBlobMapper.updateById(formBlob); + } + } + } + + /** + * 处理表单联动的旧数据&&支持多场景 (客户&商机&记录) + */ + @SuppressWarnings("unchecked") + public void processOldLinkData() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(ModuleForm::getFormKey, List.of(FormKey.CUSTOMER.getKey(), FormKey.OPPORTUNITY.getKey())); + List forms = moduleFormMapper.selectListByLambda(wrapper); + Map formKeyMap = forms.stream().collect(Collectors.toMap(ModuleForm::getId, ModuleForm::getFormKey)); + List moduleFormBlobs = moduleFormBlobMapper.selectByIds(formKeyMap.keySet().stream().toList()); + for (ModuleFormBlob formBlob : moduleFormBlobs) { + Map propMap = JSON.parseMap(formBlob.getProp()); + Object linkProp = propMap.get("linkProp"); + Map> dataMap = new HashMap<>(2); + String formKey = formKeyMap.get(formBlob.getId()); + if (linkProp == null) { + if (Strings.CS.equals(formKey, FormKey.CUSTOMER.getKey())) { + dataMap.put(FormKey.CLUE.getKey(), List.of(LinkScenario.builder().key(LinkScenarioKey.CLUE_TO_CUSTOMER.name()).linkFields(new ArrayList<>()).build())); + } else if (Strings.CS.equals(formKey, FormKey.OPPORTUNITY.getKey())) { + dataMap.put(FormKey.CLUE.getKey(), List.of(LinkScenario.builder().key(LinkScenarioKey.CLUE_TO_OPPORTUNITY.name()).linkFields(new ArrayList<>()).build())); + dataMap.put(FormKey.CUSTOMER.getKey(), List.of(LinkScenario.builder().key(LinkScenarioKey.CUSTOMER_TO_OPPORTUNITY.name()).linkFields(new ArrayList<>()).build())); + } + } else { + Map linkPropMap = (Map) linkProp; + for (Map.Entry entry : linkPropMap.entrySet()) { + if (StringUtils.isBlank(entry.getKey()) || entry.getValue() == null || !(entry.getValue() instanceof List)) { + continue; + } + List> fields = (List>) entry.getValue(); + List fieldList = fields.stream().map(field -> { + field.put("enable", true); + LinkField linkField = new LinkField(); + try { + org.apache.commons.beanutils.BeanUtils.populate(linkField, field); + } catch (IllegalAccessException | InvocationTargetException e) { + log.error("Populate old link field error", e); + } + return linkField; + }).toList(); + String scenarioKey = (Strings.CS.equals(formKey, FormKey.CUSTOMER.getKey()) && Strings.CS.equals(entry.getKey(), FormKey.CLUE.getKey()) ? + LinkScenarioKey.CLUE_TO_CUSTOMER.name() : + (Strings.CS.equals(formKey, FormKey.OPPORTUNITY.getKey()) && Strings.CS.equals(entry.getKey(), FormKey.CLUE.getKey()) ? + LinkScenarioKey.CLUE_TO_OPPORTUNITY.name() : LinkScenarioKey.CUSTOMER_TO_OPPORTUNITY.name())); + LinkScenario linkScenario = LinkScenario.builder().key(scenarioKey).linkFields(fieldList).build(); + dataMap.put(entry.getKey(), List.of(linkScenario)); + } + } + propMap.put("linkProp", dataMap); + formBlob.setProp(JSON.toJSONString(propMap)); + moduleFormBlobMapper.updateById(formBlob); + } + } + + /** + * 初始化跟进记录表单联动场景 + */ + @SuppressWarnings("unchecked") + public void initFormScenarioProp() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ModuleForm::getFormKey, FormKey.FOLLOW_RECORD.getKey()); + ModuleForm recordForm = moduleFormMapper.selectListByLambda(wrapper).getFirst(); + ModuleFormBlob recordFormBlob = moduleFormBlobMapper.selectByPrimaryKey(recordForm.getId()); + Map propMap = JSON.parseMap(recordFormBlob.getProp()); + Object linkProp = propMap.get("linkProp"); + Map> dataMap = new HashMap<>(4); + dataMap.put(FormKey.CLUE.getKey(), List.of(LinkScenario.builder().key(LinkScenarioKey.CLUE_TO_RECORD.name()).linkFields(new ArrayList<>()).build())); + dataMap.put(FormKey.CUSTOMER.getKey(), List.of(LinkScenario.builder().key(LinkScenarioKey.CUSTOMER_TO_RECORD.name()).linkFields(new ArrayList<>()).build())); + dataMap.put(FormKey.OPPORTUNITY.getKey(), List.of(LinkScenario.builder().key(LinkScenarioKey.OPPORTUNITY_TO_RECORD.name()).linkFields(new ArrayList<>()).build())); + if (linkProp != null) { + Map linkPropMap = (Map) linkProp; + List> fields = (List>) linkPropMap.get(FormKey.FOLLOW_PLAN.getKey()); + List fieldList = fields.stream().map(field -> { + LinkField linkField = JSON.parseObject(JSON.toJSONString(field), LinkField.class); + linkField.setEnable(true); + return linkField; + }).toList(); + dataMap.put(FormKey.FOLLOW_PLAN.getKey(), List.of(LinkScenario.builder().key(LinkScenarioKey.PLAN_TO_RECORD.name()).linkFields(fieldList).build())); + } else { + dataMap.put(FormKey.FOLLOW_PLAN.getKey(), List.of(LinkScenario.builder().key(LinkScenarioKey.PLAN_TO_RECORD.name()).linkFields(new ArrayList<>()).build())); + } + propMap.put("linkProp", dataMap); + recordFormBlob.setProp(JSON.toJSONString(propMap)); + moduleFormBlobMapper.updateById(recordFormBlob); + } + + /** + * 初始化发票表单联动场景 + */ + @SuppressWarnings("unchecked") + public void initInvoiceFormScenarioProp() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ModuleForm::getFormKey, FormKey.INVOICE.getKey()); + ModuleForm invoiceForm = moduleFormMapper.selectListByLambda(wrapper).getFirst(); + ModuleFormBlob invoiceFormBlob = moduleFormBlobMapper.selectByPrimaryKey(invoiceForm.getId()); + Map propMap = JSON.parseMap(invoiceFormBlob.getProp()); + List contractLinkProp = List.of(LinkScenario.builder().key(LinkScenarioKey.CONTRACT_TO_INVOICE.name()).linkFields(List.of()).build()); + propMap.put("linkProp", Map.of(FormKey.CONTRACT.getKey(), contractLinkProp)); + invoiceFormBlob.setProp(JSON.toJSONString(propMap)); + moduleFormBlobMapper.updateById(invoiceFormBlob); + } + + /** + * 初始化订单表单联动场景 + */ + @SuppressWarnings("unchecked") + public void initOrderFormScenarioProp() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ModuleForm::getFormKey, FormKey.ORDER.getKey()); + ModuleForm orderForm = moduleFormMapper.selectListByLambda(wrapper).getFirst(); + ModuleFormBlob orderFormBlob = moduleFormBlobMapper.selectByPrimaryKey(orderForm.getId()); + Map propMap = JSON.parseMap(orderFormBlob.getProp()); + List contractLinkProp = List.of(LinkScenario.builder().key(LinkScenarioKey.CONTRACT_TO_ORDER.name()).linkFields(List.of()).build()); + propMap.put("linkProp", Map.of(FormKey.CONTRACT.getKey(), contractLinkProp)); + orderFormBlob.setProp(JSON.toJSONString(propMap)); + moduleFormBlobMapper.updateById(orderFormBlob); + } + + /** + * 表单属性处理(视图) + */ + @SuppressWarnings("unchecked") + public void modifyFormProp() { + List moduleForms = moduleFormMapper.selectAll(null); + List formIds = moduleForms.stream().map(ModuleForm::getId).toList(); + List moduleFormBlobs = moduleFormBlobMapper.selectByIds(formIds); + for (ModuleFormBlob formBlob : moduleFormBlobs) { + Map propMap = JSON.parseMap(formBlob.getProp()); + if (propMap.containsKey("viewSize")) { + continue; + } + propMap.put("viewSize", "large"); + formBlob.setProp(JSON.toJSONString(propMap)); + moduleFormBlobMapper.updateById(formBlob); + } + } + + @SuppressWarnings("unchecked") + public void modifyFieldMobile() { + List moduleFields = moduleFieldMapper.selectAll(null); + List fieldIds = moduleFields.stream().map(ModuleField::getId).toList(); + List moduleFieldBlobs = moduleFieldBlobMapper.selectByIds(fieldIds); + for (ModuleFieldBlob fieldBlob : moduleFieldBlobs) { + Map propMap = JSON.parseMap(fieldBlob.getProp()); + propMap.put("mobile", true); + fieldBlob.setProp(JSON.toJSONString(propMap)); + moduleFieldBlobMapper.updateById(fieldBlob); + } + extModuleFieldMapper.batchUpdateMobile(fieldIds, true); + } + + /** + * 获取MCP表单需要的字段 + * + * @param formKey 表单Key + * @param organizationId 组织ID + * @return 字段列表 + */ + public List getMcpFields(String formKey, String organizationId) { + ModuleFormConfigDTO businessFormConfig = getBusinessFormConfig(formKey, organizationId); + return businessFormConfig.getFields().stream().filter(BaseField::canImport).map(field -> { + SimpleField simpleField = new SimpleField(); + simpleField.setId(field.getId()); + simpleField.setBusinessKey(field.getBusinessKey()); + simpleField.setName(field.getName()); + simpleField.setType(field.getType()); + simpleField.setRequired(field.needRequireCheck()); + simpleField.setShowControlRules(field.getShowControlRules()); + if (field instanceof HasOption fieldWithOption) { + simpleField.setOptions(fieldWithOption.getOptions()); + } + if (field instanceof DatasourceField datasourceField) { + simpleField.setDataSourceType(datasourceField.getDataSourceType()); + } else if (field instanceof LocationField locationField) { + simpleField.setLocationType(locationField.getLocationType()); + } else if (field instanceof DateTimeField dateTimeField) { + simpleField.setDateType(dateTimeField.getDateType()); + } + return simpleField; + }).toList(); + } + + /** + * 业务Key => 字段ID (子字段) + * + * @param fieldValues 自定义字段值 + * @param formConfig 字段配置 + * @return 处理后的自定义字段值 + */ + public List resolveSnapshotFields(List fieldValues, + ModuleFormConfigDTO formConfig, BaseResourceFieldService baseResourceFieldService, String resourceId) { + // 1. 扁平化所有字段 + final List flattenFields = flattenFormAllFields(formConfig); + List resolveFvs = resolveSubKeyToId(fieldValues, flattenFields); + + // 2. 处理数据源引用字段 + List resolveRefFvs = resolveRefFields(resolveFvs, flattenFields, baseResourceFieldService); + + // 3. 补充流水号字段 + List fs = flattenFields.stream().filter(BaseField::isSerialNumber).toList(); + for (BaseField bf : fs) { + if (StringUtils.isEmpty(bf.getBusinessKey())) { + Object serialVal = baseResourceFieldService.getResourceFieldValue(resourceId, bf.getId()); + if (serialVal != null) { + resolveRefFvs.removeIf(rvs -> Strings.CS.equals(rvs.getFieldId(), bf.getId())); + resolveRefFvs.add(new BaseModuleFieldValue(bf.getId(), serialVal)); + } + } + } + + return resolveRefFvs; + } + + /** + * 处理数据源引用字段 + * + * @param resolveFvs 字段值列表 + * @param flattenFields 平铺字段配置 + * @param baseResourceFieldService 字段服务类 + * @param 字段类型 + * @param 大字段类型 + * @return 处理后的字段值列表 + */ + @SuppressWarnings("unchecked") + private List resolveRefFields( + List resolveFvs, List flattenFields, + BaseResourceFieldService baseResourceFieldService) { + // 1. 数据源字段配置映射 && 字段配置映射 + final Map sourceConfigMap = flattenFields + .stream() + .filter(f -> f instanceof DatasourceField ds && CollectionUtils.isNotEmpty(ds.getShowFields())) + .collect(Collectors.toMap(BaseField::idOrBusinessKey, f -> f, (f1, f2) -> f1)); + + final Map fieldMap = flattenFields + .stream() + .collect(Collectors.toMap(BaseField::getId, f -> f, (f1, f2) -> f1)); + + // 2. 处理引用字段值 + List reFvs = new ArrayList<>(); + resolveFvs.forEach(fv -> { + if (fv.getFieldValue() == null) { + return; + } + final String fieldId = fv.getFieldId(); + // 数据源字段(普通字段) + if (sourceConfigMap.containsKey(fieldId)) { + final DatasourceField sourceField = (DatasourceField) sourceConfigMap.get(fieldId); + final FieldSourceType sourceType = FieldSourceType.safeValueOf(sourceField.getDataSourceType()); + final Object detail; + if (sourceType == FieldSourceType.CUSTOM_FORM) { + // 自定义表单:设置 formId 到 ThreadLocal + try { + CustomFormDataFieldService.setFormKey(sourceField.getDataSourceType()); + detail = fieldSourceServiceProvider.safeGetSimpleById(sourceType, fv.getFieldValue().toString()); + } finally { + CustomFormDataFieldService.clearFormKey(); + } + } else { + detail = fieldSourceServiceProvider.safeGetSimpleById(sourceType, fv.getFieldValue().toString()); + } + if (detail == null) { + return; + } + + final Map detailMap = JSON.MAPPER.convertValue(detail, Map.class); + sourceField.getShowFields().forEach(refId -> { + final BaseField showFieldConf = fieldMap.get(sourceField.getId() + REF_UNDERLINE + refId); + if (showFieldConf != null) { + final Object val = baseResourceFieldService.getFieldValueOfDetailMap(showFieldConf, detailMap, sourceField); + if (val != null) { + reFvs.add(new BaseModuleFieldValue(showFieldConf.getId(), val)); + } + } + }); + return; + } + + // 子字段嵌套数据源字段 + if (fieldMap.containsKey(fieldId) && fieldMap.get(fieldId).isSubField()) { + final List> subValues = (List>) fv.getFieldValue(); + subValues.forEach(sfv -> { + final Map showFieldMap = new HashMap<>(8); + sfv.forEach((k, v) -> { + if (v == null || !sourceConfigMap.containsKey(k)) { + return; + } + + final DatasourceField sourceField = (DatasourceField) sourceConfigMap.get(k); + final FieldSourceType sourceType = FieldSourceType.safeValueOf(sourceField.getDataSourceType()); + final Object detail; + if (sourceType == FieldSourceType.CUSTOM_FORM) { + // 自定义表单:设置 formId 到 ThreadLocal + try { + CustomFormDataFieldService.setFormKey(sourceField.getDataSourceType()); + detail = fieldSourceServiceProvider.safeGetSimpleById(sourceType, v.toString()); + } finally { + CustomFormDataFieldService.clearFormKey(); + } + } else { + detail = fieldSourceServiceProvider.safeGetSimpleById(sourceType, v.toString()); + } + + final Map detailMap = JSON.MAPPER.convertValue(detail, Map.class); + if (MapUtils.isEmpty(detailMap)) { + return; + } + + sourceField.getShowFields().forEach(id -> { + final BaseField showFieldConf = fieldMap.get(sourceField.getId() + REF_UNDERLINE + id); + if (showFieldConf == null) { + return; + } + if (StringUtils.isNotEmpty(showFieldConf.getSubTableFieldId()) && sfv.containsKey(PRICE_SUB_ROW_KEY)) { + Object matchVal = baseResourceFieldService.matchSubFieldValueOfDetailMap( + showFieldConf.idOrBusinessKey(), + detailMap, + BusinessModuleField.PRICE_PRODUCT_TABLE.getBusinessKey(), + sfv.get(PRICE_SUB_ROW_KEY).toString() + ); + + if (matchVal != null) { + showFieldMap.put(showFieldConf.getId(), matchVal); + } + } else { + showFieldMap.put(showFieldConf.getId(), baseResourceFieldService.getFieldValueOfDetailMap(showFieldConf, detailMap, sourceField)); + } + }); + }); + sfv.putAll(showFieldMap); + }); + } + }); + if (!reFvs.isEmpty()) { + resolveFvs.addAll(reFvs); + } + return resolveFvs; + } + + /** + * 解析子表格Key=>ID + * + * @param fvs 所有字段值 + * @param flattenFields 所有字段配置 + * @return 处理后的字段值 + */ + private List resolveSubKeyToId(List fvs, List flattenFields) { + // 获取替换后的子表格值 + final Map subFieldConfigMap = flattenFields + .stream() + .filter(f -> f instanceof SubField) + .collect(Collectors.toMap(BaseField::idOrBusinessKey, f -> f)); + + final List subFieldValues = fvs.stream() + .filter(fv -> subFieldConfigMap.containsKey(fv.getFieldId()) && subFieldConfigMap.get(fv.getFieldId()).isSubField()) + .map(fv -> new BaseModuleFieldValue( + subFieldConfigMap.get(fv.getFieldId()).getId(), + fv.getFieldValue() + )).toList(); + + // 删除原 subField 项,并加入替换后的 + fvs.removeIf(fv -> subFieldConfigMap.containsKey(fv.getFieldId()) + && subFieldConfigMap.get(fv.getFieldId()).isSubField()); + + if (!subFieldValues.isEmpty()) { + fvs.addAll(subFieldValues); + } + return fvs; + } + + /** + * 获取字段配置映射 (name => BaseField) + * + * @param formKey 表单Key + * @param orgId 组织ID + * @return 配置映射 + */ + private Map getFieldConfigMapByName(String formKey, String orgId) { + List allFields = getAllFields(formKey, orgId); + if (CollectionUtils.isEmpty(allFields)) { + return Map.of(); + } + return allFields.stream().collect(Collectors.toMap(BaseField::getName, Function.identity(), (p, n) -> p)); + } + + /** + * 组合引用字段配置 + * @param old 旧字段 + * @param ref 引用字段 + * @return 组合后的字段配置 + */ + private BaseField combineFieldsProps(BaseField old, BaseField ref) { + if (ref.isSys()) { + // 系统字段, 直接返回前端引用的字段配置即可, 后端不组装字段 + return old; + } + // 深拷贝 ref 对象, 避免修改原始对象 + BaseField refCopy = JSON.parseObject(JSON.toJSONString(ref), BaseField.class); + // 保留一些可用的属性 + refCopy.setName(old.getName()); + refCopy.setFieldWidth(old.getFieldWidth()); + refCopy.setResourceFieldId(old.getResourceFieldId()); + // 兼容新旧引用字段 + if (Strings.CI.contains(old.getId(), REF_UNDERLINE)) { + // 新版本引用字段, 直接替换 + refCopy.setId(old.getId()); + } else { + // 旧版本拼接 + refCopy.setId(old.getResourceFieldId() + REF_UNDERLINE + old.getId()); + } + // 清空多级引用的属性, 禁止数据源引用数据源 + if (refCopy instanceof DatasourceField refSourceField) { + refSourceField.setRefFields(null); + refSourceField.setShowFields(null); + } + return refCopy; + } + + /** + * 引用字段ID截取 + * @param sourceId 数据源ID + * @return 截取后的字段ID + */ + private Function splitRefId(String sourceId) { + return fieldId -> { + if (StringUtils.isEmpty(fieldId)) { + return fieldId; + } + int idx = fieldId.indexOf(sourceId + REF_UNDERLINE); + return idx >= 0 ? fieldId.substring(idx + sourceId.length() + REF_UNDERLINE.length()) : fieldId; + }; + } + + /** + * 获取业务数据详情 + * @param formKey 表单Key + * @param resourceId 资源ID + * @return 通用的条件值 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public List compressResourceDetail(String formKey, String resourceId) { + List fvs = new ArrayList<>(); + Object resourceDetail = fieldSourceServiceProvider.safeGetSimpleById(formKey, resourceId); + if (resourceDetail == null) { + return fvs; + } + Map detailMap = JSON.MAPPER.convertValue(resourceDetail, Map.class); + if (org.apache.commons.collections.MapUtils.isEmpty(detailMap)) { + return fvs; + } + + detailMap.forEach((k, v) -> { + if (Strings.CI.equals(BaseResourceFieldService.DETAIL_FIELD_PARAM_NAME, k)) { + List moduleFieldValues = (List) v; + if (org.apache.commons.collections.CollectionUtils.isNotEmpty(moduleFieldValues)) { + for (Map mfv : moduleFieldValues) { + BaseModuleFieldValue bfv = new BaseModuleFieldValue(); + bfv.setFieldId(mfv.get("fieldId").toString()); + bfv.setFieldValue(mfv.get("fieldValue")); + fvs.add(bfv); + } + } + } else { + BaseModuleFieldValue bfv = new BaseModuleFieldValue(); + bfv.setFieldId(k); + bfv.setFieldValue(v); + fvs.add(bfv); + } + }); + + return fvs; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + public List compressResourceRefDetail(String formKey, String resourceId) { + List fvs = new ArrayList<>(); + Object resourceDetail = fieldSourceServiceProvider.safeGetFieldsById(formKey, resourceId); + if (resourceDetail == null) { + return fvs; + } + Map detailMap = JSON.MAPPER.convertValue(resourceDetail, Map.class); + if (org.apache.commons.collections.MapUtils.isEmpty(detailMap)) { + return fvs; + } + + detailMap.forEach((k, v) -> { + if (Strings.CI.equals(BaseResourceFieldService.DETAIL_FIELD_PARAM_NAME, k)) { + List moduleFieldValues = (List) v; + if (org.apache.commons.collections.CollectionUtils.isNotEmpty(moduleFieldValues)) { + for (Map mfv : moduleFieldValues) { + BaseModuleFieldValue bfv = new BaseModuleFieldValue(); + bfv.setFieldId(mfv.get("fieldId").toString()); + bfv.setFieldValue(mfv.get("fieldValue")); + fvs.add(bfv); + } + } + } else { + BaseModuleFieldValue bfv = new BaseModuleFieldValue(); + bfv.setFieldId(k); + bfv.setFieldValue(v); + fvs.add(bfv); + } + }); + + return fvs; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleLogServiceFactory.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleLogServiceFactory.java new file mode 100644 index 0000000..201f59f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleLogServiceFactory.java @@ -0,0 +1,53 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.crm.approval.service.ApprovalFlowLogService; +import cn.cordys.crm.clue.service.ClueLogService; +import cn.cordys.crm.contract.service.ContractLogService; +import cn.cordys.crm.customer.service.*; +import cn.cordys.crm.follow.service.FollowUpPlanLogService; +import cn.cordys.crm.follow.service.FollowUpRecordLogService; +import cn.cordys.crm.form.service.CustomFormDataLogService; +import cn.cordys.crm.form.service.CustomFormLogService; +import cn.cordys.crm.opportunity.service.OpportunityLogService; +import cn.cordys.crm.opportunity.service.OpportunityQuotationalLogService; +import cn.cordys.crm.order.service.OrderLogService; +import cn.cordys.crm.product.service.PriceLogService; +import cn.cordys.crm.product.service.ProductLogService; + +import java.util.HashMap; + +public class ModuleLogServiceFactory { + + private static final HashMap logServiceMap = new HashMap<>(); + + static { + logServiceMap.put(LogModule.CUSTOMER_INDEX, CommonBeanFactory.getBean(CustomerLogService.class)); + logServiceMap.put(LogModule.CUSTOMER_POOL, CommonBeanFactory.getBean(CustomerLogService.class)); + logServiceMap.put(LogModule.CUSTOMER_CONTACT, CommonBeanFactory.getBean(CustomerContactLogService.class)); + logServiceMap.put(LogModule.OPPORTUNITY_INDEX, CommonBeanFactory.getBean(OpportunityLogService.class)); + logServiceMap.put(LogModule.OPPORTUNITY_QUOTATION, CommonBeanFactory.getBean(OpportunityQuotationalLogService.class)); + logServiceMap.put(LogModule.SYSTEM_ORGANIZATION, CommonBeanFactory.getBean(OrganizationLogService.class)); + logServiceMap.put(LogModule.PRODUCT_MANAGEMENT, CommonBeanFactory.getBean(ProductLogService.class)); + logServiceMap.put(LogModule.PRODUCT_PRICE_MANAGEMENT, CommonBeanFactory.getBean(PriceLogService.class)); + logServiceMap.put(LogModule.CONTRACT_INDEX, CommonBeanFactory.getBean(ContractLogService.class)); + logServiceMap.put(LogModule.CLUE_INDEX, CommonBeanFactory.getBean(ClueLogService.class)); + logServiceMap.put(LogModule.CLUE_POOL_INDEX, CommonBeanFactory.getBean(ClueLogService.class)); + logServiceMap.put(LogModule.FOLLOW_UP_RECORD, CommonBeanFactory.getBean(FollowUpRecordLogService.class)); + logServiceMap.put(LogModule.FOLLOW_UP_PLAN, CommonBeanFactory.getBean(FollowUpPlanLogService.class)); + logServiceMap.put(LogModule.SYSTEM_ROLE, CommonBeanFactory.getBean(RoleLogService.class)); + logServiceMap.put(LogModule.SYSTEM_MODULE, CommonBeanFactory.getBean(SystemModuleLogService.class)); + logServiceMap.put(LogModule.CONTRACT_PAYMENT, CommonBeanFactory.getBean(ContractPaymentPlanLogService.class)); + logServiceMap.put(LogModule.CONTRACT_INVOICE, CommonBeanFactory.getBean(ContractInvoiceLogService.class)); + logServiceMap.put(LogModule.ORDER_INDEX, CommonBeanFactory.getBean(OrderLogService.class)); + logServiceMap.put(LogModule.APPROVAL_FLOW, CommonBeanFactory.getBean(ApprovalFlowLogService.class)); + logServiceMap.put(LogModule.CUSTOM_FORM, CommonBeanFactory.getBean(CustomFormLogService.class)); + logServiceMap.put(LogModule.CUSTOM_FORM_DATA, CommonBeanFactory.getBean(CustomFormDataLogService.class)); + + } + + public static BaseModuleLogService getModuleLogService(String type) { + return logServiceMap.get(type); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleService.java new file mode 100644 index 0000000..ce1cb5f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/ModuleService.java @@ -0,0 +1,325 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.ModuleKey; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.DeptUserTreeNode; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.RoleUserTreeNode; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.constants.OrganizationConfigConstants; +import cn.cordys.crm.system.domain.Module; +import cn.cordys.crm.system.domain.OrganizationConfig; +import cn.cordys.crm.system.domain.OrganizationConfigDetail; +import cn.cordys.crm.system.domain.Parameter; +import cn.cordys.crm.system.dto.ModuleDTO; +import cn.cordys.crm.system.dto.request.ModuleRequest; +import cn.cordys.crm.system.dto.request.ModuleSortRequest; +import cn.cordys.crm.system.dto.response.RoleListResponse; +import cn.cordys.crm.system.mapper.ExtDepartmentMapper; +import cn.cordys.crm.system.mapper.ExtModuleMapper; +import cn.cordys.crm.system.mapper.ExtUserRoleMapper; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class ModuleService { + + private static final String DEFAULT_ORGANIZATION_ID = "100001"; + @Resource + private BaseMapper moduleMapper; + @Resource + private ExtModuleMapper extModuleMapper; + @Resource + private ExtDepartmentMapper extDepartmentMapper; + @Resource + private ExtUserRoleMapper extUserRoleMapper; + @Resource + private RoleService roleService; + @Resource + private DepartmentService departmentService; + @Resource + private BaseMapper organizationConfigMapper; + @Resource + private BaseMapper organizationConfigDetailMapper; + @Resource + private BaseMapper parameterMapper; + + /** + * 获取系统模块配置列表 + * + * @param request 请求参数 + * @return 模块配置列表 + */ + public List getModuleList(ModuleRequest request) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(Module::getOrganizationId, request.getOrganizationId()); + List modules = moduleMapper.selectListByLambda(queryWrapper); + return modules.stream() + .map(module -> { + ModuleDTO dto = new ModuleDTO(); + BeanUtils.copyProperties(module, dto); + dto.setDisabled(Strings.CS.equals(dto.getModuleKey(), "dashboard") && !isDashboardEnabled(request.getOrganizationId())); + return dto; + }) + .sorted(Comparator.comparing(ModuleDTO::getPos)) + .collect(Collectors.toList()); + } + + /** + * 获取表单集合 + * + * @return 表单集合 + */ + public List getFormList() { + return Arrays.stream(FormKey.values()).map(formKey -> new OptionDTO(formKey.getKey(), Translator.get(formKey.getKey()))).toList(); + } + + /** + * 单个模块开启或关闭 + * + * @param id 模块ID + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE, operator = "{#currentUser}") + public void switchModule(String id, String currentUser) { + Module module = moduleMapper.selectByPrimaryKey(id); + if (module == null) { + throw new GenericException(Translator.get("module.not_exist")); + } + module.setEnable(!module.getEnable()); + module.setUpdateUser(currentUser); + module.setUpdateTime(System.currentTimeMillis()); + moduleMapper.updateById(module); + + //添加日志上下文 + Map originalVal = new HashMap<>(1); + originalVal.put("module.switch", !module.getEnable() ? Translator.get("log.enable.true") : Translator.get("log.enable.false")); + Map modifiedVal = new HashMap<>(1); + modifiedVal.put("module.switch", module.getEnable() ? Translator.get("log.enable.true") : Translator.get("log.enable.false")); + OperationLogContext.setContext(LogContextInfo.builder() + .originalValue(originalVal) + .resourceName(Translator.get(module.getModuleKey()) + "模块开关") + .modifiedValue(modifiedVal) + .resourceId(module.getId()) + .build()); + } + + /** + * 模块排序 + * + * @param request 请求参数 + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE, operator = "{#currentUser}") + public void sort(ModuleSortRequest request, String currentUser) { + Module module = moduleMapper.selectByPrimaryKey(request.getDragModuleId()); + if (module == null) { + throw new GenericException(Translator.get("module.not_exist")); + } + List beforeKeys = getModuleSortKeys(module.getOrganizationId()); + if (request.getStart() < request.getEnd()) { + // start < end, 区间模块上移, pos - 1 + extModuleMapper.moveUpModule(request.getStart(), request.getEnd()); + } else { + // start > end, 区间模块下移, pos + 1 + extModuleMapper.moveDownModule(request.getEnd(), request.getStart()); + } + Module dragModule = new Module(); + dragModule.setId(request.getDragModuleId()); + dragModule.setPos(request.getEnd()); + dragModule.setUpdateUser(currentUser); + dragModule.setUpdateTime(System.currentTimeMillis()); + moduleMapper.updateById(dragModule); + List afterKeys = getModuleSortKeys(module.getOrganizationId()); + + //添加日志上下文 + Map> originalVal = new HashMap<>(1); + originalVal.put("sort", beforeKeys); + Map> modifiedVal = new HashMap<>(1); + modifiedVal.put("sort", afterKeys); + OperationLogContext.setContext(LogContextInfo.builder() + .originalValue(originalVal) + .resourceName(Translator.get("module.main.nav")) + .modifiedValue(modifiedVal) + .resourceId(module.getId()) + .build()); + } + + /** + * 获取带用户的信息的部门树 + * + * @return List + */ + public List getDeptUserTree(String orgId) { + List treeNodes = extDepartmentMapper.selectDeptUserTreeNode(orgId); + List userNodes = extUserRoleMapper.selectUserDeptForOrg(orgId); + userNodes = departmentService.sortByCommander(orgId, userNodes); + userNodes.addAll(treeNodes); + return BaseTreeNode.buildTree(userNodes); + } + + /** + * 获取角色树 + * + * @param orgId 组织ID + * @return 角色树 + */ + public List getRoleTree(String orgId) { + // 查询角色信息 + List list = roleService.list(orgId); + List treeNodes = list.stream().map((role) -> { + RoleUserTreeNode roleNode = new RoleUserTreeNode(); + roleNode.setNodeType("ROLE"); + roleNode.setInternal(BooleanUtils.isTrue(role.getInternal())); + roleNode.setId(role.getId()); + roleNode.setName(role.getName()); + return roleNode; + }).collect(Collectors.toList()); + return BaseTreeNode.buildTree(treeNodes); + } + + /** + * 高级搜索开关设置 + * + * @return 是否开启高级搜索 + */ + public boolean getAdvancedSetting() { + Parameter parameter = parameterMapper.selectByPrimaryKey("advance.search.setting"); + return parameter != null && !Strings.CI.equals(parameter.getParamValue(), BooleanUtils.FALSE); + } + + /** + * 切换高级搜索开关设置 + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE, operator = "{#currentUser}") + public void switchAdvanced(String currentUser) { + Parameter parameter = parameterMapper.selectByPrimaryKey("advance.search.setting"); + + Map originalVal = new HashMap<>(1); + originalVal.put("module.switch", parameter != null && Strings.CI.equals(parameter.getParamValue(), BooleanUtils.TRUE) ? Translator.get("log.enable.true") : Translator.get("log.enable.false")); + if (parameter != null) { + parameterMapper.deleteByPrimaryKey("advance.search.setting"); + boolean current = Strings.CI.equals(parameter.getParamValue(), BooleanUtils.TRUE); + parameter.setParamValue(Boolean.toString(!current)); + } else { + parameter = new Parameter(); + parameter.setParamKey("advance.search.setting"); + parameter.setParamValue(BooleanUtils.FALSE); + parameter.setType("TEXT"); + } + parameterMapper.insert(parameter); + + //日志上下文 + Map modifiedVal = new HashMap<>(1); + modifiedVal.put("module.switch", Strings.CI.equals(parameter.getParamValue(), BooleanUtils.TRUE) ? Translator.get("log.enable.true") : Translator.get("log.enable.false")); + OperationLogContext.setContext(LogContextInfo.builder() + .originalValue(originalVal) + .resourceName(Translator.get("log.global.search")) + .modifiedValue(modifiedVal) + .resourceId(parameter.getParamKey()) + .build()); + } + + /** + * 初始化系统(组织或公司)模块数据 + */ + public void initModule(String organizationId) { + // init module data + List modules = new ArrayList<>(); + AtomicLong pos = new AtomicLong(1L); + Arrays.stream(ModuleKey.values()).forEach(moduleConstant -> { + Module module = new Module(); + module.setId(IDGenerator.nextStr()); + module.setModuleKey(moduleConstant.getKey()); + module.setOrganizationId(organizationId); + module.setEnable(true); + module.setPos(pos.getAndIncrement()); + module.setCreateUser(InternalUser.ADMIN.getValue()); + module.setCreateTime(System.currentTimeMillis()); + module.setUpdateUser(InternalUser.ADMIN.getValue()); + module.setUpdateTime(System.currentTimeMillis()); + modules.add(module); + }); + moduleMapper.batchInsert(modules); + } + + public void initDefaultOrgModule() { + initModule(DEFAULT_ORGANIZATION_ID); + } + + /** + * 获取排序之后的模块菜单 + * + * @param organizationId 组织ID + * @return 模块列表 + */ + private List getModuleSortKeys(String organizationId) { + ModuleRequest request = new ModuleRequest(); + request.setOrganizationId(organizationId); + List moduleList = getModuleList(request); + return moduleList.stream().map(Module::getModuleKey).toList(); + } + + /** + * 检查仪表盘功能是否启用 + * + * @param organizationId 组织ID + * @return 仪表盘是否启用 + */ + private boolean isDashboardEnabled(String organizationId) { + // 查询第三方配置 + LambdaQueryWrapper configQuery = new LambdaQueryWrapper<>(); + configQuery.eq(OrganizationConfig::getOrganizationId, organizationId) + .eq(OrganizationConfig::getType, OrganizationConfigConstants.ConfigType.THIRD.name()); + + // 直接获取第一个配置或返回null + OrganizationConfig config = organizationConfigMapper.selectListByLambda(configQuery) + .stream() + .findFirst() + .orElse(null); + + if (config == null) { + return false; + } + + // 查询仪表盘配置详情 + LambdaQueryWrapper detailQuery = new LambdaQueryWrapper<>(); + detailQuery.eq(OrganizationConfigDetail::getConfigId, config.getId()) + .like(OrganizationConfigDetail::getType, "DE_BOARD"); + + // 查找配置并判断是否启用 + return organizationConfigDetailMapper.selectListByLambda(detailQuery) + .stream() + .findFirst() + .map(OrganizationConfigDetail::getEnable) + .orElse(false); + } + + //删除多余模块 + public void deleteExtraModules() { + List contractFormList = extModuleMapper.selectModuleListByKeyOrderPosDesc(DEFAULT_ORGANIZATION_ID, "contract"); + if (CollectionUtils.isNotEmpty(contractFormList) && contractFormList.size() > 1) { + moduleMapper.deleteByPrimaryKey(contractFormList.getFirst().getId()); + } + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/MoveNodeService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/MoveNodeService.java new file mode 100644 index 0000000..5cdcf8f --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/MoveNodeService.java @@ -0,0 +1,119 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.dto.BaseTree; +import cn.cordys.common.dto.NodeSortCountResultDTO; +import cn.cordys.common.dto.NodeSortDTO; +import cn.cordys.common.dto.NodeSortQueryParam; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.util.NodeSortUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.dto.request.NodeMoveRequest; +import org.apache.commons.lang3.Strings; +import org.springframework.validation.annotation.Validated; + +import java.util.function.Function; + +public abstract class MoveNodeService { + + public static final String ROOT_NODE_PARENT_ID = "NONE"; + protected static final long LIMIT_POS = NodeSortUtils.DEFAULT_NODE_INTERVAL_POS; + private static final String MOVE_POS_OPERATOR_LESS = "lessThan"; + private static final String MOVE_POS_OPERATOR_MORE = "moreThan"; + private static final String MOVE_POS_OPERATOR_LATEST = "latest"; + private static final String DRAG_NODE_NOT_EXIST = "drag_node.not.exist"; + + public abstract void updatePos(String id, long pos); + + public abstract void refreshPos(String testPlanId); + + /** + * 构建节点排序的参数 + * + * @param request 拖拽的前端请求参数 + * @param selectIdNodeFunc 通过id查询节点的函数 + * @param selectPosNodeFunc 通过parentId和Pos运算符查询节点的函数 + * + * @return + */ + public NodeSortDTO getNodeSortDTO(NodeMoveRequest request, Function selectIdNodeFunc, Function selectPosNodeFunc, boolean isDepartment) { + if (Strings.CS.equals(request.getDragNodeId(), request.getDropNodeId())) { + //两种节点不能一样 + throw new GenericException(Translator.get("invalid_parameter") + ": drag node and drop node"); + } + + BaseTree dragNode = selectIdNodeFunc.apply(request.getDragNodeId()); + if (dragNode == null) { + throw new GenericException(Translator.get(DRAG_NODE_NOT_EXIST) + ":" + request.getDragNodeId()); + } + + BaseTree dropNode = selectIdNodeFunc.apply(request.getDropNodeId()); + if (dropNode == null) { + throw new GenericException(Translator.get(DRAG_NODE_NOT_EXIST) + ":" + request.getDropNodeId()); + + } + BaseTree parentModule; + BaseTree previousNode; + BaseTree nextNode = null; + if (request.getDropPosition() == 0) { + parentModule = new BaseTree(dropNode.getId(), dropNode.getName(), dropNode.getPos(), dropNode.getOrganizationId(), dropNode.getParentId()); + + NodeSortQueryParam sortParam = new NodeSortQueryParam(); + sortParam.setParentId(dropNode.getId()); + sortParam.setOperator(MOVE_POS_OPERATOR_LATEST); + previousNode = selectPosNodeFunc.apply(sortParam); + } else { + if (isDepartment) { + parentModule = selectIdNodeFunc.apply(dropNode.getParentId()); + if (parentModule == null) { + throw new GenericException(Translator.get("illegal_operation")); + } + } else { + if (Strings.CI.equals(dropNode.getParentId(), ROOT_NODE_PARENT_ID)) { + parentModule = new BaseTree(ROOT_NODE_PARENT_ID, ROOT_NODE_PARENT_ID, 0, dragNode.getOrganizationId(), ROOT_NODE_PARENT_ID); + } else { + parentModule = selectIdNodeFunc.apply(dropNode.getParentId()); + } + } + + if (request.getDropPosition() == 1) { + //dropPosition=1: 放到dropNode节点后,原dropNode后面的节点之前 + previousNode = dropNode; + + NodeSortQueryParam sortParam = new NodeSortQueryParam(); + sortParam.setParentId(parentModule.getId()); + sortParam.setPos(previousNode.getPos()); + sortParam.setOperator(MOVE_POS_OPERATOR_MORE); + nextNode = selectPosNodeFunc.apply(sortParam); + } else if (request.getDropPosition() == -1) { + //dropPosition=-1: 放到dropNode节点前,原dropNode前面的节点之后 + nextNode = dropNode; + + NodeSortQueryParam sortParam = new NodeSortQueryParam(); + sortParam.setParentId(parentModule.getId()); + sortParam.setPos(nextNode.getPos()); + sortParam.setOperator(MOVE_POS_OPERATOR_LESS); + previousNode = selectPosNodeFunc.apply(sortParam); + } else { + throw new GenericException(Translator.get("invalid_parameter") + ": dropPosition"); + } + } + + return new NodeSortDTO(dragNode, parentModule, previousNode, nextNode); + } + + //排序 + public void sort(@Validated NodeSortDTO nodeMoveDTO) { + // 获取相邻节点 + BaseTree previousNode = nodeMoveDTO.getPreviousNode(); + BaseTree nextNode = nodeMoveDTO.getNextNode(); + + NodeSortCountResultDTO countResultDTO = NodeSortUtils.countModuleSort( + previousNode == null ? -1 : previousNode.getPos(), + nextNode == null ? -1 : nextNode.getPos()); + updatePos(nodeMoveDTO.getNode().getId(), countResultDTO.getPos()); + if (countResultDTO.isRefreshPos()) { + refreshPos(nodeMoveDTO.getParent().getId()); + } + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/NavigationService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/NavigationService.java new file mode 100644 index 0000000..12a73c2 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/NavigationService.java @@ -0,0 +1,89 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.domain.Navigation; +import cn.cordys.crm.system.dto.request.ModuleSortRequest; +import cn.cordys.crm.system.mapper.ExtNavigationMapper; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@Transactional(rollbackFor = Exception.class) +public class NavigationService { + + @Resource + private BaseMapper navigationMapper; + @Resource + private ExtNavigationMapper extNavigationMapper; + + + /** + * 获取系统导航栏配置列表 + * + * @param orgId + * + * @return + */ + public List getNavigationList(String orgId) { + return extNavigationMapper.selectList(orgId); + } + + + /** + * 导航栏排序 + * + * @param request + * @param userId + */ + @OperationLog(module = LogModule.SYSTEM_MODULE, type = LogType.UPDATE, operator = "{#currentUser}") + public void sort(ModuleSortRequest request, String userId) { + Navigation navigation = navigationMapper.selectByPrimaryKey(request.getDragModuleId()); + if (navigation == null) { + throw new GenericException(Translator.get("module.not_exist")); + } + List beforeKeys = getNavigationSortKeys(navigation.getOrganizationId()); + if (request.getStart() < request.getEnd()) { + // start < end, 区间模块上移, pos - 1 + extNavigationMapper.moveUpNavigation(request.getStart(), request.getEnd()); + } else { + // start > end, 区间模块下移, pos + 1 + extNavigationMapper.moveDownNavigation(request.getEnd(), request.getStart()); + } + Navigation dragNavigation = new Navigation(); + dragNavigation.setId(request.getDragModuleId()); + dragNavigation.setPos(request.getEnd()); + dragNavigation.setUpdateUser(userId); + dragNavigation.setUpdateTime(System.currentTimeMillis()); + navigationMapper.updateById(dragNavigation); + List afterKeys = getNavigationSortKeys(navigation.getOrganizationId()); + + //添加日志上下文 + Map> originalVal = new HashMap<>(1); + originalVal.put("navigationSort", beforeKeys); + Map> modifiedVal = new HashMap<>(1); + modifiedVal.put("navigationSort", afterKeys); + OperationLogContext.setContext(LogContextInfo.builder() + .originalValue(originalVal) + .resourceName(Translator.get("top.navigation")) + .modifiedValue(modifiedVal) + .resourceId(navigation.getId()) + .build()); + } + + private List getNavigationSortKeys(String orgId) { + List navigationList = getNavigationList(orgId); + return navigationList.stream().map(Navigation::getNavigationKey).toList(); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/NotificationService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/NotificationService.java new file mode 100644 index 0000000..f637920 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/NotificationService.java @@ -0,0 +1,197 @@ +package cn.cordys.crm.system.service; + + +import cn.cordys.common.constants.TopicConstants; +import cn.cordys.common.dto.OptionCountDTO; +import cn.cordys.common.redis.MessagePublisher; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.domain.Notification; +import cn.cordys.crm.system.dto.request.NotificationRequest; +import cn.cordys.crm.system.dto.response.NotificationDTO; +import cn.cordys.crm.system.mapper.ExtNotificationMapper; +import cn.cordys.crm.system.notice.dto.NoticeRedisMessage; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +public class NotificationService { + + private static final String USER_ANNOUNCE_PREFIX = "announce_user:"; // Redis 存储用户前缀 + private static final String ANNOUNCE_PREFIX = "announce_content:"; // Redis 存储信息前缀 + private static final String USER_PREFIX = "msg_user:"; // Redis 存储系统通知用户前缀 + private static final String MSG_PREFIX = "msg_content:"; // Redis 存储系统通知内容信息前缀 + private static final String USER_READ_PREFIX = "user_read:"; // Redis 存储用户读取前缀 + @Resource + private BaseMapper notificationMapper; + @Resource + private ExtNotificationMapper extNotificationMapper; + @Resource + private StringRedisTemplate stringRedisTemplate; + @Resource + private SendModuleService sendModuleService; + @Resource + private MessagePublisher messagePublisher; + + private static void buildSourceCount(List notifications, List options) { + Map countMap = new HashMap<>(); + Map> resourceMap = notifications.stream().collect(Collectors.groupingBy(Notification::getResourceType)); + resourceMap.forEach((k, v) -> { + if (k.contains(NotificationConstants.Module.CUSTOMER)) { + countMap.merge(NotificationConstants.Module.CUSTOMER, v.size(), Integer::sum); + } else if (k.contains(NotificationConstants.Module.APPROVAL)) { + countMap.merge(NotificationConstants.Module.APPROVAL, v.size(), Integer::sum); + } else if (k.contains(NotificationConstants.Module.CLUE)) { + countMap.merge(NotificationConstants.Module.CLUE, v.size(), Integer::sum); + } else if (k.contains(NotificationConstants.Module.OPPORTUNITY)) { + countMap.merge(NotificationConstants.Module.OPPORTUNITY, v.size(), Integer::sum); + } else if (k.contains(NotificationConstants.Module.CONTRACT)) { + countMap.merge(NotificationConstants.Module.CONTRACT, v.size(), Integer::sum); + } else if (k.contains(NotificationConstants.Module.SYSTEM)) { + countMap.merge(NotificationConstants.Module.SYSTEM, v.size(), Integer::sum); + } else if (k.contains(NotificationConstants.Module.ORDER)) { + countMap.merge(NotificationConstants.Module.ORDER, v.size(), Integer::sum); + } else { + countMap.merge(NotificationConstants.Type.ANNOUNCEMENT_NOTICE.name(), v.size(), Integer::sum); + } + }); + countMap.putIfAbsent(NotificationConstants.Module.CUSTOMER, 0); + countMap.putIfAbsent(NotificationConstants.Module.APPROVAL, 0); + countMap.putIfAbsent(NotificationConstants.Module.CLUE, 0); + countMap.putIfAbsent(NotificationConstants.Module.OPPORTUNITY, 0); + countMap.putIfAbsent(NotificationConstants.Module.CONTRACT, 0); + countMap.putIfAbsent(NotificationConstants.Module.SYSTEM, 0); + countMap.putIfAbsent(NotificationConstants.Module.ORDER, 0); + countMap.putIfAbsent(NotificationConstants.Type.ANNOUNCEMENT_NOTICE.name(), 0); + + countMap.forEach((k, v) -> { + OptionCountDTO optionDTO = new OptionCountDTO(); + optionDTO.setKey(k); + optionDTO.setCount(v); + options.add(optionDTO); + }); + } + + private static void buildParam(NotificationRequest notificationRequest, String userId) { + if (StringUtils.isNotBlank(notificationRequest.getSubject())) { + notificationRequest.setSubject("%" + notificationRequest.getSubject() + "%"); + } + if (StringUtils.isNotBlank(notificationRequest.getResourceType())) { + notificationRequest.setResourceType("%" + notificationRequest.getResourceType() + "%"); + } + if (StringUtils.isBlank(notificationRequest.getReceiver())) { + notificationRequest.setReceiver(userId); + } + } + + public List listNotification(NotificationRequest notificationRequest, String userId, String organizationId) { + buildParam(notificationRequest, userId); + List notifications = extNotificationMapper.listNotification(notificationRequest, organizationId); + notifications.forEach(notification -> notification.setContentText(new String(notification.getContent()))); + return notifications; + } + + public int read(String id, String userId, String orgId) { + Notification record = new Notification(); + record.setId(id); + record.setStatus(NotificationConstants.Status.READ.name()); + record.setReceiver(userId); + //删除缓存中的公告提示 + stringRedisTemplate.opsForZSet().remove(USER_ANNOUNCE_PREFIX + userId, id); + stringRedisTemplate.delete(ANNOUNCE_PREFIX + id); + stringRedisTemplate.opsForZSet().remove(USER_PREFIX + userId, id); + stringRedisTemplate.delete(MSG_PREFIX + id); + Integer update = notificationMapper.update(record); + //检查当前用户是否还有有已读信息,没有更新用户状态 + Integer unRead = getUnRead(orgId, userId); + if (unRead == 0) { + stringRedisTemplate.opsForValue().set(USER_READ_PREFIX + userId, "True"); + //sseService.broadcastPeriodically(userId,NotificationConstants.Status.READ.toString()); + NoticeRedisMessage noticeRedisMessage = new NoticeRedisMessage(); + noticeRedisMessage.setMessage(userId); + noticeRedisMessage.setNoticeType(NotificationConstants.Status.READ.toString()); + messagePublisher.publish(TopicConstants.SSE_TOPIC, JSON.toJSONString(noticeRedisMessage)); + } + return update; + } + + public int readAll(String organizationId, String userId) { + Notification record = new Notification(); + record.setStatus(NotificationConstants.Status.READ.name()); + record.setOrganizationId(organizationId); + record.setReceiver(userId); + //所有这个用户的公告都不再显示 + Set values = stringRedisTemplate.opsForZSet().range(USER_ANNOUNCE_PREFIX + userId, 0, -1); + if (CollectionUtils.isNotEmpty(values)) { + for (String value : values) { + stringRedisTemplate.delete(ANNOUNCE_PREFIX + value); + } + } + Set range = stringRedisTemplate.opsForZSet().range(USER_PREFIX + userId, 0, -1); + if (CollectionUtils.isNotEmpty(range)) { + for (String value : range) { + stringRedisTemplate.delete(MSG_PREFIX + value); + } + } + stringRedisTemplate.delete(USER_ANNOUNCE_PREFIX + userId); + stringRedisTemplate.delete(USER_PREFIX + userId); + stringRedisTemplate.opsForValue().set(USER_READ_PREFIX + userId, "True"); + NoticeRedisMessage noticeRedisMessage = new NoticeRedisMessage(); + noticeRedisMessage.setMessage(userId); + noticeRedisMessage.setNoticeType(NotificationConstants.Status.READ.toString()); + messagePublisher.publish(TopicConstants.SSE_TOPIC, JSON.toJSONString(noticeRedisMessage)); + //sseService.broadcastPeriodically(userId,NotificationConstants.Status.READ.toString()); + return extNotificationMapper.updateByReceiver(record); + } + + public List countNotification(NotificationRequest notificationRequest, String organizationId, String userId) { + List options = new ArrayList<>(); + buildParam(notificationRequest, userId); + notificationRequest.setResourceType(StringUtils.EMPTY); + notificationRequest.setStatus(NotificationConstants.Status.UNREAD.name()); + List notifications = extNotificationMapper.listNotification(notificationRequest, organizationId); + OptionCountDTO totalOptionDTO = new OptionCountDTO(); + totalOptionDTO.setKey("total"); + totalOptionDTO.setCount(notifications.size()); + options.add(totalOptionDTO); + buildSourceCount(notifications, options); + return options; + } + + public Integer getUnRead(String organizationId, String userId) { + if (StringUtils.isBlank(organizationId)) { + return 0; + } + Notification record = new Notification(); + record.setOrganizationId(organizationId); + record.setStatus(NotificationConstants.Status.UNREAD.name()); + record.setReceiver(userId); + return extNotificationMapper.countByReceiver(record); + } + + public List listLastNotification(String userId, String organizationId) { + //获取已开启的模块 + List modules = sendModuleService.getNoticeModules(); + if (CollectionUtils.isEmpty(modules)) { + return new ArrayList<>(); + } + List notifications = extNotificationMapper.selectLastList(userId, organizationId, modules); + notifications.forEach(notification -> notification.setContentText(new String(notification.getContent()))); + return notifications; + } + + public List listLastAnnouncement(String userId, String organizationId) { + List notifications = extNotificationMapper.selectLastAnnouncementList(userId, organizationId); + notifications.forEach(notification -> notification.setContentText(new String(notification.getContent()))); + return notifications; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/OrganizationConfigService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/OrganizationConfigService.java new file mode 100644 index 0000000..e6dbe72 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/OrganizationConfigService.java @@ -0,0 +1,198 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.ThirdConfigTypeConstants; +import cn.cordys.common.constants.ThirdDetailType; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.constants.OrganizationConfigConstants; +import cn.cordys.crm.system.domain.OrganizationConfig; +import cn.cordys.crm.system.domain.OrganizationConfigDetail; +import cn.cordys.crm.system.dto.response.EmailDTO; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigDetailMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigMapper; +import cn.cordys.crm.system.utils.MailSender; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +import java.util.Objects; + +@Service +@Slf4j +public class OrganizationConfigService { + + @Resource + private ExtOrganizationConfigMapper extOrganizationConfigMapper; + + @Resource + private ExtOrganizationConfigDetailMapper extOrganizationConfigDetailMapper; + + @Resource + private BaseMapper organizationConfigDetailBaseMapper; + + @Resource + private BaseMapper organizationConfigBaseMapper; + + @Resource + private MailSender mailSender; + + + public EmailDTO getEmail(String organizationId) { + OrganizationConfig organizationConfig = extOrganizationConfigMapper.getOrganizationConfig(organizationId, OrganizationConfigConstants.ConfigType.EMAIL.name()); + if (organizationConfig == null) { + return new EmailDTO(); + } + OrganizationConfigDetail organizationConfigDetail = extOrganizationConfigDetailMapper.getOrganizationConfigDetail(organizationConfig.getId()); + if (organizationConfigDetail == null) { + return new EmailDTO(); + } + return JSON.parseObject(new String(organizationConfigDetail.getContent()), EmailDTO.class); + } + + @OperationLog(module = LogModule.SYSTEM_BUSINESS_MAIL, type = LogType.UPDATE, operator = "{#userId}") + public void editEmail(EmailDTO emailDTO, String organizationId, String userId) { + OrganizationConfig organizationConfig = extOrganizationConfigMapper.getOrganizationConfig(organizationId, OrganizationConfigConstants.ConfigType.EMAIL.name()); + if (organizationConfig == null) { + organizationConfig = new OrganizationConfig(); + organizationConfig.setId(IDGenerator.nextStr()); + organizationConfig.setOrganizationId(organizationId); + organizationConfig.setType(OrganizationConfigConstants.ConfigType.EMAIL.name()); + organizationConfig.setCreateTime(System.currentTimeMillis()); + organizationConfig.setUpdateTime(System.currentTimeMillis()); + organizationConfig.setCreateUser(userId); + organizationConfig.setUpdateUser(userId); + organizationConfigBaseMapper.insert(organizationConfig); + } + EmailDTO emailDTOOld = new EmailDTO(); + OrganizationConfigDetail organizationConfigDetail = extOrganizationConfigDetailMapper.getOrganizationConfigDetail(organizationConfig.getId()); + if (organizationConfigDetail == null) { + organizationConfigDetail = getOrganizationConfigDetail(userId, organizationConfig, JSON.toJSONString(emailDTO)); + organizationConfigDetail.setType(OrganizationConfigConstants.ConfigType.EMAIL.name()); + organizationConfigDetail.setName(Translator.get("email.setting")); + organizationConfigDetail.setEnable(true); + organizationConfigDetailBaseMapper.insert(organizationConfigDetail); + } else { + emailDTOOld = JSON.parseObject(new String(organizationConfigDetail.getContent()), EmailDTO.class); + organizationConfigDetail.setContent(JSON.toJSONBytes(emailDTO)); + organizationConfigDetail.setUpdateTime(System.currentTimeMillis()); + organizationConfigDetail.setUpdateUser(userId); + organizationConfigDetailBaseMapper.update(organizationConfigDetail); + } + + // 添加日志上下文 + OperationLogContext.setContext(LogContextInfo.builder() + .originalValue(emailDTOOld) + .resourceName(Translator.get("email.setting")) + .resourceId(organizationConfigDetail.getId()) + .modifiedValue(emailDTO) + .build()); + + } + + private OrganizationConfigDetail getOrganizationConfigDetail(String userId, OrganizationConfig organizationConfig, String jsonString) { + OrganizationConfigDetail organizationConfigDetail = new OrganizationConfigDetail(); + organizationConfigDetail.setId(IDGenerator.nextStr()); + organizationConfigDetail.setContent(jsonString.getBytes()); + organizationConfigDetail.setCreateTime(System.currentTimeMillis()); + organizationConfigDetail.setUpdateTime(System.currentTimeMillis()); + organizationConfigDetail.setCreateUser(userId); + organizationConfigDetail.setUpdateUser(userId); + organizationConfigDetail.setConfigId(organizationConfig.getId()); + return organizationConfigDetail; + } + + public void verifyEmailConnection(EmailDTO emailDTO) { + try { + JavaMailSenderImpl javaMailSender = mailSender.buildMailSender(emailDTO); + javaMailSender.testConnection(); + + String recipient = emailDTO.getRecipient(); + if (StringUtils.isBlank(recipient)) { + return; // Early exit if recipient is blank + } + + MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true); + + String username = javaMailSender.getUsername(); + String email = StringUtils.isNotBlank(username) && username.contains("@") + ? username + : username + "@" + Objects.requireNonNull(javaMailSender.getHost()).substring(javaMailSender.getHost().indexOf(".") + 1); + + InternetAddress from = new InternetAddress(); + String smtpFrom = emailDTO.getFrom(); + if (StringUtils.isBlank(smtpFrom)) { + from.setAddress(email); + from.setPersonal(username); + } else { + from.setAddress(smtpFrom.contains("@") ? smtpFrom : email); + from.setPersonal(smtpFrom); + } + helper.setFrom(from); + helper.setSubject("Cordys CRM 测试邮件"); + + log.info("收件人地址: {}", recipient); + helper.setText("这是一封测试邮件,邮件发送成功", true); + helper.setTo(recipient); + + javaMailSender.send(mimeMessage); + } catch (Exception e) { + log.error("邮件发送或连接测试失败: ", e); + throw new GenericException(Translator.get("email.connection.failed")); + } + } + + + /** + * 当前组织的用户数据是否是第三方同步的 + * + * @param organizationId 组织ID + * + * @return true 是第三方同步的 false 不是第三方同步的 + */ + public boolean syncCheck(String organizationId) { + OrganizationConfig organizationConfig = extOrganizationConfigMapper.getOrganizationConfig( + organizationId, OrganizationConfigConstants.ConfigType.THIRD.name()); + String type; + if (organizationConfig == null || StringUtils.isBlank(organizationConfig.getSyncResource())) { + return false; + } + if (organizationConfig.getSyncResource().equals(ThirdConfigTypeConstants.WECOM.name())) { + type = ThirdDetailType.WECOM_SYNC.name(); + } else if (organizationConfig.getSyncResource().equals(ThirdConfigTypeConstants.DINGTALK.name())) { + type = ThirdDetailType.DINGTALK_SYNC.name(); + } else if (organizationConfig.getSyncResource().equals(ThirdConfigTypeConstants.LARK.name())) { + type = ThirdDetailType.LARK_SYNC.name(); + } else { + return false; + } + return extOrganizationConfigMapper.getSyncFlag(organizationId, type) > 0; + } + + + /** + * 更新三方同步标识 + * + * @param orgId 组织ID + * @param syncResource 同步来源 + */ + public void updateSyncFlag(String orgId, String syncResource, Boolean syncStatus) { + extOrganizationConfigMapper.updateSyncFlag(orgId, syncResource, OrganizationConfigConstants.ConfigType.THIRD.name(), syncStatus); + } + + +} + diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/OrganizationLogService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/OrganizationLogService.java new file mode 100644 index 0000000..eab2e70 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/OrganizationLogService.java @@ -0,0 +1,124 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.util.Translator; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +@Service +@Transactional(rollbackFor = Exception.class) +public class OrganizationLogService extends BaseModuleLogService { + @Resource + private DepartmentService departmentService; + @Resource + private OrganizationUserService organizationUserService; + + private void handRoleValueName(JsonDifferenceDTO differ) { + differ.setOldValueName(resolveRoleNames(differ.getOldValue())); + differ.setNewValueName(resolveRoleNames(differ.getNewValue())); + } + + private static List resolveRoleNames(Object value) { + if (!(value instanceof List list)) { + return new ArrayList<>(); + } + + List result = new ArrayList<>(); + for (Object item : list) { + if (!(item instanceof Map map)) { + continue; + } + + Object nameValue = map.get("name"); + if (nameValue == null) { + continue; + } + + result.add(resolveRoleName(nameValue.toString())); + } + return result; + } + + private static String resolveRoleName(String roleKey) { + return switch (roleKey) { + case "org_admin" -> Translator.get("role.org_admin"); + case "sales_manager" -> Translator.get("role.sales_staff"); + case "role.sales_manager" -> Translator.get("role.sales_manager"); + default -> roleKey; + }; + } + + @Override + public List handleLogField(List differences, String orgId) { + differences = super.handleModuleLogField(differences, orgId, FormKey.OPPORTUNITY.getKey()); + + // 构建列名 -> 处理函数映射 + Map> handlers = Map.of( + Translator.get("log.roles"), this::handRoleValueName, + Translator.get("log.commander"), this::setUserFieldName, + Translator.get("log.departmentId"), this::setDepartmentName, + Translator.get("log.supervisorId"), this::setSupervisorName, + Translator.get("log.enable"), differ -> { + differ.setOldValueName(toText(differ.getOldValueName(), "log.enable.true", "log.enable.false")); + differ.setNewValueName(toText(differ.getNewValueName(), "log.enable.true", "log.enable.false")); + }, + Translator.get("log.gender"), differ -> { + differ.setOldValueName(toText(differ.getOldValueName(), "woman", "man")); + differ.setNewValueName(toText(differ.getNewValueName(), "woman", "man")); + }, + Translator.get("log.employeeType"), differ -> { + differ.setOldValueName(Translator.get(differ.getOldValue().toString())); + differ.setNewValueName(Translator.get(differ.getNewValue().toString())); + }, + Translator.get("log.onboardingDate"), differ -> { + setFormatDataTimeFieldValueName(differ,new SimpleDateFormat("yyyy-MM-dd")); + } + ); + + // 流式处理 + differences.forEach(differ -> + handlers.entrySet().stream() + .filter(e -> Strings.CS.equals(differ.getColumnName(), e.getKey())) + .findFirst() + .ifPresent(e -> e.getValue().accept(differ)) + ); + + return differences; + } + + // 通用 Boolean / Object 转译方法 + private String toText(Object value, String trueKey, String falseKey) { + return Translator.get(Boolean.parseBoolean(value.toString()) ? trueKey : falseKey); + } + + protected void setDepartmentName(JsonDifferenceDTO differ) { + if (differ.getOldValue() != null) { + String userName = departmentService.getDepartmentName(differ.getOldValue().toString()); + differ.setOldValueName(userName); + } + if (differ.getNewValue() != null) { + String userName = departmentService.getDepartmentName(differ.getNewValue().toString()); + differ.setNewValueName(userName); + } + } + + protected void setSupervisorName(JsonDifferenceDTO differ) { + if (differ.getOldValue() != null) { + String userName = organizationUserService.getSupervisorName(differ.getOldValue().toString()); + differ.setOldValueName(userName); + } + if (differ.getNewValue() != null) { + String userName = organizationUserService.getSupervisorName(differ.getNewValue().toString()); + differ.setNewValueName(userName); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/OrganizationUserService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/OrganizationUserService.java new file mode 100644 index 0000000..f822bb1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/OrganizationUserService.java @@ -0,0 +1,1004 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.InternalRole; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.DeptUserTreeNode; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.CodingUtils; +import cn.cordys.common.util.SubListUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.approval.service.ApprovalActionService; +import cn.cordys.crm.clue.mapper.ExtClueMapper; +import cn.cordys.crm.customer.mapper.ExtCustomerMapper; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityMapper; +import cn.cordys.crm.search.mapper.ExtUserSearchConfigMapper; +import cn.cordys.crm.system.domain.*; +import cn.cordys.crm.system.dto.convert.UserRoleConvert; +import cn.cordys.crm.system.dto.request.*; +import cn.cordys.crm.system.dto.response.UserImportDTO; +import cn.cordys.crm.system.dto.response.UserImportResponse; +import cn.cordys.crm.system.dto.response.UserPageResponse; +import cn.cordys.crm.system.dto.response.UserResponse; +import cn.cordys.crm.system.excel.domain.UserExcelData; +import cn.cordys.crm.system.excel.domain.UserExcelDataFactory; +import cn.cordys.crm.system.excel.handler.UserTemplateWriteHandler; +import cn.cordys.crm.system.excel.listener.UserCheckEventListener; +import cn.cordys.crm.system.excel.listener.UserImportEventListener; +import cn.cordys.crm.system.mapper.*; +import cn.cordys.excel.utils.EasyExcelExporter; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.security.SessionUtils; +import cn.idev.excel.FastExcelFactory; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.ibatis.session.ExecutorType; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.mybatis.spring.SqlSessionUtils; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + + +@Service("organizationUserService") +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class OrganizationUserService { + + @Resource + private ExtOrganizationUserMapper extOrganizationUserMapper; + @Resource + private ExtUserMapper extUserMapper; + @Resource + private RoleService roleService; + @Resource + private BaseMapper userMapper; + @Resource + private BaseMapper organizationUserMapper; + @Resource + private BaseMapper userRoleMapper; + @Resource + private ExtUserRoleMapper extUserRoleMapper; + @Resource + private LogService logService; + @Resource + private ExtDepartmentCommanderMapper extDepartmentCommanderMapper; + @Resource + private ExtDepartmentMapper extDepartmentMapper; + @Resource + private BaseMapper departmentCommanderMapper; + @Resource + private BaseMapper userExtendMapper; + @Resource + private ExtUserExtendMapper extUserExtendMapper; + @Resource + private BaseMapper departmentMapper; + @Resource + private DepartmentService departmentService; + @Resource + private SqlSessionFactory sqlSessionFactory; + @Resource + private ExtOpportunityMapper extOpportunityMapper; + @Resource + private ExtCustomerMapper extCustomerMapper; + @Resource + private ExtClueMapper extClueMapper; + @Resource + private ExtUserViewMapper extUserViewMapper; + @Resource + private ExtUserViewConditionMapper extUserViewConditionMapper; + @Resource + private ExtUserSearchConfigMapper extUserSearchConfigMapper; + @Resource + private PermissionCache permissionCache; + @Resource + private ExtNotificationMapper extNotificationMapper; + @Resource + private UserRoleService userRoleService; + @Resource + private ApprovalActionService approvalActionService; + + + /** + * 员工列表查询 + * + * @param request + * @return + */ + public List list(UserPageRequest request) { + String orderByClause = buildOrderByFieldClause(request.getDepartmentIds()); + request.setDepartmentIds(request.getDepartmentIds().stream().filter(id -> id.chars().allMatch(Character::isDigit)).toList()); + List list = extOrganizationUserMapper.list(request, orderByClause); + handleData(list, request.getDepartmentIds().getFirst()); + return list; + } + + private String buildOrderByFieldClause(List departmentIds) { + if (departmentIds == null || departmentIds.isEmpty()) { + return "1"; // 默认排序,如果无部门ID传入 + } + departmentIds = departmentIds.stream().filter(id -> id.chars().allMatch(Character::isDigit)).toList(); + StringJoiner sj = new StringJoiner(",", "FIELD(department_id, ", ")"); + for (String deptId : departmentIds) { + sj.add("'" + deptId + "'"); + } + return sj.toString(); + } + + + private void handleData(List list, String departmentId) { + if (CollectionUtils.isNotEmpty(list)) { + String orgId = list.stream(). + map(UserPageResponse::getOrganizationId) + .toList() + .getFirst(); + List userIds = list.stream() + .map(UserPageResponse::getUserId) + .toList(); + //获取用户角色 + List userRoles = extUserMapper.getUserRole(userIds, orgId); + userRoles.forEach(role -> role.setName(roleService.translateInternalRole(role.getName()))); + Map> userRoleMap = userRoles.stream() + .collect(Collectors.groupingBy(UserRoleConvert::getUserId)); + //创建人 更新人 + List ids = new ArrayList<>(); + ids.addAll(list.stream().map(UserPageResponse::getCreateUser).toList()); + ids.addAll(list.stream().map(UserPageResponse::getUpdateUser).toList()); + List options = extUserMapper.selectUserOptionByIds(ids); + Map userMap = options + .stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + //直属上级 + List supervisorIds = list.stream() + .map(UserPageResponse::getSupervisorId) + .distinct() + .toList(); + List supervisors = extUserMapper.selectUserOptionByIds(supervisorIds); + Map supervisorMap = supervisors.stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + //部门 + List departmentIds = list.stream() + .map(UserPageResponse::getDepartmentId) + .distinct() + .toList(); + List departmentList = departmentMapper.selectByIds(departmentIds.toArray(new String[0])); + Map departmentMap = departmentList.stream() + .collect(Collectors.toMap(Department::getId, Department::getName)); + //部门负责人 + List commanderIds = extDepartmentCommanderMapper.selectCommander(departmentId, list.getFirst().getOrganizationId()); + + //todo 暂无用户组 后续需求再处理 + list.forEach(user -> { + if (userRoleMap.containsKey(user.getUserId())) { + user.setRoles(userRoleMap.get(user.getUserId())); + } + if (userMap.containsKey(user.getCreateUser())) { + user.setCreateUserName(userMap.get(user.getCreateUser())); + } + if (userMap.containsKey(user.getUpdateUser())) { + user.setUpdateUserName(userMap.get(user.getUpdateUser())); + } + if (departmentMap.containsKey(user.getDepartmentId())) { + user.setDepartmentName(departmentMap.get(user.getDepartmentId())); + } + if (supervisorMap.containsKey(user.getSupervisorId())) { + user.setSupervisorName(supervisorMap.get(user.getSupervisorId())); + } + if (commanderIds.contains(user.getUserId())) { + user.setCommander(true); + } + }); + + } + } + + + /** + * 添加员工 + * + * @param request + * @param organizationId + * @param operatorId + */ + @OperationLog(module = LogModule.SYSTEM_ORGANIZATION, type = LogType.ADD, operator = "{#operatorId}") + public void addUser(UserAddRequest request, String organizationId, String operatorId) { + String id = IDGenerator.nextStr(); + //邮箱和手机号唯一性校验 + checkEmailAndPhone(request.getEmail(), request.getPhone(), id); + //add user base + User user = addUserBaseData(request, operatorId, id); + user.setLastOrganizationId(organizationId); + //add user info + addUserInfo(request, organizationId, operatorId, user.getId()); + //add user role + addUserRole(request.getRoleIds(), user.getId(), operatorId); + //todo add user group + //添加日志上下文 + OperationLogContext.setContext(LogContextInfo.builder() + .originalValue(null) + .modifiedValue(user) + .resourceName(user.getName()) + .resourceId(user.getId()) + .build()); + } + + + /** + * 添加用户角色关系 + * + * @param roleIds + * @param userId + * @param operatorId + */ + private void addUserRole(List roleIds, String userId, String operatorId) { + if (CollectionUtils.isNotEmpty(roleIds)) { + List list = new ArrayList<>(); + roleIds.forEach(roleId -> { + UserRole userRole = new UserRole(); + userRole.setId(IDGenerator.nextStr()); + userRole.setUserId(userId); + userRole.setRoleId(roleId); + userRole.setCreateTime(System.currentTimeMillis()); + userRole.setCreateUser(operatorId); + userRole.setUpdateTime(System.currentTimeMillis()); + userRole.setUpdateUser(operatorId); + list.add(userRole); + }); + userRoleMapper.batchInsert(list); + } + } + + + /** + * 添加用戶信息 + * + * @param request + * @param organizationId + * @param userId + */ + private void addUserInfo(UserAddRequest request, String organizationId, String operatorId, String userId) { + OrganizationUser orgUser = new OrganizationUser(); + BeanUtils.copyBean(orgUser, request); + orgUser.setId(IDGenerator.nextStr()); + orgUser.setOrganizationId(organizationId); + orgUser.setUserId(userId); + orgUser.setCreateTime(System.currentTimeMillis()); + orgUser.setCreateUser(operatorId); + orgUser.setUpdateTime(System.currentTimeMillis()); + orgUser.setUpdateUser(operatorId); + orgUser.setOnboardingDate(request.getOnboardingDate()); + organizationUserMapper.insert(orgUser); + } + + /** + * 邮箱和手机号唯一性校验 + * + * @param email + * @param phone + */ + private void checkEmailAndPhone(String email, String phone, String id) { + if (extUserMapper.countByEmail(email, id) > 0) { + throw new GenericException(Translator.get("email.exist")); + } + if (extUserMapper.countByPhone(phone, id) > 0) { + throw new GenericException(Translator.get("phone.exist")); + } + + } + + /** + * 添加用户基本数据 + * + * @param request + * @param operatorId + * @return + */ + private User addUserBaseData(UserAddRequest request, String operatorId, String id) { + User user = new User(); + BeanUtils.copyBean(user, request); + user.setId(id); + user.setPassword(CodingUtils.md5(request.getPhone().substring(request.getPhone().length() - 6))); + user.setCreateTime(System.currentTimeMillis()); + user.setCreateUser(operatorId); + user.setUpdateTime(System.currentTimeMillis()); + user.setUpdateUser(operatorId); + user.setLanguage(Locale.SIMPLIFIED_CHINESE.toString()); + userMapper.insert(user); + return user; + } + + + /** + * 获取用户详情 + * + * @param id + * @return + */ + public UserResponse getUserDetail(String id) { + UserResponse userDetail = extUserMapper.getUserDetail(id); + if (StringUtils.isNotBlank(userDetail.getSupervisorId())) { + User user = userMapper.selectByPrimaryKey(userDetail.getSupervisorId()); + userDetail.setSupervisorName(Optional.ofNullable(user).map(User::getName).orElse(null)); + } + //获取用户角色 + List userRoles = extUserMapper.getUserRole(List.of(userDetail.getUserId()), userDetail.getOrganizationId()); + userRoles.forEach(role -> role.setName(roleService.translateInternalRole(role.getName()))); + userDetail.setRoles(userRoles); + return userDetail; + } + + /** + * 更新用户 + * + * @param request + * @param operatorId + */ + @OperationLog(module = LogModule.SYSTEM_ORGANIZATION, type = LogType.UPDATE, operator = "{#operatorId}") + public void updateUser(UserUpdateRequest request, String operatorId, String orgId) { + UserResponse oldUser = getUserDetail(request.getId()); + //邮箱和手机号唯一性校验 + checkEmailAndPhone(request.getEmail(), request.getPhone(), oldUser.getUserId()); + //update user info + updateUserInfo(request, operatorId, oldUser); + //update user base + updateUserBaseData(request, operatorId, oldUser.getUserId()); + //update user role + updateUserRole(request.getRoleIds(), oldUser, operatorId, orgId); + if (!request.getEnable()) { + // 踢出该用户 + SessionUtils.kickOutUser(operatorId, oldUser.getUserId()); + } + + //添加日志上下文 + OperationLogContext.setContext(LogContextInfo.builder() + .originalValue(oldUser) + .resourceName(oldUser.getUserName()) + .modifiedValue(getUserDetail(request.getId())) + .resourceId(oldUser.getId()) + .build()); + } + + /** + * 更新用户角色 + * + * @param roleIds + * @param oldUser + * @param operatorId + */ + private void updateUserRole(List roleIds, UserResponse oldUser, String operatorId, String orgId) { + Optional.ofNullable(roleIds).ifPresent(ids -> extUserRoleMapper.deleteUserRoleByUserId(oldUser.getUserId())); + if (CollectionUtils.isNotEmpty(roleIds)) { + addUserRole(roleIds, oldUser.getUserId(), operatorId); + } + permissionCache.clearCache(oldUser.getUserId(), orgId); + } + + /** + * 更新用户信息 + * + * @param request + * @param operatorId + */ + private void updateUserInfo(UserUpdateRequest request, String operatorId, UserResponse user) { + OrganizationUser organizationUser = BeanUtils.copyBean(new OrganizationUser(), user); + BeanUtils.copyBean(organizationUser, request); + organizationUser.setUpdateTime(System.currentTimeMillis()); + organizationUser.setUpdateUser(operatorId); + organizationUser.setOnboardingDate(request.getOnboardingDate()); + extOrganizationUserMapper.updateById(organizationUser); + } + + + /** + * 更新用户基本数据 + * + * @param request + * @param operatorId + * @param userId + */ + private void updateUserBaseData(UserUpdateRequest request, String operatorId, String userId) { + User updateUser = BeanUtils.copyBean(new User(), request); + updateUser.setId(userId); + updateUser.setUpdateTime(System.currentTimeMillis()); + updateUser.setUpdateUser(operatorId); + userMapper.updateById(updateUser); + } + + /** + * 重置密码 + * + * @param userId + * @param operatorId + */ + public void resetPassword(String userId, String operatorId, String orgId) { + if (!Strings.CI.equals(userId, InternalUser.ADMIN.getValue())) { + User user = userMapper.selectByPrimaryKey(userId); + if (StringUtils.isBlank(user.getPhone())) { + throw new GenericException(Translator.get("user_phone_not_exist")); + } + user.setPassword(CodingUtils.md5(user.getPhone().substring(user.getPhone().length() - 6))); + user.setUpdateTime(System.currentTimeMillis()); + user.setUpdateUser(operatorId); + userMapper.updateById(user); + + // 踢出该用户 + SessionUtils.kickOutUser(operatorId, userId); + + LogDTO logDTO = new LogDTO(orgId, userId, operatorId, LogType.UPDATE, LogModule.SYSTEM_ORGANIZATION, user.getName()); + Map oldMap = new HashMap<>(); + oldMap.put("userPassword", "############"); + Map newMap = new HashMap<>(); + newMap.put("userPassword", "************"); + logDTO.setOriginalValue(oldMap); + logDTO.setModifiedValue(newMap); + logService.add(logDTO); + } + } + + /** + * 批量启用/禁用 + * + * @param request + * @param operatorId + */ + public void enable(UserBatchEnableRequest request, String operatorId, String orgId) { + extOrganizationUserMapper.enable(request, operatorId, System.currentTimeMillis()); + + // 记录日志 + OrganizationUser originUser = new OrganizationUser(); + originUser.setEnable(!request.isEnable()); + OrganizationUser newUser = new OrganizationUser(); + newUser.setEnable(request.isEnable()); + SubListUtils.dealForSubList(request.getIds(), 50, ids -> { + List orgUsers = extOrganizationUserMapper.selectEnableOrgUser(ids, !request.isEnable()); + if (!request.isEnable()) { + List userIds = orgUsers.stream().map(OptionDTO::getId).toList(); + approvalActionService.refreshApprovingTasksForDisabledUser(userIds, orgId); + } + + List logs = new ArrayList<>(); + orgUsers.forEach(orgUser -> { + // 踢出该用户 + SessionUtils.kickOutUser(operatorId, orgUser.getId()); + + LogDTO logDTO = new LogDTO(orgId, orgUser.getId(), operatorId, LogType.UPDATE, LogModule.SYSTEM_ORGANIZATION, orgUser.getName()); + logDTO.setOriginalValue(originUser); + logDTO.setModifiedValue(newUser); + logs.add(logDTO); + }); + + logService.batchAdd(logs); + }); + } + + + /** + * 批量重置密码 + * + * @param request + * @param operatorId + * @param orgId + */ + public void batchResetPassword(UserBatchRequest request, String operatorId, String orgId) { + List userList = extOrganizationUserMapper.getUserList(request); + if (CollectionUtils.isEmpty(userList)) { + throw new GenericException(Translator.get("user_phone_not_exist")); + } + + List logs = new ArrayList<>(); + Map oldMap = new HashMap<>(); + oldMap.put("userPassword", "############"); + Map newMap = new HashMap<>(); + newMap.put("userPassword", "************"); + userList.forEach(user -> { + if (!Strings.CI.equals(user.getId(), InternalUser.ADMIN.getValue())) { + user.setPassword(CodingUtils.md5(user.getPhone().substring(user.getPhone().length() - 6))); + } + user.setUpdateTime(System.currentTimeMillis()); + user.setUpdateUser(operatorId); + LogDTO logDTO = new LogDTO(orgId, user.getId(), operatorId, LogType.UPDATE, LogModule.SYSTEM_ORGANIZATION, user.getName()); + logDTO.setOriginalValue(oldMap); + logDTO.setModifiedValue(newMap); + logs.add(logDTO); + // 踢出该用户 + SessionUtils.kickOutUser(operatorId, user.getId()); + }); + extUserMapper.batchUpdatePassword(userList); + logService.batchAdd(logs); + + } + + + /** + * 删除用户 + * + * @param orgId + */ + public void deleteUser(String orgId) { + List departmentIds = extDepartmentMapper.selectAllDepartmentIds(orgId); + if (CollectionUtils.isNotEmpty(departmentIds)) { + extDepartmentCommanderMapper.deleteByDepartmentIds(departmentIds); + } + List userList = extUserMapper.getAllUserIds(orgId); + List ids = userList.stream().map(User::getId).toList(); + if (CollectionUtils.isNotEmpty(ids)) { + extUserMapper.deleteByIds(ids); + extUserExtendMapper.deleteUser(ids); + // 删除用户角色关联 + extUserRoleMapper.deleteUserRoleByUserIds(ids); + extUserViewConditionMapper.deleteByUserIds(ids, orgId); + extUserViewMapper.deleteUserViewByUserIds(ids, orgId); + extUserSearchConfigMapper.deleteByUserIds(ids, orgId); + //删除用户收到的公告和通知 + extNotificationMapper.deleteByReceivers(ids, orgId); + } + // 踢出用户 + userList.forEach(user -> SessionUtils.kickOutUser(user.getName())); + extOrganizationUserMapper.deleteUserByOrgId(orgId); + + } + + /** + * 保存同步信息 + * + * @param users + * @param userExtends + * @param organizationUsers + * @param departmentCommanders + */ + public void save(List users, List userExtends, List organizationUsers, List departmentCommanders) { + userMapper.batchInsert(users); + userExtendMapper.batchInsert(userExtends); + organizationUserMapper.batchInsert(organizationUsers); + departmentCommanderMapper.batchInsert(departmentCommanders); + } + + public void disableUsers(List userList) { + try { + userList.forEach(user -> SessionUtils.kickOutUser(user.getUserId())); + } catch (Exception e) { + log.error("disableUsers kick out user error", e); + } + SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); + ExtUserMapper extUserMapper = sqlSession.getMapper(ExtUserMapper.class); + ExtOrganizationUserMapper extOrganizationUserMapper = sqlSession.getMapper(ExtOrganizationUserMapper.class); + userList.forEach(extUserMapper::updateUserInfo); + userList.forEach(extOrganizationUserMapper::disableUser); + sqlSession.flushStatements(); + SqlSessionUtils.closeSqlSession(sqlSession, sqlSessionFactory); + } + + + /** + * 更新同步信息 + * + * @param updateUsers + * @param updateOrganizationUsers + */ + public void update(List updateUsers, List updateOrganizationUsers) { + SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); + ExtUserMapper extUserMapper = sqlSession.getMapper(ExtUserMapper.class); + ExtOrganizationUserMapper extOrganizationUserMapper = sqlSession.getMapper(ExtOrganizationUserMapper.class); + updateUsers.forEach(extUserMapper::updateUser); + updateOrganizationUsers.forEach(extOrganizationUserMapper::updateOrganizationUser); + sqlSession.flushStatements(); + SqlSessionUtils.closeSqlSession(sqlSession, sqlSessionFactory); + } + + + /** + * 批量编辑用户 + * + * @param request + * @param operatorId + * @param organizationId + */ + public void batchEditUser(UserBatchEditRequest request, String operatorId, String organizationId) { + List originUser = extOrganizationUserMapper.selectByIds(request.getIds()); + extOrganizationUserMapper.updateUserByIds(request, operatorId, organizationId); + List modifiedUser = extOrganizationUserMapper.selectByIds(request.getIds()); + Map userMap = modifiedUser.stream().collect(Collectors.toMap(UserResponse::getUserId, Function.identity())); + + + List logs = originUser.stream() + .map(origUser -> { + LogDTO logDTO = new LogDTO(organizationId, origUser.getUserId(), operatorId, LogType.UPDATE, LogModule.SYSTEM_ORGANIZATION, origUser.getUserName()); + logDTO.setOriginalValue(origUser); + logDTO.setModifiedValue(userMap.get(origUser.getUserId())); + return logDTO; + }).toList(); + logService.batchAdd(logs); + + } + + + /** + * excel导入-下载模板 + * + * @param response + */ + public void downloadExcelTemplate(HttpServletResponse response) { + //获取表头字段 + List> heads = getTemplateHead(); + //表头备注信息 + UserTemplateWriteHandler handler = new UserTemplateWriteHandler(heads); + List> data = new ArrayList<>(); + + new EasyExcelExporter() + .exportByCustomWriteHandler(response, heads, data, Translator.get("user_import_template_name"), + Translator.get("user_import_template_sheet"), handler); + } + + + /** + * 获取表头字段 + * + * @return + */ + private List> getTemplateHead() { + return new UserExcelDataFactory().getUserExcelDataLocal().getHead(); + } + + + /** + * 导入excel检查 + * + * @param file + * @return + */ + public UserImportResponse preCheck(MultipartFile file, String orgId) { + if (file == null) { + throw new GenericException(Translator.get("file_cannot_be_null")); + } + + UserImportResponse response = new UserImportResponse(); + checkImportExcel(response, file, orgId); + return response; + } + + + private void checkImportExcel(UserImportResponse response, MultipartFile file, String orgId) { + try { + //根据本地语言环境选择用哪种数据对象进行存放读取的数据 + Class clazz = new UserExcelDataFactory().getExcelDataByLocal(); + UserCheckEventListener eventListener = new UserCheckEventListener(clazz, orgId); + FastExcelFactory.read(file.getInputStream(), eventListener).headRowNumber(3).sheet().doRead(); + response.setErrorMessages(eventListener.getErrList()); + response.setSuccessCount(eventListener.getList().size()); + response.setFailCount(eventListener.getErrList().size()); + } catch (Exception e) { + log.error("checkImportExcel error", e); + throw new GenericException(Translator.get("check_import_excel_error")); + } + } + + + /** + * 用户导入 + * + * @param file + * @param operatorId + * @param orgId + * @return + */ + public UserImportResponse importByExcel(MultipartFile file, String operatorId, String orgId) { + if (file == null) { + throw new GenericException(Translator.get("file_cannot_be_null")); + } + try { + UserImportResponse response = new UserImportResponse(); + //根据本地语言环境选择用哪种数据对象进行存放读取的数据 + Class clazz = new UserExcelDataFactory().getExcelDataByLocal(); + UserImportEventListener eventListener = new UserImportEventListener(clazz, operatorId, orgId); + FastExcelFactory.read(file.getInputStream(), eventListener).headRowNumber(3).sheet().doRead(); + response.setErrorMessages(eventListener.getErrList()); + response.setSuccessCount(eventListener.getSuccessCount()); + response.setFailCount(eventListener.getErrList().size()); + return response; + } catch (Exception e) { + log.error("checkImportExcel error", e); + throw new GenericException(Translator.get("check_import_excel_error")); + } + } + + + /** + * 保存导入数据 + * + * @param list + * @param departmentTree + * @param departmentMap + * @param operatorId + * @param orgId + */ + @CacheEvict(value = "dept_tree_cache", key = "#orgId", beforeInvocation = true) + public void saveImportData(List list, List departmentTree, Map departmentMap, String operatorId, String orgId) { + //部门 + List departmentPath = list.stream().map(UserExcelData::getDepartment).toList(); + // 记录日志 + List logs = new ArrayList<>(); + //构建部门树 + Map departmentPathMap = departmentService.createDepartment(departmentPath, orgId, departmentTree, operatorId, departmentMap, logs); + List nameList = list.stream().map(UserExcelData::getSupervisor).filter(StringUtils::isNotBlank).toList(); + List supervisorList = new ArrayList<>(); + if (CollectionUtils.isNotEmpty(nameList)) { + supervisorList = extOrganizationUserMapper.selectSupervisor(nameList, orgId); + } + + List userList = new ArrayList<>(); + List organizationUsers = new ArrayList<>(); + List finalSupervisorList = supervisorList; + list.forEach(userData -> { + buildUser(userData, operatorId, userList, departmentPathMap, orgId, organizationUsers, finalSupervisorList, logs); + }); + + userMapper.batchInsert(userList); + organizationUserMapper.batchInsert(organizationUsers); + + logService.batchAdd(logs); + } + + private void buildUser(UserExcelData userData, String operatorId, List userList, Map departmentPathMap, String orgId, List organizationUsers, List supervisorList, List logs) { + User user = new User(); + user.setId(IDGenerator.nextStr()); + user.setLastOrganizationId(orgId); + user.setName(userData.getName()); + user.setPhone(userData.getPhone()); + user.setEmail(userData.getEmail()); + user.setPassword(CodingUtils.md5(userData.getPhone().substring(userData.getPhone().length() - 6))); + user.setLanguage(Locale.SIMPLIFIED_CHINESE.toString()); + user.setGender(Boolean.valueOf(userData.getGender())); + user.setCreateUser(operatorId); + user.setCreateTime(System.currentTimeMillis()); + user.setUpdateUser(operatorId); + user.setUpdateTime(System.currentTimeMillis()); + userList.add(user); + + OrganizationUser organizationUser = new OrganizationUser(); + organizationUser.setId(IDGenerator.nextStr()); + organizationUser.setOrganizationId(orgId); + organizationUser.setDepartmentId(departmentPathMap.get("/" + userData.getDepartment())); + organizationUser.setUserId(user.getId()); + organizationUser.setEnable(true); + organizationUser.setEmployeeId(userData.getEmployeeId()); + organizationUser.setPosition(userData.getPosition()); + organizationUser.setEmployeeType(userData.getEmployeeType()); + organizationUser.setSupervisorId(handleSupervisor(supervisorList, organizationUser.getDepartmentId(), userData.getSupervisor())); + organizationUser.setWorkCity(userData.getWorkCity()); + organizationUser.setCreateTime(System.currentTimeMillis()); + organizationUser.setCreateUser(operatorId); + organizationUser.setUpdateTime(System.currentTimeMillis()); + organizationUser.setUpdateUser(operatorId); + organizationUsers.add(organizationUser); + + + LogDTO logDTO = new LogDTO(orgId, user.getId(), operatorId, LogType.ADD, LogModule.SYSTEM_ORGANIZATION, userData.getName()); + logDTO.setModifiedValue(user); + logs.add(logDTO); + + } + + + /** + * 所属上级 + * + * @param supervisorList + * @param departmentId + * @param name + * @return + */ + private String handleSupervisor(List supervisorList, String departmentId, String name) { + if (StringUtils.isBlank(name)) { + return null; + } + List departmentUsers = supervisorList.stream().filter(supervisor -> Strings.CI.equals(supervisor.getName(), name) && Strings.CI.equals(supervisor.getDepartmentId(), departmentId)).toList(); + if (CollectionUtils.isNotEmpty(departmentUsers)) { + List userIds = departmentUsers.stream().map(UserImportDTO::getUserId).toList(); + List commanders = extDepartmentCommanderMapper.selectCommanderByUsers(departmentId, userIds); + if (CollectionUtils.isNotEmpty(commanders)) { + return commanders.getFirst().getUserId(); + } + return departmentUsers.getFirst().getUserId(); + } + + List unDepartmentUsers = supervisorList.stream().filter(supervisor -> Strings.CI.equals(supervisor.getName(), name)).toList(); + if (CollectionUtils.isNotEmpty(unDepartmentUsers)) { + return unDepartmentUsers.getFirst().getUserId(); + } + return null; + } + + + /** + * 导入校验电话号码唯一 + * + * @param phone + * @return + */ + public boolean checkPhone(String phone) { + return extUserMapper.countByPhone(phone, null) > 0; + } + + /** + * 导入校验邮箱唯一 + * + * @param email + * @return + */ + public boolean checkEmail(String email) { + return extUserMapper.countByEmail(email, null) > 0; + } + + /** + * 获取系统用户options + * + * @param orgId 组织ID + * @return 用户选项列表 + */ + public List getUserOptions(String orgId) { + List allDpIds = getSortDepartmentIds(orgId); + String defaultOrder = CollectionUtils.isEmpty(allDpIds) ? StringUtils.EMPTY : buildOrderByFieldClause(allDpIds); + return extUserMapper.selectUserOptionByOrgId(orgId, defaultOrder); + } + + + /** + * 删除用户(单个) + * + * @param id + * @param orgId + */ + @OperationLog(module = LogModule.SYSTEM_ORGANIZATION, type = LogType.DELETE, resourceId = "{#id}") + public void deleteUserById(String id, String orgId) { + UserResponse user = extUserMapper.getUserDetail(id); + if (checkUserResource(user.getUserId())) { + approvalActionService.refreshApprovingTasksForDisabledUser(List.of(user.getUserId()), orgId); + //删除后该员工在系统上的全部数据将会被清理 + deleteUserAllData(user.getUserId(), id, orgId); + // 踢出该用户 + SessionUtils.kickOutUser(SessionUtils.getUserId(), user.getUserId()); + // 添加日志上下文 + OperationLogContext.setResourceName(user.getUserName()); + } else { + throw new GenericException(Translator.get("user_resource_exist")); + } + + permissionCache.clearCache(user.getUserId(), orgId); + } + + + /** + * 删除用户全部数据 + * + * @param userId + */ + private void deleteUserAllData(String userId, String id, String orgId) { + OrganizationUser organizationUser = new OrganizationUser(); + organizationUser.setUserId(userId); + if (organizationUserMapper.countByExample(organizationUser) == 1) { + //删除主用户 + userMapper.deleteByPrimaryKey(userId); + } + //删除用户 + organizationUserMapper.deleteByPrimaryKey(id); + //删除用户关联角色 + List userRoleIds = extUserRoleMapper.selectUserRole(userId, orgId); + if (CollectionUtils.isNotEmpty(userRoleIds)) { + extUserRoleMapper.deleteByIds(userRoleIds); + } + + } + + public boolean checkUserResource(String id) { + return extOpportunityMapper.countByOwner(id) <= 0 && + extCustomerMapper.countByOwner(id) <= 0 && + extClueMapper.countByOwner(id) <= 0; + } + + public boolean deleteCheck(String id) { + UserResponse user = extUserMapper.getUserDetail(id); + return checkUserResource(user.getUserId()); + } + + public List getUserByOrgId(String orgId) { + return extOrganizationUserMapper.getUserByOrgId(orgId); + } + + public String getSupervisorName(String id) { + User user = userMapper.selectByPrimaryKey(id); + return Optional.ofNullable(user).map(User::getName).orElse(null); + } + + + @OperationLog(module = LogModule.SYSTEM_ORGANIZATION, type = LogType.UPDATE, operator = "{#operatorId}") + public void updateUserName(UserUpdateName request, String operatorId) { + User originUser = userMapper.selectByPrimaryKey(request.getUserId()); + + User user = new User(); + user.setName(request.getName()); + user.setUpdateTime(System.currentTimeMillis()); + user.setUpdateUser(operatorId); + user.setId(request.getUserId()); + userMapper.update(user); + + extOrganizationUserMapper.updateUserByUserId(user.getId(), System.currentTimeMillis(), operatorId); + + Map originalVal = new HashMap<>(1); + originalVal.put("name", originUser.getName()); + Map modifiedVal = new HashMap<>(1); + modifiedVal.put("name", request.getName()); + OperationLogContext.setContext(LogContextInfo.builder() + .originalValue(originalVal) + .resourceName(request.getName()) + .modifiedValue(modifiedVal) + .resourceId(request.getUserId()) + .build()); + + } + + /** + * 获取单个部门下的用户 + * + * @param departmentId + * @param orgId + * @return + */ + public List getUserTreeByDepId(String departmentId, String orgId) { + List treeNodes = extDepartmentMapper.selectDeptUserTreeNode(orgId); + List userNodes = extUserRoleMapper.selectUserDeptForOrg(orgId); + userNodes.forEach(userNode -> { + if (!Strings.CS.equals(userNode.getParentId(), departmentId)) { + userNode.setEnabled(false); + } + }); + userNodes.addAll(treeNodes); + return BaseTreeNode.buildTree(userNodes); + } + + private List getSortDepartmentIds(String orgId) { + List sortDpIds = new ArrayList<>(); + List dTree = departmentService.getTree(orgId); + if (CollectionUtils.isEmpty(dTree)) { + return new ArrayList<>(); + } + Deque stack = new ArrayDeque<>(); + stack.push(dTree.getFirst()); + + while (!stack.isEmpty()) { + BaseTreeNode currentNode = stack.pop(); + sortDpIds.add(currentNode.getId()); + List childrenList = currentNode.getChildren(); + if (CollectionUtils.isNotEmpty(childrenList)) { + for (int i = childrenList.size() - 1; i >= 0; i--) { + stack.push(childrenList.get(i)); + } + } + } + + return sortDpIds; + } + + public List getAdminUserOptions(String organizationId) { + return extUserRoleMapper.selectUserOptionByRoleId(organizationId, InternalRole.ORG_ADMIN.getValue()); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/PersonalCenterService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/PersonalCenterService.java new file mode 100644 index 0000000..475d7a4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/PersonalCenterService.java @@ -0,0 +1,267 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.ModuleKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.pager.PageUtils; +import cn.cordys.common.pager.PagerWithOption; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.CodingUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.follow.dto.request.FollowUpPlanPageRequest; +import cn.cordys.crm.follow.dto.response.FollowUpPlanListResponse; +import cn.cordys.crm.follow.mapper.ExtFollowUpPlanMapper; +import cn.cordys.crm.follow.service.FollowUpPlanService; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.domain.Module; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.request.PersonalInfoRequest; +import cn.cordys.crm.system.dto.request.PersonalPasswordRequest; +import cn.cordys.crm.system.dto.request.SendEmailDTO; +import cn.cordys.crm.system.dto.response.UserResponse; +import cn.cordys.crm.system.mapper.ExtOrganizationUserMapper; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.crm.system.mapper.ExtUserRoleMapper; +import cn.cordys.crm.system.utils.MailSender; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import com.github.pagehelper.Page; +import com.github.pagehelper.PageHelper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +import static cn.cordys.security.SessionUtils.kickOutUser; + +@Service +public class PersonalCenterService { + + private static final String PREFIX = "personal_email_code:"; // Redis 存储前缀 + @Resource + private StringRedisTemplate stringRedisTemplate; + @Resource + private MailSender mailSender; + @Resource + private OrganizationUserService organizationUserService; + @Resource + private FollowUpPlanService followUpPlanService; + @Resource + private ExtFollowUpPlanMapper extFollowUpPlanMapper; + @Resource + private ExtUserMapper extUserMapper; + @Resource + private BaseMapper userBaseMapper; + @Resource + private ExtUserRoleMapper extUserRoleMapper; + @Resource + private BaseMapper moduleMapper; + @Resource + private ExtOrganizationUserMapper extOrganizationUserMapper; + + public UserResponse getUserDetail(String id, String orgId) { + if (Strings.CS.equals(id, InternalUser.ADMIN.getValue())) { + return BeanUtils.copyBean(new UserResponse(), userBaseMapper.selectByPrimaryKey(id)); + } + String orgUserIdByUserId = extOrganizationUserMapper.getOrgUserIdByUserId(orgId, id); + return organizationUserService.getUserDetail(orgUserIdByUserId); + } + + /** + * 发送验证码 + */ + public void sendCode(SendEmailDTO emailDTO, String organizationId) { + String email = emailDTO.getEmail(); + String redisKey = PREFIX + email; + if (stringRedisTemplate.hasKey(redisKey)) { + stringRedisTemplate.delete(redisKey); // 验证通过后删除验证码 + } + String code = generateCode(); + saveCode(email, code); + + try { + String emailContent = Translator.get("email_setting_content") + .replace("${code}", code); + + mailSender.send( + Translator.get("email_setting_subject"), + emailContent, + new String[]{email}, + new String[0], + organizationId + ); + } catch (Exception e) { + stringRedisTemplate.delete(redisKey); + throw new GenericException(e.getMessage()); + } + } + + /** + * 存储验证码到 Redis,设置有效期 10 分钟 + */ + private void saveCode(String email, String code) { + stringRedisTemplate.opsForValue().set(PREFIX + email, code, 10, TimeUnit.MINUTES); + } + + /** + * 生成6位随机验证码 + */ + private String generateCode() { + return String.valueOf(100000 + new Random().nextInt(900000)); + } + + /** + * 重置用户密码 + * + * @param personalPasswordRequest 包含邮箱、验证码和新密码的请求对象 + * @param operatorId 操作者用户ID + * + * @throws GenericException 验证码错误或密码重置失败时抛出 + */ + public void resetUserPassword(PersonalPasswordRequest personalPasswordRequest, String operatorId) { + String password = personalPasswordRequest.getPassword(); + //检查原密码 + if (checkPwd(personalPasswordRequest.getOriginPassword(), operatorId)) { + // 更新用户密码 + extUserMapper.updateUserPassword(CodingUtils.md5(password), operatorId); + // 登出当前用户 + kickOutUser(operatorId, operatorId); + } else { + throw new GenericException(Translator.get("password_reset_error")); + } + } + + /** + * 检查原密码 + * + * @param originPassword + * @param userId + * + * @return + */ + private boolean checkPwd(String originPassword, String userId) { + User example = new User(); + example.setId(userId); + example.setPassword(CodingUtils.md5(originPassword)); + return userBaseMapper.exist(example); + } + + @OperationLog(module = LogModule.SYSTEM_ORGANIZATION, type = LogType.UPDATE, operator = "{#userId}") + public UserResponse updateInfo(PersonalInfoRequest personalInfoRequest, String userId, String orgId) { + User oldUser = userBaseMapper.selectByPrimaryKey(userId); + int countByPhone = extUserMapper.countByPhone(personalInfoRequest.getPhone(), userId); + if (countByPhone > 0) { + throw new GenericException(Translator.get("phone.exist")); + } + int countByEmail = extUserMapper.countByEmail(personalInfoRequest.getEmail(), userId); + if (countByEmail > 0) { + throw new GenericException(Translator.get("email.exist")); + } + User user = new User(); + user.setId(userId); + user.setPhone(personalInfoRequest.getPhone()); + user.setEmail(personalInfoRequest.getEmail()); + userBaseMapper.update(user); + + UserResponse userDetail = getUserDetail(userId, orgId); + + //添加日志上下文 + OperationLogContext.setContext(LogContextInfo.builder() + .originalValue(oldUser) + .resourceName(oldUser.getName()) + .modifiedValue(userDetail) + .resourceId(userId) + .build()); + + return userDetail; + } + + /** + * 分页获取跟进计划列表 + * + * @param request 分页查询请求参数 + * @param userId 当前用户ID + * @param organizationId 组织ID + * + * @return 分页的跟进计划列表及选项数据 + */ + public PagerWithOption> getPlanList( + FollowUpPlanPageRequest request, + String userId, + String organizationId) { + + // 1. 获取用户权限和模块信息 + List permissions = extUserRoleMapper.selectPermissionsByUserId(userId); + boolean isAdmin = Strings.CI.equals(userId, InternalUser.ADMIN.getValue()); + + // 2. 查询已启用的模块 + List enabledModules = moduleMapper.selectListByLambda( + new LambdaQueryWrapper() + .eq(Module::getOrganizationId, organizationId) + .eq(Module::getEnable, true) + ).stream() + .map(Module::getModuleKey) + .toList(); + + // 3. 构建可访问的资源类型列表 + List resourceTypeList = buildAccessibleResourceTypes(permissions, isAdmin, enabledModules); + + // 4. 初始化分页 + Page page = PageHelper.startPage(request.getCurrent(), request.getPageSize()); + // 5. 查询跟进计划基础数据 + if (CollectionUtils.isEmpty(resourceTypeList)) { + return PageUtils.setPageInfoWithOption(page, new ArrayList<>(), new HashMap<>()); + + } + List planList = extFollowUpPlanMapper.selectList(request, userId, organizationId, null, null, resourceTypeList); + + // 6. 构建完整数据和选项映射 + List enrichedList = followUpPlanService.buildListData(planList, organizationId); + Map> optionMap = followUpPlanService.buildOptionMap(organizationId, planList, enrichedList); + + // 7. 返回分页结果 + return PageUtils.setPageInfoWithOption(page, enrichedList, optionMap); + } + + /** + * 构建用户可访问的资源类型列表 + */ + private List buildAccessibleResourceTypes(List permissions, + boolean isAdmin, + List enabledModules) { + List resourceTypes = new ArrayList<>(); + + // 检查客户模块权限 + if ((permissions.contains(PermissionConstants.CUSTOMER_MANAGEMENT_READ) || isAdmin) + && enabledModules.contains(ModuleKey.CUSTOMER.getKey())) { + resourceTypes.add(NotificationConstants.Module.CUSTOMER); + } + + // 检查商机模块权限 + if ((permissions.contains(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) || isAdmin) + && enabledModules.contains(ModuleKey.BUSINESS.getKey())) { + resourceTypes.add(NotificationConstants.Module.OPPORTUNITY); + } + + // 检查线索模块权限 + if ((permissions.contains(PermissionConstants.CLUE_MANAGEMENT_READ) || isAdmin) + && enabledModules.contains(ModuleKey.CLUE.getKey())) { + resourceTypes.add(NotificationConstants.Module.CLUE); + } + + return resourceTypes; + } + + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/PicService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/PicService.java new file mode 100644 index 0000000..2db8d2d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/PicService.java @@ -0,0 +1,47 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.exception.GenericException; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * @author song-cc-rock + */ +@Service +public class PicService { + + @Resource + private AttachmentService attachmentService; + + + /** + * 上传临时图片 + * + * @param pics 上传图片集合 + */ + public List uploadTempPic(List pics) { + pics.forEach(pic -> { + String filename = pic.getOriginalFilename(); + if (StringUtils.isBlank(filename)) { + throw new GenericException("图片类型不支持!"); + } + }); + return attachmentService.uploadTemp(pics); + } + + /** + * 获取图片文件流 + * + * @param picId 图片ID + * + * @return 文件流 + */ + public ResponseEntity getResource(String picId) { + return attachmentService.getResource(picId); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/RoleService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/RoleService.java new file mode 100644 index 0000000..07d255c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/RoleService.java @@ -0,0 +1,588 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.common.constants.RoleDataScope; +import cn.cordys.common.dto.RoleDataScopeDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.permission.Permission; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.permission.PermissionDefinitionItem; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.domain.Role; +import cn.cordys.crm.system.domain.RolePermission; +import cn.cordys.crm.system.domain.RoleScopeDept; +import cn.cordys.crm.system.domain.UserRole; +import cn.cordys.crm.system.dto.log.RoleLogDTO; +import cn.cordys.crm.system.dto.request.PermissionUpdateRequest; +import cn.cordys.crm.system.dto.request.RoleAddRequest; +import cn.cordys.crm.system.dto.request.RoleUpdateRequest; +import cn.cordys.crm.system.dto.response.RoleGetResponse; +import cn.cordys.crm.system.dto.response.RoleListResponse; +import cn.cordys.crm.system.mapper.ExtRoleMapper; +import cn.cordys.crm.system.mapper.ExtUserRoleMapper; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; + +import static cn.cordys.crm.system.constants.SystemResultCode.INTERNAL_ROLE_PERMISSION; +import static cn.cordys.crm.system.constants.SystemResultCode.ROLE_EXIST; + +/** + * @author jianxing + * @date 2025-01-03 12:01:54 + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class RoleService { + + @Resource + private BaseMapper roleMapper; + @Resource + private ExtRoleMapper extRoleMapper; + @Resource + private ExtUserRoleMapper extUserRoleMapper; + @Resource + private BaseMapper userRoleMapper; + @Resource + private BaseMapper roleScopeDeptMapper; + @Resource + private BaseMapper rolePermissionMapper; + @Resource + private BaseService baseService; + @Resource + private PermissionCache permissionCache; + + public List list(String orgId) { + List roleListResponseList = getRoleListResponses(orgId); + return baseService.setCreateAndUpdateUserName(roleListResponseList); + } + + public List getRoleListResponses(String orgId) { + Role role = new Role(); + role.setOrganizationId(orgId); + List roles = roleMapper.select(role); + List roleListResponseList = JSON.parseArray(JSON.toJSONString(roles), RoleListResponse.class); + // 翻译内置角色名称 + roleListResponseList.stream() + .filter(RoleListResponse::getInternal) + .forEach(this::translateInternalRole); + // 按创建时间排序 + roleListResponseList.sort(Comparator.comparingLong(RoleListResponse::getCreateTime)); + return roleListResponseList; + } + + /** + * 翻译内置角色名 + * + * @param role + * + * @return + */ + public Role translateInternalRole(Role role) { + if (BooleanUtils.isTrue(role.getInternal())) { + role.setName(translateInternalRole(role.getName())); + } + return role; + } + + /** + * 翻译内置角色名 + * + * @param roleKey + * + * @return + */ + public String translateInternalRole(String roleKey) { + return Translator.get("role." + roleKey, roleKey); + } + + public RoleGetResponse get(String id) { + Role role = roleMapper.selectByPrimaryKey(id); + RoleGetResponse roleGetResponse = BeanUtils.copyBean(new RoleGetResponse(), role); + translateInternalRole(roleGetResponse); + roleGetResponse.setPermissions(getPermissionSetting(id)); + if (Strings.CS.equals(role.getDataScope(), RoleDataScope.DEPT_CUSTOM.name())) { + roleGetResponse.setDeptIds(getDeptIdsByRoleId(id)); + } + return baseService.setCreateAndUpdateUserName(roleGetResponse); + } + + private List getDeptIdsByRoleId(String roleId) { + RoleScopeDept example = new RoleScopeDept(); + example.setRoleId(roleId); + return roleScopeDeptMapper.select(example) + .stream() + .map(RoleScopeDept::getDepartmentId) + .collect(Collectors.toList()); + } + + public List getDeptIdsByRoleIds(List roleIds) { + if (CollectionUtils.isEmpty(roleIds)) { + return List.of(); + } + List roleScopeDeptList = getRoleScopeDeptByRoleIds(roleIds); + return roleScopeDeptList + .stream() + .map(RoleScopeDept::getDepartmentId) + .distinct() + .collect(Collectors.toList()); + } + + public List getRoleScopeDeptByRoleIds(List roleIds) { + if (CollectionUtils.isEmpty(roleIds)) { + return List.of(); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(RoleScopeDept::getRoleId, roleIds); + return roleScopeDeptMapper.selectListByLambda(wrapper); + } + + @OperationLog(module = LogModule.SYSTEM_ROLE, type = LogType.ADD, resourceName = "{#request.name}") + public Role add(RoleAddRequest request, String userId, String orgId) { + Role role = BeanUtils.copyBean(new Role(), request); + role.setId(IDGenerator.nextStr()); + role.setCreateTime(System.currentTimeMillis()); + role.setUpdateTime(System.currentTimeMillis()); + role.setUpdateUser(userId); + role.setCreateUser(userId); + role.setInternal(false); + role.setOrganizationId(orgId); + // 创建默认仅可查看本人数据 + role.setDataScope(Optional.ofNullable(request.getDataScope()).orElse(RoleDataScope.SELF.name())); + // 校验名称重复 + checkAddExist(role); + roleMapper.insert(role); + + // 配置指定部门权限 + if (Strings.CS.equals(request.getDataScope(), RoleDataScope.DEPT_CUSTOM.name()) && CollectionUtils.isNotEmpty(request.getDeptIds())) { + roleScopeDeptMapper.batchInsert(getRoleScopeDept(role.getId(), request.getDeptIds())); + } + + // 配置权限 + insertRolePermission(request.getPermissions(), role.getId()); + + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceId(role.getId()) + .modifiedValue(role) + .build() + ); + + return role; + } + + private List getRoleScopeDept(String roleId, List deptIds) { + return deptIds.stream().map(deptId -> { + RoleScopeDept roleScopeDept = new RoleScopeDept(); + roleScopeDept.setId(IDGenerator.nextStr()); + roleScopeDept.setRoleId(roleId); + roleScopeDept.setDepartmentId(deptId); + return roleScopeDept; + }).collect(Collectors.toList()); + } + + @OperationLog(module = LogModule.SYSTEM_ROLE, type = LogType.UPDATE, resourceId = "{#request.id}") + public Role update(RoleUpdateRequest request, String userId, String orgId) { + Role originRole = roleMapper.selectByPrimaryKey(request.getId()); + if (originRole == null) { + throw new GenericException(Translator.get("role.not_exist")); + } + + Role role = BeanUtils.copyBean(new Role(), request); + // 校验名称重复 + checkUpdateExist(role); + if (originRole.getInternal()) { + // 内置角色名称不能修改 + role.setName(null); + } + role.setUpdateTime(System.currentTimeMillis()); + role.setUpdateUser(userId); + roleMapper.update(role); + + RoleGetResponse roleGetResponse = get(role.getId()); + + String dataScope = request.getDataScope(); + if (StringUtils.isNotBlank(dataScope) + && Strings.CS.equals(originRole.getDataScope(), RoleDataScope.DEPT_CUSTOM.name()) + && !Strings.CS.equals(dataScope, RoleDataScope.DEPT_CUSTOM.name())) { + // 如果从自定义部门改为其他数据权限,则删除部门关联关系 + deleteRoleScopeDeptByRoleId(role.getId()); + } + + List originDeptIds = null; + if (request.getDeptIds() != null) { + originDeptIds = getDeptIdsByRoleId(request.getId()); + } + + List originPermissionIds = null; + if (request.getPermissions() != null) { + originPermissionIds = getPermissionIds(List.of(role.getId())).stream().toList(); + } + + // 配置指定部门权限 + if (Strings.CS.equals(dataScope, RoleDataScope.DEPT_CUSTOM.name()) && request.getDeptIds() != null) { + // 先删除 + deleteRoleScopeDeptByRoleId(role.getId()); + + // 再添加 + if (CollectionUtils.isNotEmpty(request.getDeptIds())) { + roleScopeDeptMapper.batchInsert(getRoleScopeDept(role.getId(), request.getDeptIds())); + } + } + + // 更新权限设置 + updatePermissionSetting(request.getPermissions(), role.getId()); + + clearPermissionCacheByRoleId(role.getId(), orgId); + + RoleLogDTO originRoleLogDTO = BeanUtils.copyBean(new RoleLogDTO(), originRole); + RoleLogDTO modifiedRoleLogDTO = BeanUtils.copyBean(new RoleLogDTO(), roleMapper.selectByPrimaryKey(request.getId())); + if (request.getDeptIds() != null) { + modifiedRoleLogDTO.setDeptIds(request.getDeptIds()); + originRoleLogDTO.setDeptIds(originDeptIds); + } + + if (request.getPermissions() != null) { + final Set newPermissionIdSet = request.getPermissions().stream() + .filter(p -> BooleanUtils.isTrue(p.getEnable())) + .map(PermissionUpdateRequest::getId) + .collect(Collectors.toSet()); + + final Set originPermissionIdSet = originPermissionIds == null + ? Collections.emptySet() + : new HashSet<>(originPermissionIds); + + if (!Objects.equals(newPermissionIdSet, originPermissionIdSet)) { + // 如果权限有变更,则记录权限 + modifiedRoleLogDTO.setPermissions(new ArrayList<>(newPermissionIdSet)); + originRoleLogDTO.setPermissions(new ArrayList<>(originPermissionIdSet)); + } + } + + OperationLogContext.setContext( + LogContextInfo.builder() + .resourceName(roleGetResponse.getName()) + .originalValue(originRoleLogDTO) + .modifiedValue(modifiedRoleLogDTO) + .build() + ); + + return roleGetResponse; + } + + private void clearPermissionCacheByRoleId(String roleId, String orgId) { + List userIds = extUserRoleMapper.getUserIdsByRoleIds(List.of(roleId)); + userIds.forEach(userId -> { + // 清除用户的权限缓存 + permissionCache.clearCache(userId, orgId); + }); + } + + private void deleteRoleScopeDeptByRoleId(String roleId) { + RoleScopeDept example = new RoleScopeDept(); + example.setRoleId(roleId); + roleScopeDeptMapper.delete(example); + } + + /** + * 校验是否是内置角色 + */ + public void checkInternalRole(String roleId) { + Role role = roleMapper.selectByPrimaryKey(roleId); + if (BooleanUtils.isTrue(role.getInternal())) { + throw new GenericException(INTERNAL_ROLE_PERMISSION); + } + } + + private void checkAddExist(Role role) { + if (extRoleMapper.checkAddExist(role)) { + throw new GenericException(ROLE_EXIST); + } + } + + private void checkUpdateExist(Role role) { + if (extRoleMapper.checkUpdateExist(role)) { + throw new GenericException(ROLE_EXIST); + } + } + + @OperationLog(module = LogModule.SYSTEM_ROLE, type = LogType.DELETE, resourceId = "{#id}") + public void delete(String id, String orgId) { + Role role = roleMapper.selectByPrimaryKey(id); + if (role == null) { + throw new GenericException(Translator.get("role.not_exist")); + } + // 内置角色不能删除 + checkInternalRole(role.getId()); + // 删除角色 + roleMapper.deleteByPrimaryKey(id); + // 删除与权限的关联关系 + deletePermissionByRoleId(id); + // 删除与部门的关联关系 + deleteRoleScopeDeptByRoleId(id); + + clearPermissionCacheByRoleId(role.getId(), orgId); + // 删除与用户的关联关系 + deleteUserRoleByRoleId(id); + + // 设置操作对象 + OperationLogContext.setResourceName(role.getName()); + } + + private void deletePermissionByRoleId(String id) { + RolePermission example = new RolePermission(); + example.setRoleId(id); + rolePermissionMapper.delete(example); + } + + private void deleteUserRoleByRoleId(String id) { + UserRole example = new UserRole(); + example.setRoleId(id); + userRoleMapper.delete(example); + } + + public List getPermissionSetting(String id) { + // 获取该角色拥有的权限 + Set permissionIds = getPermissionIdSetByRoleId(id); + return getPermissionDefinitionItems(permissionIds); + } + + /** + * 获取空的权限配置 + * + * @return + */ + public List getPermissionSetting() { + return getPermissionDefinitionItems(Set.of()); + } + + private List getPermissionDefinitionItems(Set permissionIds) { + // 获取所有的权限 + List permissionDefinitions = getPermissionDefinitions(); + // 设置勾选项 + for (PermissionDefinitionItem firstLevel : permissionDefinitions) { + List children = firstLevel.getChildren(); + boolean allCheck = true; + firstLevel.setName(Translator.get(firstLevel.getName())); + for (PermissionDefinitionItem secondLevel : children) { + List permissions = secondLevel.getPermissions(); + secondLevel.setName(Translator.get(secondLevel.getName())); + if (CollectionUtils.isEmpty(permissions)) { + continue; + } + boolean secondAllCheck = true; + for (Permission p : permissions) { + if (StringUtils.isNotBlank(p.getName())) { + // 有 name 字段翻译 name 字段 + p.setName(Translator.get(p.getName())); + } else { + p.setName(translateDefaultPermissionName(p.getId())); + } + if (permissionIds.contains(p.getId())) { + p.setEnable(true); + } else { + // 如果权限有未勾选,则二级菜单设置为未勾选 + p.setEnable(false); + secondAllCheck = false; + } + } + secondLevel.setEnable(secondAllCheck); + if (!secondAllCheck) { + // 如果二级菜单有未勾选,则一级菜单设置为未勾选 + allCheck = false; + } + } + firstLevel.setEnable(allCheck); + } + return permissionDefinitions; + } + + public List getPermissionDefinitions() { + List permissionDefinitions = null; + try { + Enumeration urls = this.getClass().getClassLoader().getResources("permission.json"); + while (urls.hasMoreElements()) { + URL url = urls.nextElement(); + String content = IOUtils.toString(url.openStream(), StandardCharsets.UTF_8); + if (StringUtils.isBlank(content)) { + continue; + } + List temp = JSON.parseArray(content, PermissionDefinitionItem.class); + if (permissionDefinitions == null) { + permissionDefinitions = temp; + } else { + permissionDefinitions.addAll(temp); + } + } + } catch (IOException e) { + throw new GenericException(e); + } + return permissionDefinitions; + } + + /** + * 翻译默认的权限名称 + * + * @param permissionId + * + * @return + */ + public String translateDefaultPermissionName(String permissionId) { + String[] idSplit = permissionId.split(":"); + String permissionKey = idSplit[idSplit.length - 1]; + String permissionName = "permission." + permissionKey.toLowerCase(); + return Translator.get(permissionName); + } + + /** + * 查询用户组对应的权限ID + * + * @param roleId + * + * @return + */ + public Set getPermissionIdSetByRoleId(String roleId) { + return getRolePermissionByRoleId(roleId).stream() + .map(RolePermission::getPermissionId) + .collect(Collectors.toSet()); + } + + /** + * 查询用户组对应的权限列表 + * + * @param roleId + * + * @return + */ + public List getRolePermissionByRoleId(String roleId) { + RolePermission example = new RolePermission(); + example.setRoleId(roleId); + return rolePermissionMapper.select(example); + } + + public Set getPermissionIds(List roleIds) { + return getPermissions(roleIds) + .stream() + .map(RolePermission::getPermissionId) + .collect(Collectors.toSet()); + } + + public List getPermissions(List roleIds) { + if (CollectionUtils.isEmpty(roleIds)) { + return List.of(); + } + var userLambdaQueryWrapper = new LambdaQueryWrapper() + .in(RolePermission::getRoleId, roleIds); + return rolePermissionMapper.selectListByLambda(userLambdaQueryWrapper); + } + + + public List getRoleIdsByUserId(String userId) { + List userRoles = getUserRolesByUserId(userId); + if (CollectionUtils.isEmpty(userRoles)) { + return List.of(); + } + return userRoles.stream() + .map(UserRole::getRoleId) + .toList(); + } + + public List getUserRolesByUserId(String userId) { + UserRole example = new UserRole(); + example.setUserId(userId); + return userRoleMapper.select(example); + } + + /** + * 更新单个用户组的配置项 + * + * @param permissions + */ + public void updatePermissionSetting(List permissions, String roleId) { + if (permissions == null) { + return; + } + + // 先删除 + deletePermissionByRoleId(roleId); + + // 再新增 + insertRolePermission(permissions, roleId); + } + + private void insertRolePermission(List permissions, String roleId) { + if (CollectionUtils.isEmpty(permissions)) { + return; + } + permissions.forEach(permission -> { + if (BooleanUtils.isTrue(permission.getEnable())) { + String permissionId = permission.getId(); + RolePermission rolePermission = new RolePermission(); + rolePermission.setId(IDGenerator.nextStr()); + rolePermission.setRoleId(roleId); + rolePermission.setPermissionId(permissionId); + rolePermissionMapper.insert(rolePermission); + } + }); + } + + public List getByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return List.of(); + } + return roleMapper.selectByIds(ids.toArray(new String[0])); + } + + public List getRoleOptions(String userId, String orgId) { + List roleIds = getRoleIdsByUserId(userId); + if (CollectionUtils.isEmpty(roleIds)) { + return List.of(); + } + List roles = getByIds(roleIds); + return roles.stream() + .filter(role -> Strings.CS.equals(role.getOrganizationId(), orgId)) + .map(role -> { + role = translateInternalRole(role); + return BeanUtils.copyBean(new RoleDataScopeDTO(), role); + }).toList(); + } + + public List getUserRoleByRoleIds(List roleIds) { + if (CollectionUtils.isEmpty(roleIds)) { + return List.of(); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(UserRole::getRoleId, roleIds); + return userRoleMapper.selectListByLambda(wrapper); + } + + public List getRolePermissionByRoleIds(List roleIds) { + if (CollectionUtils.isEmpty(roleIds)) { + return List.of(); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(RolePermission::getRoleId, roleIds); + return rolePermissionMapper.selectListByLambda(wrapper); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/SendModuleService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/SendModuleService.java new file mode 100644 index 0000000..7eaa4d6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/SendModuleService.java @@ -0,0 +1,57 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.constants.ModuleKey; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.domain.Module; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Service +@Transactional(rollbackFor = Exception.class) +public class SendModuleService { + + @Resource + private BaseMapper moduleMapper; + + /** + * 获取已开启的模块 + * + * @return 已开启的模块列表 + */ + public List getNoticeModules() { + List enabledModules = moduleMapper.selectListByLambda( + new LambdaQueryWrapper() + .eq(Module::getOrganizationId, OrganizationContext.getOrganizationId()) + .eq(Module::getEnable, true) + ).stream() + .map(Module::getModuleKey).distinct() + .toList(); + + List modules = new ArrayList<>(); + for (String enabledModule : enabledModules) { + if (Strings.CI.equals(enabledModule, ModuleKey.BUSINESS.getKey())) { + modules.add(NotificationConstants.Module.OPPORTUNITY); + } + if (Strings.CI.equals(enabledModule, ModuleKey.CUSTOMER.getKey())) { + modules.add(NotificationConstants.Module.CUSTOMER); + } + if (Strings.CI.equals(enabledModule, ModuleKey.CLUE.getKey())) { + modules.add(NotificationConstants.Module.CLUE); + } + if (Strings.CI.equals(enabledModule, "contract")) { + modules.add(NotificationConstants.Module.CONTRACT); + } + } + //这里是系统模块的通知,默认开启的 + modules.add(NotificationConstants.Module.SYSTEM); + return modules; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/StageAdvancedConfigService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/StageAdvancedConfigService.java new file mode 100644 index 0000000..c4bfeb6 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/StageAdvancedConfigService.java @@ -0,0 +1,185 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.dto.stage.CirculationSetting; +import cn.cordys.common.dto.stage.StageAdvancedConfigRequest; +import cn.cordys.common.dto.stage.StageConfigResponse; +import cn.cordys.common.dto.stage.Target; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.opportunity.constants.OpportunityStageType; +import cn.cordys.crm.system.constants.CirculationTypeEnum; +import cn.cordys.crm.system.domain.StageAdvancedConfig; +import cn.cordys.crm.system.mapper.ExtStageAdvancedConfigMapper; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@Transactional(rollbackFor = Exception.class) +public class StageAdvancedConfigService { + + @Resource + private ExtStageAdvancedConfigMapper extStageAdvancedConfigMapper; + @Resource + private BaseMapper stageAdvanceConfigMapper; + @Resource + private LogService logService; + + public static final Map STAGE_CONFIG_TABLE = new HashMap<>(2); + + static { + STAGE_CONFIG_TABLE.put(FormKey.ORDER.getKey(), "sales_order_stage_config"); + STAGE_CONFIG_TABLE.put(FormKey.CONTRACT.getKey(), "contract_stage_config"); + } + + public void saveAdvancedConfig(StageAdvancedConfigRequest request, String moduleType, String orgId, String userId) { + String tableName = STAGE_CONFIG_TABLE.get(moduleType); + if (StringUtils.isBlank(tableName)) { + return; + } + extStageAdvancedConfigMapper.update(tableName, request.getCirculationType(), orgId, userId, System.currentTimeMillis()); + + List oldConfigs = extStageAdvancedConfigMapper.selectConfigByType(orgId, moduleType); + + List circulationSettings = request.getCirculationSettings(); + List stageAdvanceConfigList = new ArrayList<>(); + + circulationSettings.forEach(setting -> { + List targets = setting.getTargets(); + targets.forEach(target -> { + StageAdvancedConfig stageAdvanceConfig = new StageAdvancedConfig(); + stageAdvanceConfig.setId(IDGenerator.nextStr()); + stageAdvanceConfig.setOriginId(setting.getOriginId()); + stageAdvanceConfig.setTargetId(target.getTargetId()); + stageAdvanceConfig.setEnable(target.getEnable()); + stageAdvanceConfig.setFieldConfig(JSON.toJSONString(target.getCirculationFieldValues())); + stageAdvanceConfig.setModuleType(moduleType); + stageAdvanceConfig.setOrganizationId(orgId); + stageAdvanceConfig.setCreateTime(System.currentTimeMillis()); + stageAdvanceConfig.setCreateUser(userId); + stageAdvanceConfig.setUpdateTime(System.currentTimeMillis()); + stageAdvanceConfig.setUpdateUser(userId); + stageAdvanceConfigList.add(stageAdvanceConfig); + }); + + }); + + if (CollectionUtils.isNotEmpty(oldConfigs)) { + List ids = oldConfigs.stream().map(StageAdvancedConfig::getId).toList(); + stageAdvanceConfigMapper.deleteByIds(ids); + } + + if (CollectionUtils.isNotEmpty(stageAdvanceConfigList)) { + stageAdvanceConfigMapper.batchInsert(stageAdvanceConfigList); + } + + LogDTO logDTO = new LogDTO(orgId, IDGenerator.nextStr(), userId, LogType.UPDATE, LogModule.SYSTEM_MODULE, Translator.get(moduleType) + Translator.get("advanced_circulation_setting")); + logDTO.setOriginalValue(oldConfigs); + logDTO.setModifiedValue(stageAdvanceConfigList); + logService.add(logDTO); + } + + + public void switchType(String type, String moduleType, String orgId) { + String tableName = STAGE_CONFIG_TABLE.get(moduleType); + if (StringUtils.isNotBlank(tableName)) { + extStageAdvancedConfigMapper.update(tableName, type, orgId, null, null); + } + + } + + + /** + * stage校验 + * + * @param originStage + * @param targetStage + * @param moduleType + */ + public boolean checkStage(String originStage, String targetStage, String moduleType) { + if (Strings.CI.equals(originStage, targetStage)) { + return true; + } + + String tableName = STAGE_CONFIG_TABLE.get(moduleType); + if (StringUtils.isBlank(tableName)) { + return false; + } + + StageConfigResponse originConfig = extStageAdvancedConfigMapper.getStageConfig(tableName, originStage); + StageConfigResponse targetConfig = extStageAdvancedConfigMapper.getStageConfig(tableName, targetStage); + + if (Strings.CI.equals(originConfig.getCirculationType(), CirculationTypeEnum.NORMAL.name())) { + return handleNormal(originConfig, targetConfig); + } + + if (Strings.CI.equals(originConfig.getCirculationType(), CirculationTypeEnum.ADVANCED.name())) { + return handleAdvanced(originConfig, targetConfig, moduleType); + } + + return false; + + } + + private boolean handleAdvanced(StageConfigResponse originConfig, StageConfigResponse targetConfig, String moduleType) { + StageAdvancedConfig config = extStageAdvancedConfigMapper.getConfigByOriginAndTarget(originConfig.getId(), targetConfig.getId(), moduleType); + if (config == null || !config.getEnable()) { + throw new GenericException("[" + originConfig.getName() + "] 不允许流转至 [" + targetConfig.getName() + "]"); + } + return config.getEnable(); + } + + + private boolean handleNormal(StageConfigResponse originConfig, StageConfigResponse targetConfig) { + // 基础流转 + if (originConfig.getEndRollBack() && originConfig.getAfootRollBack()) { + // 进行中&完结 同时开启 任意流转 + return true; + } + + if (originConfig.getEndRollBack()) { + // 只开启完结回退配置 + // 按pos顺序(目标pos > 源pos)允许任何类型切换 + if (targetConfig.getPos() > originConfig.getPos()) { + return true; + } + // 特定的类型组合,允许pos不递增的情况 + String originType = originConfig.getType(); + String targetType = targetConfig.getType(); + if ((Strings.CI.equals(OpportunityStageType.AFOOT.name(), originType) && Strings.CI.equals(OpportunityStageType.END.name(), targetType)) + || (Strings.CI.equals(OpportunityStageType.END.name(), originType) && Strings.CI.equals(OpportunityStageType.END.name(), targetType))) { + return true; + } + throw new GenericException("[" + originConfig.getName() + "] 不允许流转至 [" + targetConfig.getName() + "]"); + } + + if (originConfig.getAfootRollBack()) { + // 只开启进行中回退 + // 源阶段为 END 时,不允许任何切换 + if (Strings.CI.equals(OpportunityStageType.END.name(), originConfig.getType())) { + throw new GenericException("[" + originConfig.getName() + "] 不允许流转至 [" + targetConfig.getName() + "]"); + } + + // 源阶段为 AFOOT 时,允许目标为 AFOOT 或 END(不限制 pos) + String targetType = targetConfig.getType(); + return Strings.CI.equals(OpportunityStageType.END.name(), targetType) || Strings.CI.equals(OpportunityStageType.AFOOT.name(), targetType); + } + + throw new GenericException("[" + originConfig.getName() + "] 不允许流转至 [" + targetConfig.getName() + "]"); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/SysLoginLogService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/SysLoginLogService.java new file mode 100644 index 0000000..f8006ca --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/SysLoginLogService.java @@ -0,0 +1,71 @@ +package cn.cordys.crm.system.service; + + +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.dto.request.LoginLogRequest; +import cn.cordys.crm.system.dto.response.LoginLogListResponse; +import cn.cordys.crm.system.mapper.ExtLoginLogMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * @author jianxing + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class SysLoginLogService { + @Resource + private ExtLoginLogMapper extLoginLogMapper; + @Resource + private BaseService baseService; + + /** + * 处理数据 + * + * @param list + */ + private List handleData(List list) { + if (CollectionUtils.isNotEmpty(list)) { + List userIds = list.stream().map(LoginLogListResponse::getOperator).collect(Collectors.toList()); + Map userNameMap = baseService.getUserNameMap(userIds); + list.forEach(item -> item.setOperatorName(userNameMap.getOrDefault(item.getOperator(), StringUtils.EMPTY))); + } + return list; + } + + /** + * 时间校验 + * + * @param startTime + * @param endTime + */ + private void checkTime(Long startTime, Long endTime) { + int compare = Long.compare(startTime, endTime); + if (compare > 0) { + throw new GenericException(Translator.get("startTime_must_be_less_than_endTime")); + } + } + + + /** + * 登录日志列表查询 + * + * @param request + * + * @return + */ + public List list(LoginLogRequest request, String orgId) { + checkTime(request.getStartTime(), request.getEndTime()); + List list = extLoginLogMapper.list(request, orgId); + return handleData(list); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/SysOperationLogService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/SysOperationLogService.java new file mode 100644 index 0000000..a87223d --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/SysOperationLogService.java @@ -0,0 +1,191 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JsonDifferenceUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.domain.OperationLog; +import cn.cordys.crm.system.domain.OperationLogBlob; +import cn.cordys.crm.system.dto.request.OperationLogRequest; +import cn.cordys.crm.system.dto.response.OperationLogDetailResponse; +import cn.cordys.crm.system.dto.response.OperationLogResponse; +import cn.cordys.crm.system.mapper.ExtOperationLogMapper; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class SysOperationLogService { + + @Resource + private ExtOperationLogMapper extOperationLogMapper; + + @Resource + private ExtUserMapper extUserMapper; + + @Resource + private BaseMapper operationLogBlobMapper; + @Resource + private BaseMapper operationLogMapper; + @Resource + private BaseService baseService; + + /** + * 操作日志列表查询 + */ + public List list(OperationLogRequest request, String orgId) { + checkTime(request.getStartTime(), request.getEndTime()); + List list = extOperationLogMapper.list(request, orgId); + handleData(list); + return list; + } + + /** + * 处理数据 + * + * @param list 操作日志列表 + */ + private void handleData(List list) { + if (CollectionUtils.isNotEmpty(list)) { + List userIds = list.stream() + .map(OperationLogResponse::getOperator) + .collect(Collectors.toList()); + + List userList = extUserMapper.selectUserOptionByIds(userIds); + Map userMap = userList.stream() + .collect(Collectors.toMap(OptionDTO::getId, OptionDTO::getName)); + + list.forEach(item -> item.setOperatorName(userMap.getOrDefault(item.getOperator(), StringUtils.EMPTY))); + } + } + + /** + * 时间校验 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + */ + private void checkTime(Long startTime, Long endTime) { + if (startTime > endTime) { + throw new GenericException(Translator.get("startTime_must_be_less_than_endTime")); + } + } + + /** + * 获取日志详情 + * + * @param id 日志ID + * + * @return 日志详情 + */ + public OperationLogDetailResponse getLogDetail(String id, String orgId) { + OperationLog operationLog = operationLogMapper.selectByPrimaryKey(id); + if (operationLog == null) { + throw new GenericException(Translator.get("log.not_found")); + } + + // 构建基础响应对象 + OperationLogDetailResponse logResponse = BeanUtils.copyBean(new OperationLogDetailResponse(), operationLog); + logResponse.setOperator(operationLog.getCreateUser()); + logResponse.setOperatorName(baseService.getUserName(logResponse.getOperator())); + + OperationLogBlob operationLogBlob = operationLogBlobMapper.selectByPrimaryKey(id); + if (operationLogBlob == null) { + return logResponse; + } + + // 解析旧值和新值 + String oldString = Optional.ofNullable(operationLogBlob.getOriginalValue()) + .map(bytes -> new String(bytes, StandardCharsets.UTF_8)) + .orElse(""); + String newString = Optional.ofNullable(operationLogBlob.getModifiedValue()) + .map(bytes -> new String(bytes, StandardCharsets.UTF_8)) + .orElse(""); + + try { + List differences = getJsonDifferences(oldString, newString); + + if (CollectionUtils.isNotEmpty(differences)) { + // 获取模块对应处理服务 + BaseModuleLogService moduleLogService = ModuleLogServiceFactory.getModuleLogService(operationLog.getModule()); + + if (moduleLogService != null) { + moduleLogService.setOldValue(oldString); + moduleLogService.setNewValue(newString); + differences = moduleLogService.handleLogField(differences, orgId); + } else { + handleDefaultDifferences(operationLog, differences); + } + } + + logResponse.setDiffs(differences); + } catch (Exception e) { + log.error("解析日志差异异常: {}", e.getMessage(), e); + throw new GenericException(Translator.get("data_parsing_exception")); + } + + return logResponse; + } + + public List getJsonDifferences(String oldString, String newString) throws Exception { + List differences = new ArrayList<>(); + JsonDifferenceUtils.compareJson(oldString, newString, differences); + + // 过滤掉不需要的字段 + differences = filterIgnoreFields(differences); + return differences; + } + + // 默认差异处理逻辑 + private void handleDefaultDifferences(OperationLog operationLog, List differences) { + // 特殊处理合同业务更新字段 + if (Strings.CI.equals(operationLog.getModule(), LogModule.CONTRACT_BUSINESS_TITLE) + && Strings.CI.equals(operationLog.getType(), LogType.UPDATE)) { + differences.forEach(diff -> { + if (Strings.CI.equals(diff.getColumn(), "name")) { + diff.setColumn("companyName"); + } + }); + } + // 通用翻译 + differences.forEach(BaseModuleLogService::translatorDifferInfo); + } + + + /** + * 过滤掉日志对比无需显示的字段 + * 例如:organizationId + * + * @param differences + * + * @return + */ + private List filterIgnoreFields(List differences) { + differences = differences + .stream() + .filter(differ -> !Strings.CS.equalsAny(differ.getColumn(), + "organizationId", "createUser", "updateUser", "createTime", "updateTime", "departmentName", "supervisorName", "lastStage", "pos")) + .collect(Collectors.toList()); + return differences; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/SystemModuleLogService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/SystemModuleLogService.java new file mode 100644 index 0000000..a76c7c7 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/SystemModuleLogService.java @@ -0,0 +1,274 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.constants.LinkScenarioKey; +import cn.cordys.common.dto.JsonDifferenceDTO; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.search.constants.SearchModuleEnum; +import cn.cordys.crm.system.domain.ModuleField; +import cn.cordys.crm.system.dto.ScopeNameDTO; +import cn.cordys.crm.system.dto.form.FormProp; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional(rollbackFor = Exception.class) +@Slf4j +public class SystemModuleLogService extends BaseModuleLogService { + + public static final String LINK_KEY_SPILT = "-"; + @Resource + private BaseMapper moduleFieldMapper; + @Resource + private UserExtendService userExtendService; + + @Override + public List handleLogField(List differences, String orgId) { + differences.forEach(differ -> { + if (isLinkFormKey(differ.getColumn()) && differ.getColumn().split(LINK_KEY_SPILT).length == 2) { + String[] splitKey = differ.getColumn().split(LINK_KEY_SPILT); + differ.setColumnName(Translator.get("log." + splitKey[0] + ".link") + "-" + Translator.get(splitKey[1].toLowerCase())); + handleLinkFieldsLogDetail(differ); + } + if (Strings.CS.equals("module.switch", differ.getColumn())) { + differ.setColumnName(Translator.get(differ.getColumn())); + differ.setOldValueName(differ.getOldValue()); + differ.setNewValueName(differ.getNewValue()); + } + if (Strings.CS.equals("fields", differ.getColumn())) { + differ.setColumnName(Translator.get("log." + differ.getColumn())); + handleFieldsLogDetail(differ); + } + if (Strings.CS.equals("sort", differ.getColumn())) { + differ.setColumnName(Translator.get("module.main.nav")); + handleModuleMainNav(differ); + } + + if (Strings.CS.equals("navigationSort", differ.getColumn())) { + differ.setColumnName(Translator.get("top.navigation")); + handleModuleMainNav(differ); + } + + if (Strings.CS.equals("viewSize", differ.getColumn())) { + differ.setColumnName(Translator.get("form.view.size")); + differ.setOldValueName(Translator.get(differ.getOldValue().toString())); + differ.setNewValueName(Translator.get(differ.getNewValue().toString())); + } + + if (Strings.CS.equals("formProp", differ.getColumn())) { + differ.setColumnName(Translator.get("log.form.prop")); + handleFormPropLogDetail(differ); + } + + + if (Strings.CI.equalsAny(differ.getColumn(), + "rate", "stage", "contractStage", "orderStage","afootRollBack", "endRollBack","name")) { + differ.setColumnName(Translator.get("log.".concat(differ.getColumn()))); + differ.setNewValueName(differ.getNewValue()); + differ.setOldValueName(differ.getOldValue()); + } + + if (Strings.CS.equals("stageSort", differ.getColumn())) { + differ.setColumnName(Translator.get("opportunity_stage_setting")); + differ.setNewValueName(differ.getNewValue()); + differ.setOldValueName(differ.getOldValue()); + } + + if (Strings.CS.equals("optBtnContent", differ.getColumn())) { + differ.setColumnName(Translator.get("log.module.form.operate.btn")); + differ.setNewValueName(getBtnContentList(differ.getNewValue()).toString()); + differ.setOldValueName(getBtnContentList(differ.getOldValue()).toString()); + } + + handlePoolConfig(differ); + + if (differ.getColumn().contains("searchAdvanced")) { + searchSetting(differ); + } + }); + + differences.removeIf(differ -> differ.getOldValueName() == null && differ.getNewValueName() == null); + return differences; + } + + private List getBtnContentList(Object value) { + List newValuesList = new ArrayList<>(); + if (value != null) { + try { + List> newValues = (List>) value; + newValuesList = newValues.stream().filter(newValue -> newValue.get("enable") != null && (Boolean) newValue.get("enable")) + .map(newValue -> (String) newValue.get("text")) + .toList(); + } catch (Exception e) { + log.error(e.getMessage(), e); + return newValuesList; + } + } + return newValuesList; + } + + private void handlePoolConfig(JsonDifferenceDTO differ) { + if (Strings.CS.equals("scopeId", differ.getColumn())) { + differ.setColumnName(Translator.get("log.pool.members")); + handlePoolOwnerOrMember(differ); + } + + if (Strings.CS.equals("ownerId", differ.getColumn())) { + differ.setColumnName(Translator.get("log.pool.owner")); + handlePoolOwnerOrMember(differ); + } + + if (Strings.CS.equals("auto", differ.getColumn())) { + differ.setColumnName(Translator.get("log.pool.auto")); + differ.setNewValueName(differ.getNewValue()); + differ.setOldValueName(differ.getOldValue()); + } + } + + private void searchSetting(JsonDifferenceDTO differ) { + List oldValues = parseFieldList(differ.getOldValue()).stream().map(String::valueOf).toList(); + List newValues = parseFieldList(differ.getNewValue()).stream().map(String::valueOf).toList(); + if (CollectionUtils.isNotEmpty(oldValues)) { + List moduleFields = moduleFieldMapper.selectByIds(oldValues); + differ.setOldValueName(moduleFields.stream() + .map(ModuleField::getName) + .toList()); + } + if (CollectionUtils.isNotEmpty(newValues)) { + List moduleFields = moduleFieldMapper.selectByIds(newValues); + differ.setNewValueName(moduleFields.stream() + .map(ModuleField::getName) + .toList()); + } + switch (differ.getColumn()) { + case SearchModuleEnum.SEARCH_ADVANCED_CLUE: + differ.setColumnName(Translator.get("clue")); + break; + case SearchModuleEnum.SEARCH_ADVANCED_CUSTOMER: + differ.setColumnName(Translator.get("customer")); + break; + case SearchModuleEnum.SEARCH_ADVANCED_CONTACT: + differ.setColumnName(Translator.get("contact")); + break; + case SearchModuleEnum.SEARCH_ADVANCED_PUBLIC: + differ.setColumnName(Translator.get("customer_pool")); + break; + case SearchModuleEnum.SEARCH_ADVANCED_CLUE_POOL: + differ.setColumnName(Translator.get("clue_pool")); + break; + case SearchModuleEnum.SEARCH_ADVANCED_OPPORTUNITY: + differ.setColumnName(Translator.get("opportunity")); + break; + } + } + + private void handlePoolOwnerOrMember(JsonDifferenceDTO differ) { + + if (differ.getOldValue() != null) { + List oldScope = userExtendService.getScope(JSON.parseArray((String) differ.getOldValue(), String.class)); + differ.setOldValueName(JSON.toJSONString(oldScope.stream().map(ScopeNameDTO::getName).toList())); + } + + if (differ.getNewValue() != null) { + List newScope = userExtendService.getScope(JSON.parseArray((String) differ.getNewValue(), String.class)); + differ.setNewValue(JSON.toJSONString(newScope.stream().map(ScopeNameDTO::getName).toList())); + } + } + + private void handleModuleMainNav(JsonDifferenceDTO differ) { + // 将 oldValue 和 newValue 转为列表并映射为名称 + differ.setOldValueName(parseFieldList(differ.getOldValue()) + .stream() + .map(String::valueOf) + .map(Translator::get) + .toList()); + + differ.setNewValueName(parseFieldList(differ.getNewValue()) + .stream() + .map(String::valueOf) + .map(Translator::get) + .toList()); + } + + private void handleLinkFieldsLogDetail(JsonDifferenceDTO differ) { + Map oldPairs = parseLinkFieldMap(differ.getOldValue()); + Map newPairs = parseLinkFieldMap(differ.getNewValue()); + + // 去重且保序 + Set fieldIds = new LinkedHashSet<>(); + oldPairs.forEach((k, v) -> { + fieldIds.add(k); + fieldIds.add(v); + }); + newPairs.forEach((k, v) -> { + fieldIds.add(k); + fieldIds.add(v); + }); + + Map fieldMap = new HashMap<>(); + if (!fieldIds.isEmpty()) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.in(ModuleField::getId, new ArrayList<>(fieldIds)); + List moduleFields = moduleFieldMapper.selectListByLambda(queryWrapper); + fieldMap = moduleFields.stream() + .collect(Collectors.toMap(ModuleField::getId, ModuleField::getName, (a, b) -> a)); + } + + if (!oldPairs.isEmpty()) { + differ.setOldValueName(toNamePairs(oldPairs, fieldMap)); + } + if (!newPairs.isEmpty()) { + differ.setNewValueName(toNamePairs(newPairs, fieldMap)); + } + } + + private Map parseLinkFieldMap(Object value) { + Map result = new LinkedHashMap<>(); + if (!(value instanceof List list)) { + return result; + } + for (Object item : list) { + if (!(item instanceof Map m)) { + continue; + } + Object current = m.get("current"); + Object link = m.get("link"); + if (current != null && link != null) { + result.put(String.valueOf(current), String.valueOf(link)); + } + } + return result; + } + + private List parseFieldList(Object value) { + if (value instanceof List list) { + return list; + } + return Collections.emptyList(); + } + + private List toNamePairs(Map pairs, Map fieldMap) { + return pairs.entrySet().stream() + .map(e -> fieldMap.getOrDefault(e.getKey(), e.getKey()) + + "-" + fieldMap.getOrDefault(e.getValue(), e.getValue())) + .collect(Collectors.toList()); + } + + private boolean isLinkFormKey(String key) { + for (LinkScenarioKey linkKey : LinkScenarioKey.values()) { + if (key.contains(linkKey.name())) { + return true; + } + } + return false; + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/SystemService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/SystemService.java new file mode 100644 index 0000000..f280802 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/SystemService.java @@ -0,0 +1,155 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.crm.system.constants.LicenseStatus; +import cn.cordys.crm.system.dto.VersionInfoDTO; +import cn.cordys.crm.system.utils.ArchUtils; +import cn.cordys.crm.system.utils.CSHttpClient; +import cn.cordys.crm.system.utils.CopyrightUtils; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.time.Year; +import java.util.Map; +import java.util.Objects; + +@Service +@Slf4j +public class SystemService { + + private static final String CACHE_COPYRIGHT = "copyright_cache"; + private static final String CACHE_FORM = "form_cache"; + private static final String CACHE_FIELD = "field_cache"; + private static final String CACHE_TABLE_SCHEMA = "table_schema_cache"; + private static final String PRODUCT = "CORDYS"; + private static final String EDITION_CE = "ce"; + private static final String EDITION_EE = "ee"; + private static final String PERMISSION_CACHE = "permission_cache"; + + private final CacheManager cacheManager; + private final LicenseService licenseService; + + public SystemService(CacheManager cacheManager, LicenseService licenseService) { + this.cacheManager = cacheManager; + this.licenseService = licenseService; + } + + private static String baseVersion(String v) { + if (StringUtils.isBlank(v)) return ""; + String core = StringUtils.substringBefore(v.trim(), "-"); + core = StringUtils.substringBefore(core, "+"); + if (core.startsWith("v") || core.startsWith("V")) { + core = core.substring(1); + } + return core; + } + + private static boolean isNumericSemver(String v) { + String core = baseVersion(v); + if (StringUtils.isBlank(core)) return false; + String[] parts = StringUtils.split(core, '.'); + if (parts == null || parts.length == 0) return false; + for (String p : parts) { + if (!StringUtils.isNumeric(p)) return false; + } + return true; + } + + private static int compareSemverCore(String v1, String v2) { + String[] a = StringUtils.split(v1, '.'); + String[] b = StringUtils.split(v2, '.'); + int al = a == null ? 0 : a.length; + int bl = b == null ? 0 : b.length; + int len = Math.max(al, bl); + for (int i = 0; i < len; i++) { + int ai = i < al ? safeParseInt(a[i]) : 0; + int bi = i < bl ? safeParseInt(b[i]) : 0; + int cmp = Integer.compare(ai, bi); + if (cmp != 0) return cmp; + } + return 0; + } + + private static int safeParseInt(String s) { + try { + return Integer.parseInt(s); + } catch (Exception e) { + return 0; + } + } + + @Cacheable(value = CACHE_COPYRIGHT) + public VersionInfoDTO getVersion() { + // 支持系统属性覆盖,环境变量退化,最后为 unknown + String current = StringUtils.defaultIfBlank(System.getenv("CRM_VERSION"), "unknown"); + String currentBase = baseVersion(current); + + String edition = getEdition(); + CSHttpClient.DownloadInfo info = null; + try { + info = CSHttpClient.sendGetRequest(Map.of( + "currentVersion", currentBase, + "majorVersion", "v1", + "edition", edition, + "product", PRODUCT + )); + } catch (Exception e) { + log.info("检查版本信息失败: {}", e.getMessage()); + } + + String latest = info != null + ? StringUtils.defaultIfBlank(info.getLatestVersion(), current) + : current; + String latestBase = baseVersion(latest); + + boolean comparable = isNumericSemver(currentBase) && isNumericSemver(latestBase); + boolean hasNew = comparable && compareSemverCore(latestBase, currentBase) > 0; + + int year = info != null ? info.getYear() : Year.now().getValue(); + + return VersionInfoDTO.builder() + .currentVersion(current) + .latestVersion(hasNew ? latest : "v" + currentBase) + .copyright(CopyrightUtils.getCopyright(year)) + .hasNewVersion(hasNew) + .architecture(ArchUtils.getNormalizedArch()) + .build(); + } + + /** + * 清理表单相关缓存 + *

清理表单缓存和表结构缓存,确保应用使用最新的表单配置和结构

+ */ + public void clearFormCache() { + if (cacheManager == null) { + log.info("CacheManager 未初始化,跳过清理。"); + return; + } + String[] cacheNames = {CACHE_FORM, CACHE_FIELD, CACHE_TABLE_SCHEMA, CACHE_COPYRIGHT, PERMISSION_CACHE}; + for (String cacheName : cacheNames) { + try { + Cache cache = cacheManager.getCache(cacheName); + if (cache != null) { + cache.clear(); + } + } catch (Exception e) { + log.info("清理缓存失败:{} - {}", cacheName, e.getMessage()); + } + } + } + + private String getEdition() { + try { + String status = Objects.toString(licenseService.validate().getStatus(), ""); + return Strings.CI.equals(status, LicenseStatus.NOT_FOUND.getName()) ? EDITION_CE : EDITION_EE; + } catch (Exception e) { + // 许可异常时默认为 CE + log.info("获取许可状态失败,默认使用 CE:{}", e.getMessage()); + return EDITION_CE; + } + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/UserExtendService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/UserExtendService.java new file mode 100644 index 0000000..8a56b0c --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/UserExtendService.java @@ -0,0 +1,389 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.constants.InternalRole; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.constants.ScopeKey; +import cn.cordys.crm.system.domain.OrganizationUser; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.domain.UserExtend; +import cn.cordys.crm.system.domain.UserRole; +import cn.cordys.crm.system.dto.ScopeNameDTO; +import cn.cordys.crm.system.dto.UserSimple; +import cn.cordys.crm.system.mapper.ExtUserExtendMapper; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.ListUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class UserExtendService { + + public static final String ROOT_NODE_PARENT_ID = "NONE"; + @Resource + private BaseMapper organizationUserMapper; + @Resource + private ExtUserExtendMapper extUserExtendMapper; + @Resource + private DepartmentService departmentService; + @Resource + private BaseMapper userRoleMapper; + @Resource + private BaseMapper userMapper; + @Resource + private BaseMapper userExtendMapper; + + /** + * 获取范围的所有负责人ID + * + * @param scopeIds 范围ID集合 + * @param orgId 组织ID + * + * @return 负责人ID集合 + */ + public List getScopeOwnerIds(List scopeIds, String orgId) { + List scopes = getScope(scopeIds); + List ownerIds = scopes.stream().filter(scope -> Strings.CS.equals(scope.getScope(), ScopeKey.USER.name())).map(ScopeNameDTO::getId).collect(Collectors.toList()); + List roleList = scopes.stream().filter(scope -> Strings.CS.equals(scope.getScope(), ScopeKey.ROLE.name())).toList(); + List dptList = scopes.stream().filter(scope -> Strings.CS.equals(scope.getScope(), ScopeKey.DEPARTMENT.name())).toList(); + if (!CollectionUtils.isEmpty(roleList)) { + List roleIds = roleList.stream().map(ScopeNameDTO::getId).toList(); + LambdaQueryWrapper userRoleWrapper = new LambdaQueryWrapper<>(); + userRoleWrapper.in(UserRole::getRoleId, roleIds); + List userRoles = userRoleMapper.selectListByLambda(userRoleWrapper); + ownerIds.addAll(userRoles.stream().map(UserRole::getUserId).toList()); + } + if (!CollectionUtils.isEmpty(dptList)) { + List tree = departmentService.getTree(orgId); + List allDptIds = new ArrayList<>(dptList.stream().map(ScopeNameDTO::getId).toList()); + dptList.forEach(dpt -> { + List childDptIds = getChildDptById(tree.getFirst(), dpt.getId()); + allDptIds.addAll(childDptIds); + }); + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.in(OrganizationUser::getDepartmentId, allDptIds.stream().distinct().toList()); + List organizationUsers = organizationUserMapper.selectListByLambda(queryWrapper); + ownerIds.addAll(organizationUsers.stream().map(OrganizationUser::getUserId).toList()); + } + return ownerIds.stream().distinct().collect(Collectors.toList()); + } + + /** + * 获取成员范围集合 + * + * @param scopeIds 范围ID集合 + * + * @return 范围集合 + */ + public List getScope(List scopeIds) { + if (CollectionUtils.isEmpty(scopeIds)) { + return new ArrayList<>(); + } + List scopeNames = extUserExtendMapper.groupByScopeIds(scopeIds); + return scopeNames.stream().peek(scope -> { + if (Strings.CS.equalsAny(scope.getName(), InternalRole.ORG_ADMIN.getValue(), InternalRole.SALES_MANAGER.getValue(), InternalRole.SALES_STAFF.getValue())) { + scope.setName(Translator.get("role." + scope.getName())); + } + }).toList(); + } + + /** + * 获取负责人范围ID集合 + * + * @param ownerId 负责人ID + * @param organizationId 组织ID + * + * @return 范围ID集合 + */ + public List getUserScopeIds(String ownerId, String organizationId) { + List departmentIds = getParentDepartmentIds(ownerId, organizationId); + departmentIds.add(ownerId); + LambdaQueryWrapper userRoleWrapper = new LambdaQueryWrapper<>(); + userRoleWrapper.eq(UserRole::getUserId, ownerId); + List roles = userRoleMapper.selectListByLambda(userRoleWrapper); + List roleIds = roles.stream().map(UserRole::getRoleId).toList(); + return ListUtils.union(departmentIds, roleIds); + } + + /** + * 获取负责人范围对象集合 + * + * @param ownerIds 负责人ID集合 + * @param organizationId 组织ID + * + * @return 范围对象集合 + */ + public Map> getMultiScopeMap(List ownerIds, String organizationId) { + if (CollectionUtils.isEmpty(ownerIds)) { + return Map.of(); + } + Map> scopeMap = new HashMap<>(ownerIds.size()); + // 查询所有负责人的角色 + LambdaQueryWrapper userRoleWrapper = new LambdaQueryWrapper<>(); + userRoleWrapper.in(UserRole::getUserId, ownerIds); + List allOwnerRoles = userRoleMapper.selectListByLambda(userRoleWrapper); + Map> ownerRoleMap = allOwnerRoles.stream().collect(Collectors.groupingBy( + UserRole::getUserId, Collectors.mapping(UserRole::getRoleId, Collectors.toList()))); + // 查询所有负责人的部门 + Map> ownerDepMap = getOwnerAncestorMap(ownerIds, organizationId); + + ownerIds.forEach(ownerId -> { + List roleIds = ownerRoleMap.getOrDefault(ownerId, List.of()); + List deptIds = ownerDepMap.getOrDefault(ownerId, List.of()); + List allDeptIds = new ArrayList<>(deptIds); + // 负责人也作为范围对象 + allDeptIds.add(ownerId); + scopeMap.put(ownerId, ListUtils.union(roleIds, allDeptIds)); + }); + return scopeMap; + } + + /** + * 获取负责人集合的父部门映射 + * + * @param ownerIds 负责人ID集合 + * @param organizationId 组织ID + * + * @return 负责人ID -> 父部门ID集合映射 + */ + public Map> getOwnerAncestorMap(List ownerIds, String organizationId) { + Map deptParentMap = loadDeptParentMap(organizationId); + Map> ancestorMap = new HashMap<>(ownerIds.size()); + // 查询所有负责人的部门ID + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(OrganizationUser::getUserId, ownerIds).eq(OrganizationUser::getOrganizationId, organizationId); + List organizationUsers = organizationUserMapper.selectListByLambda(wrapper); + if (organizationUsers.isEmpty()) { + return ancestorMap; + } + Map ownerDepIdMap = organizationUsers.stream().collect(Collectors.toMap(OrganizationUser::getUserId, OrganizationUser::getDepartmentId)); + ownerDepIdMap.forEach((k, v) -> ancestorMap.put(k, getDepAllAncestor(v, deptParentMap))); + return ancestorMap; + } + + /** + * 获取部门的父部门 + * + * @param deptId 部门ID + * + * @return 父部门ID集合 + */ + public List getDepAllAncestor(String deptId, Map deptParentMap) { + List ancestors = new ArrayList<>(); + ancestors.add(deptId); + String current = deptParentMap.get(deptId); + while (StringUtils.isNotEmpty(current) && !Strings.CS.equals(current, ROOT_NODE_PARENT_ID)) { + ancestors.add(current); + current = deptParentMap.get(current); + } + return ancestors; + } + + /** + * 获取用户所有的上级部门 + * + * @param userId 用户ID + * @param currentOrgId 当前组织ID + * + * @return 上级部门ID集合 + */ + public List getParentDepartmentIds(String userId, String currentOrgId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(OrganizationUser::getUserId, userId).eq(OrganizationUser::getOrganizationId, currentOrgId); + List organizationUsers = organizationUserMapper.selectListByLambda(wrapper); + if (CollectionUtils.isEmpty(organizationUsers)) { + return new ArrayList<>(); + } + String departmentId = organizationUsers.getFirst().getDepartmentId(); + List departmentTree = departmentService.getTree(currentOrgId); + List deptPath = new ArrayList<>(); + boolean found = findDeptPathWithDfs(departmentTree.getFirst(), departmentId, deptPath); + // not found, department has been removed + return found ? deptPath : new ArrayList<>(); + } + + /** + * 递归获取叶子部门的路径 + */ + private boolean findDeptPathWithDfs(BaseTreeNode node, String targetNode, List path) { + // 加入当前节点 + path.add(node.getId()); + // 命中目标节点返回 + if (Strings.CS.equals(node.getId(), targetNode)) { + return true; + } + // 递归子节点 + for (BaseTreeNode treeNode : node.getChildren()) { + if (findDeptPathWithDfs(treeNode, targetNode, path)) { + return true; + } + } + // 未命中, 回退当前节点 + path.removeLast(); + return false; + } + + /** + * 查找目标部门的子部门节点 + * + * @param node 树节点 + * @param targetId 目标部门ID + * + * @return 子部门节点 + */ + public List getChildDptById(BaseTreeNode node, String targetId) { + BaseTreeNode targetNode = findTargetById(node, targetId); + if (targetNode != null) { + // find, return all children + return getAllChildDptIds(targetNode); + } else { + // not found, return empty list + return new ArrayList<>(); + } + } + + /** + * 根据ID递归查找目标部门节点 + * + * @param node 树节点 + * @param targetId 目标部门ID + * + * @return 树节点 + */ + public BaseTreeNode findTargetById(BaseTreeNode node, String targetId) { + if (Strings.CS.equals(node.getId(), targetId)) { + return node; + } + for (BaseTreeNode child : node.getChildren()) { + BaseTreeNode result = findTargetById(child, targetId); + if (result != null) { + return result; + } + } + return null; + } + + /** + * 递归获取所有子节点部门ID + * + * @param currentNode 当前节点 + * + * @return 子节点部门ID集合 + */ + public List getAllChildDptIds(BaseTreeNode currentNode) { + List children = new ArrayList<>(); + if (CollectionUtils.isEmpty(currentNode.getChildren())) { + return children; + } + for (BaseTreeNode child : currentNode.getChildren()) { + children.add(child.getId()); + children.addAll(getAllChildDptIds(child)); + } + return children; + } + + /** + * 用户ID集合获取用户选项 + * + * @param ids 用户ID集合 + * + * @return 用户选项 + */ + public List getUserOptionByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return new ArrayList<>(); + } + return userMapper.selectByIds(ids.toArray(new String[0])); + } + + public List getUserOptionById(String id) { + if (StringUtils.isBlank(id)) { + return List.of(); + } + return getUserOptionByIds(List.of(id)); + } + + /** + * 是否含有重复对象元素 + * + * @param scopeIds 原始范围ID集合 + * @param targetScopeIds 目标范围ID集合 + * @param currentOrgId 当前组织ID + * + * @return 是否含有重复对象元素 + */ + public boolean hasDuplicateScopeObj(List scopeIds, List targetScopeIds, String currentOrgId) { + List ownerIds = getScopeOwnerIds(scopeIds, currentOrgId); + List targetOwnerIds = getScopeOwnerIds(targetScopeIds, currentOrgId); + ownerIds.retainAll(targetOwnerIds); + return !ownerIds.isEmpty(); + } + + /** + * 是否池子管理员 + * + * @param scopeIds 范围ID集合 + * @param ownerId 操作人ID + * @param orgId 组织ID + * + * @return 是否管理员 + */ + public boolean isPoolAdmin(List scopeIds, String ownerId, String orgId) { + List adminIds = getScopeOwnerIds(scopeIds, orgId); + return adminIds.contains(ownerId); + } + + /** + * 加载组织部门父节点映射 + * + * @param orgId 组织ID + * + * @return 部门ID -> 父部门ID 映射 + */ + private Map loadDeptParentMap(String orgId) { + List departmentTree = departmentService.getTree(orgId); + Map deptMap = new HashMap<>(2 << 7); + collectDeptNodes(departmentTree, deptMap); + return deptMap; + } + + /** + * 递归收集部门节点的ID和父ID映射 + * + * @param nodes 节点列表 + * @param deptMap 部门ID -> 父部门ID 映射 + */ + private void collectDeptNodes(List nodes, Map deptMap) { + if (CollectionUtils.isEmpty(nodes)) { + return; + } + for (BaseTreeNode node : nodes) { + deptMap.put(node.getId(), node.getParentId()); + // 递归处理子节点 + collectDeptNodes(node.getChildren(), deptMap); + } + } + + /** + * 获取用户头像集合 + * @return 用户头像集合 + */ + public Map getAllUserSimpleMap() { + List userExtends = userExtendMapper.selectAll(null); + List users = userMapper.selectAll(null); + Map avatarMap = userExtends.stream().filter(userExtend -> StringUtils.isNotBlank(userExtend.getAvatar())).collect(Collectors.toMap(UserExtend::getId, UserExtend::getAvatar)); + List simples = users.stream().map(user -> new UserSimple(user.getId(), user.getName(), avatarMap.get(user.getId()))).toList(); + return simples.stream().collect(Collectors.toMap(UserSimple::getId, u -> u)); + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/UserKeyService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/UserKeyService.java new file mode 100644 index 0000000..6024812 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/UserKeyService.java @@ -0,0 +1,193 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.builder.LogDTOBuilder; +import cn.cordys.aspectj.constants.LogConstants; +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.constants.HttpMethodConstants; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.CodingUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.domain.UserKey; +import cn.cordys.crm.system.dto.request.UserKeyUpdateRequest; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; + +import java.util.List; + +@Service +@Transactional(rollbackFor = Exception.class) +public class UserKeyService { + + @Resource + private BaseMapper userKeyMapper; + + @Resource + private UserLoginService userLoginService; + + @Resource + private LogService logService; + + /** + * 获取指定用户的 API 密钥信息 + */ + public List getUserKeysInfo(String userId) { + UserKey example = new UserKey(); + example.setCreateUser(userId); + return userKeyMapper.select(example); + } + + /** + * 为指定用户添加 API 密钥 + */ + public void addUserKey(String userId) { + validateUserExistence(userId); + + List userKeysList = getUserKeysByUserId(userId); + + // 如果该用户已有 5 个密钥,抛出限制异常 + if (!CollectionUtils.isEmpty(userKeysList) && userKeysList.size() >= 5) { + throw new GenericException(Translator.get("user_apikey_limit")); + } + + UserKey userKey = generateUserKey(userId); + userKeyMapper.insert(userKey); + + logApiKeyAction(userKey); + } + + /** + * 删除指定 ID 的 API 密钥 + */ + public void deleteUserKey(String id) { + UserKey userKey = validateAndGetUserKey(id); + userKeyMapper.deleteByPrimaryKey(userKey.getId()); + } + + /** + * 启用指定 ID 的 API 密钥 + */ + public void enableUserKey(String id) { + UserKey userKey = validateAndGetUserKey(id); + updateUserKeyStatus(userKey, true); + } + + /** + * 禁用指定 ID 的 API 密钥 + */ + public void disableUserKey(String id) { + UserKey userKey = validateAndGetUserKey(id); + updateUserKeyStatus(userKey, false); + } + + /** + * 根据 accessKey 获取 API 密钥信息 + */ + public UserKey getUserKey(String accessKey) { + UserKey example = new UserKey(); + example.setAccessKey(accessKey); + example.setEnable(true); + List userKeysList = userKeyMapper.select(example); + return CollectionUtils.isEmpty(userKeysList) ? null : userKeysList.getFirst(); + } + + /** + * 校验 API 密钥是否存在 + */ + private UserKey validateAndGetUserKey(String id) { + UserKey userKey = userKeyMapper.selectByPrimaryKey(id); + if (userKey == null) { + throw new GenericException(Translator.get("api_key_not_exist")); + } + return userKey; + } + + /** + * 校验用户是否存在 + */ + private void validateUserExistence(String userId) { + if (userLoginService.authenticateUser(userId) == null) { + throw new GenericException(Translator.get("user_not_exist") + userId); + } + } + + /** + * 获取指定用户的所有 API 密钥 + */ + private List getUserKeysByUserId(String userId) { + UserKey example = new UserKey(); + example.setCreateUser(userId); + return userKeyMapper.select(example); + } + + /** + * 生成新的 API 密钥 + */ + private UserKey generateUserKey(String userId) { + UserKey userKey = new UserKey(); + userKey.setId(IDGenerator.nextStr()); + userKey.setCreateUser(userId); + userKey.setEnable(true); + userKey.setAccessKey(CodingUtils.generateAK()); + userKey.setSecretKey(CodingUtils.generateSecretKey()); + userKey.setCreateTime(System.currentTimeMillis()); + userKey.setForever(true); + return userKey; + } + + /** + * 更新 API 密钥的状态(启用/禁用) + */ + private void updateUserKeyStatus(UserKey userKey, boolean enable) { + userKey.setEnable(enable); + userKeyMapper.updateById(userKey); + } + + /** + * 记录 API 密钥操作日志 + */ + private void logApiKeyAction(UserKey userKey) { + LogDTO logDTO = LogDTOBuilder.builder() + .organizationId(LogConstants.SYSTEM) + .type(LogType.ADD) + .module(LogModule.PERSONAL_INFORMATION_APIKEY) + .method(HttpMethodConstants.GET.name()) + .path("/user/api/key/add") + .sourceId(userKey.getId()) + .content(userKey.getAccessKey()) + .build() + .getLogDTO(); + + OperationLogContext.setContext( + LogContextInfo.builder() + .modifiedValue(userKey) + .build() + ); + logService.add(logDTO); + } + + + public void updateUserKey(UserKeyUpdateRequest request) { + UserKey userKey = validateAndGetUserKey(request.getId()); + userKey.setId(request.getId()); + userKey.setForever(request.getForever()); + if (BooleanUtils.isFalse(request.getForever())) { + if (request.getExpireTime() == null) { + throw new GenericException(Translator.get("expire_time_not_null")); + } + userKey.setExpireTime(request.getExpireTime()); + } else { + userKey.setExpireTime(null); + } + userKey.setDescription(request.getDescription()); + userKeyMapper.updateById(userKey); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/UserLoginService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/UserLoginService.java new file mode 100644 index 0000000..66039b1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/UserLoginService.java @@ -0,0 +1,405 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.ThirdDetailType; +import cn.cordys.common.dto.RoleDataScopeDTO; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.request.LoginRequest; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.CodingUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.ServletUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.system.constants.LoginType; +import cn.cordys.crm.system.constants.OrganizationConfigConstants; +import cn.cordys.crm.system.domain.*; +import cn.cordys.crm.system.dto.ThirdAuthConfigDTO; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigDetailMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationMapper; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import cn.cordys.security.SessionUser; +import cn.cordys.security.SessionUtils; +import cn.cordys.security.UserDTO; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.authc.*; +import org.apache.shiro.authz.UnauthorizedException; +import org.apache.shiro.subject.Subject; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 用户登录服务 + *

+ * 提供用户认证、登录、密码验证等相关功能 + *

+ */ +@Service +@Transactional(rollbackFor = Exception.class) +public class UserLoginService { + @Resource + private BaseMapper userMapper; + + @Resource + private BaseMapper organizationUserMapper; + + @Resource + private ExtOrganizationMapper extOrganizationMapper; + + @Resource + private ExtUserMapper extUserMapper; + + @Resource + private RoleService roleService; + + @Resource + private BaseMapper loginLogMapper; + + @Resource + private BaseMapper departmentMapper; + + @Resource + private PermissionCache permissionCache; + + @Resource + private ExtOrganizationConfigMapper extOrganizationConfigMapper; + + @Resource + private ExtOrganizationConfigDetailMapper extOrganizationConfigDetailMapper; + + /** + * 用户登录 + * + * @param request 登录请求 + * + * @return 会话用户信息 + * + * @throws AuthenticationException 登录失败时抛出相应异常 + */ + public SessionUser login(LoginRequest request) { + String username = StringUtils.trim(request.getUsername()); + String password = StringUtils.trim(request.getPassword()); + + Subject subject = SecurityUtils.getSubject(); + UsernamePasswordToken token = new UsernamePasswordToken(username, password); + + try { + subject.login(token); + + if (!subject.isAuthenticated()) { + throw new GenericException(Translator.get("login_fail")); + } + + // 登录成功,记录会话用户并添加登录日志 + SessionUser sessionUser = SessionUtils.getUser(); + SessionUtils.putUser(sessionUser); + recordLoginLog(request); + + return sessionUser; + } catch (ExcessiveAttemptsException e) { + throw new ExcessiveAttemptsException(Translator.get("password_is_incorrect")); + } catch (LockedAccountException e) { + throw new LockedAccountException(Translator.get("password_is_incorrect")); + } catch (DisabledAccountException e) { + throw new DisabledAccountException(Translator.get("password_is_incorrect")); + } catch (ExpiredCredentialsException e) { + throw new ExpiredCredentialsException(Translator.get("password_is_incorrect")); + } catch (AuthenticationException e) { + throw new AuthenticationException(e.getMessage()); + } catch (UnauthorizedException e) { + throw new UnauthorizedException(Translator.get("password_is_incorrect") + e.getMessage()); + } + } + + /** + * 认证用户并获取用户详细信息 + * + * @param userKey 用户标识(用户名/手机号/邮箱) + * + * @return 用户详细信息 + * + * @throws AuthenticationException 如果用户不存在或被禁用 + */ + public UserDTO authenticateUser(String userKey) { + // 获取用户信息 + UserDTO userDTO = Optional.ofNullable(extUserMapper.selectByPhoneOrEmail(userKey)) + .orElseThrow(() -> new AuthenticationException(Translator.get("password_is_incorrect"))); + + // 非管理员用户需要检查是否被禁用 + if (!isAdminUser(userDTO.getId())) { + checkUserStatus(userDTO); + } + + // 获取用户所属组织列表 + Set orgIds = getUserOrganizations(userDTO.getId()); + + // 确定当前使用的组织ID + String organizationId = determineOrganizationId(userDTO, orgIds); + + // 设置用户权限和角色信息 + setupUserPermissions(userDTO, organizationId, orgIds); + + //默认密码检查 + checkDefaultPwd(userDTO); + + return userDTO; + } + + /** + * 检查默认密码 + * + * @param userDTO + */ + private void checkDefaultPwd(UserDTO userDTO) { + String defaultPwd = ""; + if (Strings.CI.equals(userDTO.getId(), InternalUser.ADMIN.getValue())) { + defaultPwd = CodingUtils.md5("CordysCRM"); + } else { + if (StringUtils.isNotBlank(userDTO.getPhone())) { + defaultPwd = CodingUtils.md5(userDTO.getPhone().substring(userDTO.getPhone().length() - 6)); + } + } + + if (Strings.CI.equals(defaultPwd, userDTO.getPassword())) { + userDTO.setDefaultPwd(true); + } + + } + + /** + * 检查用户密码是否正确 + * + * @param userId 用户ID + * @param password 密码 + * + * @return 密码是否正确 + * + * @throws GenericException 如果用户ID或密码为空 + */ + public boolean checkUserPassword(String userId, String password) { + if (StringUtils.isBlank(userId)) { + throw new GenericException(Translator.get("user_name_is_null")); + } + if (StringUtils.isBlank(password)) { + throw new GenericException(Translator.get("password_is_null")); + } + + User example = new User(); + example.setId(userId); + example.setPassword(CodingUtils.md5(password)); + return userMapper.exist(example); + } + + /** + * 检查移动端认证配置 + * + * @param organizationId 组织ID + * + * @throws AuthenticationException 如果未配置移动端认证或配置无效 + */ + public void checkMobileAuthConfig(String organizationId) { + // 如果不是移动端请求,直接返回 + if (!isMobileRequest()) { + return; + } + + // 检查组织认证配置 + OrganizationConfig orgConfig = getOrganizationAuthConfig(organizationId); + if (orgConfig == null) { + throw new AuthenticationException(Translator.get("auth.setting.no.exists")); + } + + // 检查企业微信认证配置 + List enabledConfigs = getEnabledWeComOauthConfigs(orgConfig.getId()); + if (CollectionUtils.isEmpty(enabledConfigs)) { + throw new AuthenticationException(Translator.get("auth.setting.no.exists")); + } + + // 验证配置内容 + validateAuthConfig(enabledConfigs.getFirst()); + } + + /** + * 检查用户状态是否正常 + */ + private void checkUserStatus(UserDTO userDTO) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper() + .eq(OrganizationUser::getUserId, userDTO.getId()) + .eq(OrganizationUser::getEnable, true); + + if (StringUtils.isNotBlank(userDTO.getLastOrganizationId())) { + queryWrapper.eq(OrganizationUser::getOrganizationId, userDTO.getLastOrganizationId()); + } + + List orgUsers = organizationUserMapper.selectListByLambda(queryWrapper); + if (CollectionUtils.isEmpty(orgUsers)) { + throw new DisabledAccountException(Translator.get("password_is_incorrect")); + } + + // 设置用户部门信息 + setUserDepartmentInfo(userDTO, orgUsers.getFirst()); + } + + /** + * 设置用户的部门信息 + */ + private void setUserDepartmentInfo(UserDTO userDTO, OrganizationUser orgUser) { + userDTO.setDepartmentId(orgUser.getDepartmentId()); + + Optional.ofNullable(departmentMapper.selectByPrimaryKey(orgUser.getDepartmentId())) + .ifPresent(department -> userDTO.setDepartmentName(department.getName())); + } + + /** + * 确定用户当前使用的组织ID + */ + private String determineOrganizationId(UserDTO userDTO, Set orgIds) { + String orgId = OrganizationContext.getOrganizationId(); + + if (StringUtils.isBlank(orgId) && CollectionUtils.isNotEmpty(orgIds)) { + // 上下文中无组织ID时,优先使用用户最后访问的组织,否则取第一个可用组织 + return orgIds.contains(userDTO.getLastOrganizationId()) + ? userDTO.getLastOrganizationId() + : orgIds.iterator().next(); + } + + return orgId; + } + + /** + * 设置用户的权限和角色信息 + */ + private void setupUserPermissions(UserDTO userDTO, String organizationId, Set orgIds) { + // 设置用户角色 + List roleOptions = roleService.getRoleOptions(userDTO.getId(), organizationId); + userDTO.setRoles(roleOptions); + + // 更新最后登录的组织ID + userDTO.setLastOrganizationId(organizationId); + + // 设置用户权限 + userDTO.setPermissionIds(permissionCache.getPermissionIds(userDTO.getId(), organizationId)); + + // 设置用户所属的所有组织 + userDTO.setOrganizationIds(orgIds); + } + + /** + * 获取用户所属的所有组织ID + */ + private Set getUserOrganizations(String userId) { + // 管理员可以访问所有组织 + if (isAdminUser(userId)) { + return extOrganizationMapper.selectAllOrganizationIds(); + } + + // 普通用户只能访问已授权且启用的组织 + return organizationUserMapper.select(createOrgUserExample(userId)).stream() + .map(OrganizationUser::getOrganizationId) + .collect(Collectors.toSet()); + } + + /** + * 创建组织用户查询条件 + */ + private OrganizationUser createOrgUserExample(String userId) { + OrganizationUser example = new OrganizationUser(); + example.setUserId(userId); + example.setEnable(true); + return example; + } + + /** + * 记录登录日志 + */ + private void recordLoginLog(LoginRequest request) { + LoginLog log = new LoginLog(); + log.setId(IDGenerator.nextStr()); + log.setLoginAddress(request.getLoginAddress()); + log.setOperator(SessionUtils.getUserId()); + log.setCreateTime(System.currentTimeMillis()); + log.setPlatform(determinePlatform()); + + loginLogMapper.insert(log); + } + + /** + * 获取组织认证配置 + */ + private OrganizationConfig getOrganizationAuthConfig(String organizationId) { + return extOrganizationConfigMapper.getOrganizationConfig( + organizationId, OrganizationConfigConstants.ConfigType.THIRD.name()); + } + + /** + * 获取启用的企业微信OAuth配置 + */ + private List getEnabledWeComOauthConfigs(String configId) { + return extOrganizationConfigDetailMapper + .getEnableOrganizationConfigDetails(configId, List.of(ThirdDetailType.WECOM_SYNC.name(), ThirdDetailType.DINGTALK_SYNC.name(), ThirdDetailType.LARK_SYNC.name())); + } + + /** + * 验证认证配置是否有效 + */ + private void validateAuthConfig(OrganizationConfigDetail configDetail) { + String content = new String(configDetail.getContent(), StandardCharsets.UTF_8); + ThirdAuthConfigDTO authConfig = JSON.parseObject(content, ThirdAuthConfigDTO.class); + + if (authConfig == null) { + throw new AuthenticationException(Translator.get("auth.setting.no.exists")); + } + } + + /** + * 根据User-Agent确定登录平台类型 + */ + private String determinePlatform() { + return isMobileRequest() ? LoginType.MOBILE.getName() : LoginType.WEB.getName(); + } + + /** + * 判断是否为移动端请求 + */ + private boolean isMobileRequest() { + String userAgent = ServletUtils.getUserAgent(); + return StringUtils.isNotBlank(userAgent) && isMobileUserAgent(userAgent); + } + + /** + * 判断是否为管理员用户 + */ + private boolean isAdminUser(String userId) { + return Strings.CS.equals(userId, InternalUser.ADMIN.getValue()); + } + + /** + * 判断是否为移动端User-Agent + */ + private boolean isMobileUserAgent(String userAgent) { + return userAgent.contains("miniprogram") || + userAgent.contains("MicroMessenger") || + userAgent.contains("Android") || + userAgent.contains("iOS") || + userAgent.contains("Mobile") || + userAgent.contains("MQQBrowser") || + userAgent.contains("Mobile Safari") || + userAgent.contains("iPhone") || + userAgent.contains("iPad") || + userAgent.contains("ipod"); + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/UserRoleService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/UserRoleService.java new file mode 100644 index 0000000..99856c3 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/UserRoleService.java @@ -0,0 +1,284 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.DeptUserTreeNode; +import cn.cordys.common.dto.RoleUserTreeNode; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.SubListUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.domain.Department; +import cn.cordys.crm.system.domain.Role; +import cn.cordys.crm.system.domain.UserExtend; +import cn.cordys.crm.system.domain.UserRole; +import cn.cordys.crm.system.dto.convert.UserRoleConvert; +import cn.cordys.crm.system.dto.request.RoleUserPageRequest; +import cn.cordys.crm.system.dto.request.RoleUserRelateRequest; +import cn.cordys.crm.system.dto.response.RoleListResponse; +import cn.cordys.crm.system.dto.response.RoleUserListResponse; +import cn.cordys.crm.system.dto.response.RoleUserOptionResponse; +import cn.cordys.crm.system.mapper.ExtDepartmentMapper; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.crm.system.mapper.ExtUserRoleMapper; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import jodd.util.StringUtil; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.ListUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * @author jianxing + * @date 2025-01-13 17:33:23 + */ +@Service +@Transactional(rollbackFor = Exception.class) +public class UserRoleService { + @Resource + private BaseMapper userRoleMapper; + @Resource + private ExtUserRoleMapper extUserRoleMapper; + @Resource + private ExtUserMapper extUserMapper; + @Resource + private ExtDepartmentMapper extDepartmentMapper; + @Resource + private RoleService roleService; + @Resource + private BaseMapper departmentMapper; + @Resource + private BaseMapper userExtendMapper; + @Resource + private PermissionCache permissionCache; + @Resource + private BaseMapper roleMapper; + @Resource + private BaseService baseService; + @Resource + private LogService logService; + @Resource + private DepartmentService departmentService; + + public void delete(String id) { + userRoleMapper.deleteByPrimaryKey(id); + } + + public List listUserByRoleId(RoleUserPageRequest request, String orgId) { + List users = extUserRoleMapper.list(request, orgId); + Map> userRoleMap = getUserRoleMap(orgId, users); + + Map deptNameMap = getDeptNameMap(users); + Map userAvatarMap = getUserAvatarMap(users); + + for (RoleUserListResponse user : users) { + user.setRoles(userRoleMap.get(user.getUserId())); + user.setDepartmentName(deptNameMap.get(user.getDepartmentId())); + user.setAvatar(userAvatarMap.get(user.getUserId())); + } + return users; + } + + private Map getUserAvatarMap(List users) { + if (CollectionUtils.isEmpty(users)) { + return Map.of(); + } + + List userIds = users.stream().map(RoleUserListResponse::getUserId).toList(); + return userExtendMapper.selectByIds(userIds.toArray(String[]::new)) + .stream() + .filter(userExtend -> userExtend != null && StringUtils.isNotBlank(userExtend.getId())) + .collect(Collectors.toMap( + UserExtend::getId, + userExtend -> StringUtils.defaultIfBlank(userExtend.getAvatar(), "") + )); + } + + private Map getDeptNameMap(List users) { + if (CollectionUtils.isEmpty(users)) { + return Map.of(); + } + return departmentMapper.selectByIds(users.stream().map(RoleUserListResponse::getDepartmentId).distinct().toArray(String[]::new)) + .stream() + .collect(Collectors.toMap(Department::getId, Department::getName)); + } + + private Map> getUserRoleMap(String orgId, List users) { + if (CollectionUtils.isEmpty(users)) { + return Map.of(); + } + List userIds = users.stream() + .map(RoleUserListResponse::getUserId) + .collect(Collectors.toList()); + + List userRoles = extUserMapper.getUserRole(userIds, orgId); + userRoles.forEach(role -> role.setName(roleService.translateInternalRole(role.getName()))); + return userRoles.stream().collect(Collectors.groupingBy(UserRoleConvert::getUserId)); + } + + /** + * 获取带用户的信息的部门树 + * + * @return List + */ + public List getDeptUserTree(String orgId, String roleId) { + List treeNodes = extDepartmentMapper.selectDeptUserTreeNode(orgId); + List userNodes = extUserRoleMapper.selectUserDeptForRelevance(orgId, roleId); + // 如果已经关联的用户,设置为 disable + Set currentRoleUserIds = new HashSet<>(extUserRoleMapper.getUserIdsByRoleIds(List.of(roleId))); + userNodes.forEach(userNode -> { + if (currentRoleUserIds.contains(userNode.getId())) { + userNode.setEnabled(false); + } + }); + + userNodes = departmentService.sortByCommander(orgId, userNodes); + + userNodes.addAll(treeNodes); + return BaseTreeNode.buildTree(userNodes); + } + + public List getRoleUserTree(String orgId, String roleId) { + // 查询角色信息 + List list = roleService.list(orgId); + List treeNodes = list.stream().filter(role -> !StringUtil.equals(roleId, role.getId())) + .map((role) -> { + RoleUserTreeNode roleNode = new RoleUserTreeNode(); + roleNode.setNodeType("ROLE"); + roleNode.setInternal(BooleanUtils.isTrue(role.getInternal())); + roleNode.setId(role.getId()); + roleNode.setName(role.getName()); + return roleNode; + }).collect(Collectors.toList()); + // 查询用户信息 + List userNodes = extUserRoleMapper.selectUserRoleForRelevance(orgId, roleId); + + // 如果已经关联的用户,设置为 disable + Set currentRoleUserIds = new HashSet<>(extUserRoleMapper.getUserIdsByRoleIds(List.of(roleId))); + + userNodes.forEach(userNode -> { + if (currentRoleUserIds.contains(userNode.getId())) { + userNode.setEnabled(false); + } + }); + treeNodes.addAll(userNodes); + return BaseTreeNode.buildTree(treeNodes); + } + + public void relateUser(RoleUserRelateRequest request, String operator, String orgId) { + Set userSet = new HashSet<>(); + if (CollectionUtils.isNotEmpty(request.getRoleIds())) { + userSet.addAll(extUserRoleMapper.getUserIdsByRoleIds(request.getRoleIds())); + } + if (CollectionUtils.isNotEmpty(request.getDeptIds())) { + userSet.addAll(extDepartmentMapper.getUserIdsByDeptIds(request.getDeptIds())); + } + if (CollectionUtils.isNotEmpty(request.getUserIds())) { + userSet.addAll(request.getUserIds()); + } + + List userIds = new ArrayList<>(userSet); + SubListUtils.dealForSubList(userIds, 50, (subUserIds) -> { + Map userNameMap = baseService.getUserNameMap(subUserIds); + // 过滤掉已删除的用户 + subUserIds = subUserIds.stream() + .filter(userId -> userNameMap.get(userId) != null) + .toList(); + + List currentRoleUserIds = extUserRoleMapper.getUserIdsByRoleIds(List.of(request.getRoleId())); + // 去除已关联的用户,添加未关联的用户 + List userRoles = ListUtils.subtract(subUserIds, currentRoleUserIds).stream() + .map(userId -> { + UserRole userRole = new UserRole(); + userRole.setUserId(userId); + userRole.setRoleId(request.getRoleId()); + userRole.setId(IDGenerator.nextStr()); + userRole.setCreateUser(operator); + userRole.setUpdateUser(operator); + userRole.setCreateTime(System.currentTimeMillis()); + userRole.setUpdateTime(System.currentTimeMillis()); + return userRole; + }) + .collect(Collectors.toList()); + userRoleMapper.batchInsert(userRoles); + + Role role = roleMapper.selectByPrimaryKey(request.getRoleId()); + role.setName(roleService.translateInternalRole(role.getName())); + List logs = new ArrayList<>(); + userRoles.forEach(u -> { + // 记录日志 + LogDTO logDTO = new LogDTO(orgId, u.getRoleId(), operator, LogType.ADD_USER, LogModule.SYSTEM_ROLE, role.getName()); + String detail = Translator.getWithArgs("role.add_user", role.getName(), + userNameMap.get(u.getUserId())); + logDTO.setDetail(detail); + logs.add(logDTO); + + // 清除权限缓存 + permissionCache.clearCache(u.getUserId(), orgId); + }); + + logService.batchAdd(logs); + }); + } + + public void deleteRoleUser(String id, String userId, String orgId) { + UserRole userRole = userRoleMapper.selectByPrimaryKey(id); + userRoleMapper.deleteByPrimaryKey(id); + permissionCache.clearCache(userRole.getUserId(), orgId); + + Role role = roleMapper.selectByPrimaryKey(userRole.getRoleId()); + role.setName(roleService.translateInternalRole(role.getName())); + String userName = baseService.getUserName(userRole.getUserId()); + // 记录日志 + LogDTO logDTO = new LogDTO(orgId, role.getId(), userId, LogType.REMOVE_USER, LogModule.SYSTEM_ROLE, role.getName()); + String detail = Translator.getWithArgs("role.remove_user", role.getName(), + userName); + logDTO.setDetail(detail); + logService.add(logDTO); + } + + public void batchDeleteRoleUser(List ids, String userId, String orgId) { + List userRoles = userRoleMapper.selectByIds(ids); + extUserRoleMapper.deleteByIds(ids); + + List userIds = userRoles.stream() + .map(UserRole::getUserId) + .toList(); + Map userNameMap = baseService.getUserNameMap(userIds); + + Role role = roleMapper.selectByPrimaryKey(userRoles.getFirst().getRoleId()); + role.setName(roleService.translateInternalRole(role.getName())); + List logs = new ArrayList<>(); + userRoles.forEach(userRole -> { + // 记录日志 + LogDTO logDTO = new LogDTO(orgId, userRole.getRoleId(), userId, LogType.REMOVE_USER, LogModule.SYSTEM_ROLE, role.getName()); + String detail = Translator.getWithArgs("role.remove_user", role.getName(), + userNameMap.get(userRole.getUserId())); + logDTO.setDetail(detail); + logs.add(logDTO); + + // 清除权限缓存 + permissionCache.clearCache(userRole.getUserId(), orgId); + }); + + logService.batchAdd(logs); + } + + public List getUserOptionByRoleId(String organizationId, String roleId) { + List roleUserOptions = extUserRoleMapper.selectUserOption(organizationId); + Set roleUserIdSet = new HashSet<>(extUserRoleMapper.getUserIdsByRoleIds(List.of(roleId))); + + roleUserOptions.forEach(userOption -> userOption.setEnabled(!roleUserIdSet.contains(userOption.getId()))); + + return roleUserOptions; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/service/UserViewService.java b/backend/crm/src/main/java/cn/cordys/crm/system/service/UserViewService.java new file mode 100644 index 0000000..8d150b4 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/service/UserViewService.java @@ -0,0 +1,381 @@ +package cn.cordys.crm.system.service; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.condition.CombineSearch; +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.uid.utils.EnumUtils; +import cn.cordys.common.util.*; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.constants.UserViewConditionValueType; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.domain.UserViewCondition; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.field.base.SubField; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.dto.response.UserViewListResponse; +import cn.cordys.crm.system.dto.response.UserViewResponse; +import cn.cordys.crm.system.mapper.ExtUserViewConditionMapper; +import cn.cordys.crm.system.mapper.ExtUserViewMapper; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service("userViewService") +@Transactional(rollbackFor = Exception.class) +public class UserViewService { + + @Resource + private BaseMapper userViewMapper; + @Resource + private ExtUserViewMapper extUserViewMapper; + @Resource + private BaseMapper userViewConditionMapper; + @Resource + private ExtUserViewConditionMapper extUserViewConditionMapper; + @Resource + private ModuleFormCacheService moduleFormCacheService; + @Resource + private ModuleFormService moduleFormService; + + /** + * 添加用户视图 + * + * @param request 视图请求参数 + * @param userId 用户ID + * @param orgId 组织ID + * @param resourceType 视图类型 + * + * @return 新增的用户视图 + */ + public UserView add(UserViewAddRequest request, String userId, String orgId, String resourceType) { + Long nextPos = getNextPos(orgId, userId, resourceType); + String viewId = IDGenerator.nextStr(); + UserView userView = new UserView(); + userView.setId(viewId); + userView.setName(request.getName()); + userView.setUserId(userId); + userView.setResourceType(resourceType); + userView.setFixed(false); + userView.setEnable(true); + userView.setOrganizationId(orgId); + userView.setPos(nextPos); + userView.setSearchMode(request.getSearchMode()); + userView.setCreateUser(userId); + userView.setUpdateUser(userId); + userView.setCreateTime(System.currentTimeMillis()); + userView.setUpdateTime(System.currentTimeMillis()); + userViewMapper.insert(userView); + + addUserViewConditions(request.getConditions(), viewId, userId); + + return userView; + } + + + private Long getNextPos(String orgId, String userId, String resourceType) { + Long pos = extUserViewMapper.getNextPos(orgId, userId, resourceType); + return (pos == null ? 0 : pos) + NodeSortUtils.DEFAULT_NODE_INTERVAL_POS; + } + + + /** + * 解析自定义字段 + * + * @param conditions + * @param viewId + * @param userId + */ + private void addUserViewConditions(List conditions, String viewId, String userId) { + if (CollectionUtils.isEmpty(conditions)) { + return; + } + List insertConditions = conditions.stream().map(condition -> { + UserViewCondition userViewCondition = new UserViewCondition(); + userViewCondition.setId(IDGenerator.nextStr()); + Object value = condition.getValue(); + String conditionValueType = getConditionValueType(value); + userViewCondition.setValueType(conditionValueType); + if (condition.getValue() != null) { + if (value instanceof List) { + userViewCondition.setValue(JSON.toJSONString(value)); + } else { + userViewCondition.setValue(condition.getValue().toString()); + } + } + if (CollectionUtils.isNotEmpty(condition.getContainChildIds())) { + userViewCondition.setChildrenValue(JSON.toJSONString(condition.getContainChildIds())); + } + userViewCondition.setName(condition.getName()); + userViewCondition.setType(condition.getType()); + userViewCondition.setMultipleValue(condition.getMultipleValue()); + userViewCondition.setOperator(condition.getOperator()); + userViewCondition.setSysUserViewId(viewId); + userViewCondition.setCreateUser(userId); + userViewCondition.setUpdateUser(userId); + userViewCondition.setCreateTime(System.currentTimeMillis()); + userViewCondition.setUpdateTime(System.currentTimeMillis()); + return userViewCondition; + }).toList(); + + userViewConditionMapper.batchInsert(insertConditions); + } + + + private String getConditionValueType(Object value) { + if (value instanceof List) { + return UserViewConditionValueType.ARRAY.name(); + } else if (value instanceof Integer || value instanceof Long) { + return UserViewConditionValueType.INT.name(); + } else if (value instanceof Float || value instanceof Double) { + return UserViewConditionValueType.FLOAT.name(); + } else if (value instanceof Boolean) { + return UserViewConditionValueType.BOOLEAN.name(); + } else { + return UserViewConditionValueType.STRING.name(); + } + } + + + /** + * 编辑用户视图 + * + * @param request + * @param userId + * @param orgId + * + * @return + */ + public UserView update(UserViewUpdateRequest request, String userId, String orgId) { + checkView(userId, request.getId(), orgId); + + UserView userView = new UserView(); + userView.setId(request.getId()); + userView.setName(request.getName()); + userView.setSearchMode(request.getSearchMode()); + userView.setUpdateTime(System.currentTimeMillis()); + userViewMapper.update(userView); + + // 先删除 + deleteConditionsByViewId(request.getId()); + // 再新增 + addUserViewConditions(request.getConditions(), request.getId(), userId); + + return userView; + } + + private void deleteConditionsByViewId(String viewId) { + extUserViewConditionMapper.delete(viewId); + } + + private void checkView(String userId, String id, String orgId) { + if (extUserViewMapper.countUserView(userId, id, orgId) <= 0) { + throw new GenericException(Translator.get("view_blank")); + } + } + + + /** + * 删除视图 + * + * @param id + * @param userId + * @param orgId + */ + public void delete(String id, String userId, String orgId) { + checkView(userId, id, orgId); + userViewMapper.deleteByPrimaryKey(id); + deleteConditionsByViewId(id); + } + + + /** + * 获取视图详情 + * + * @param id + * @param userId + * @param orgId + * + * @return + */ + public UserViewResponse getViewDetail(String id, String userId, String orgId, String formKey) { + checkView(userId, id, orgId); + UserView userView = userViewMapper.selectByPrimaryKey(id); + List conditions = getFilterConditions(id); + UserViewResponse response = new UserViewResponse(); + BeanUtils.copyBean(response, userView); + response.setConditions(conditions); + response.setOptionMap(buildOptionMap(orgId, formKey, conditions)); + return response; + } + + public Map> buildOptionMap(String orgId, String formKey, List conditions) { + Set businessKeys = Arrays.stream(BusinessModuleField.values()) + .map(BusinessModuleField::getBusinessKey) + .collect(Collectors.toSet()); + + ModuleFormConfigDTO customerFormConfig = moduleFormCacheService.getBusinessFormConfig(formKey, orgId); + + Map fieldIdBusinessKeyMap = customerFormConfig.getFields().stream() + .filter(baseField -> businessKeys.contains(baseField.getBusinessKey())) + .collect(Collectors.toMap(BaseField::getId, BaseField::getBusinessKey)); + + // 获取表单配置,用于查询字段类型 + Map fieldIdTypeMap = new HashMap<>(); + if (customerFormConfig != null && CollectionUtils.isNotEmpty(customerFormConfig.getFields())) { + customerFormConfig.getFields().stream() + .forEach(baseField -> { + if (baseField instanceof SubField subField) { + subField.getSubFields().forEach(sub -> { + fieldIdTypeMap.put(baseField.getId() + "." + sub.getId(), sub.getType()); + if (StringUtils.isNotBlank(baseField.getBusinessKey())) { + fieldIdTypeMap.put(baseField.getId() + "." + baseField.getBusinessKey(), sub.getType()); + } + }); + } + fieldIdTypeMap.put(baseField.getId(), baseField.getType()); + if (StringUtils.isNotBlank(baseField.getBusinessKey())) { + fieldIdTypeMap.put(baseField.getBusinessKey(), baseField.getType()); + } + }); + } + + // 获取数据源类型的字段值 + List moduleFieldValues = conditions.stream() + .filter(condition -> StringUtils.isNotBlank(fieldIdTypeMap.get(condition.getName())) + && Strings.CS.equalsAny(fieldIdTypeMap.get(condition.getName()), + FieldType.DATA_SOURCE.name(), + FieldType.DATA_SOURCE_MULTIPLE.name(), + FieldType.MEMBER.name(), + FieldType.MEMBER_MULTIPLE.name(), + FieldType.DEPARTMENT.name(), + FieldType.DEPARTMENT_MULTIPLE.name()) + ) + .map(condition -> { + BaseModuleFieldValue moduleFieldValue = new BaseModuleFieldValue(); + moduleFieldValue.setFieldId(condition.getName()); + moduleFieldValue.setFieldValue(condition.getValue()); + return moduleFieldValue; + }).collect(Collectors.toList()); + + // 获取选项值对应的 option + Map> optionMap = moduleFormService.getOptionMap(customerFormConfig, moduleFieldValues); + fieldIdBusinessKeyMap.forEach((fieldId, businessKey) -> { + if (optionMap.containsKey(fieldId)) { + List options = optionMap.get(fieldId); + optionMap.put(businessKey, options); + } + }); + return optionMap; + } + + public List getFilterConditions(String viewId) { + List viewConditions = extUserViewConditionMapper.getViewConditions(viewId); + return viewConditions.stream().map(condition -> { + FilterCondition filterCondition = BeanUtils.copyBean(new FilterCondition(), condition); + Object value = getConditionValueByType(condition.getValueType(), condition.getValue()); + filterCondition.setValue(value); + if (StringUtils.isNotBlank(condition.getChildrenValue())) { + filterCondition.setContainChildIds(JSON.parseArray(condition.getChildrenValue(), String.class)); + } + return filterCondition; + }).toList(); + } + + + private Object getConditionValueByType(String valueType, String value) { + UserViewConditionValueType conditionValueType = EnumUtils.valueOf(UserViewConditionValueType.class, valueType); + if (StringUtils.isBlank(value)) { + return null; + } + return switch (conditionValueType) { + case ARRAY -> JSON.parseObject(value); + case INT -> Long.valueOf(value); + case FLOAT -> Double.valueOf(value); + case BOOLEAN -> Boolean.valueOf(value); + default -> value; + }; + } + + + /** + * 视图列表 + * + * @param resourceType + * @param userId + * @param orgId + * + * @return + */ + public List list(String resourceType, String userId, String orgId) { + return extUserViewMapper.selectViewList(resourceType, userId, orgId); + } + + + /** + * 固定/取消固定 + * + * @param id + * @param userId + * @param orgId + */ + public void fixed(String id, String userId, String orgId) { + checkView(userId, id, orgId); + UserView userView = userViewMapper.selectByPrimaryKey(id); + UserView updateView = new UserView(); + updateView.setId(id); + updateView.setFixed(!userView.getFixed()); + userViewMapper.update(updateView); + } + + public void editPos(PosRequest request, String userId, String resourceType) { + ServiceUtils.updatePosFieldByDesc(request, + UserView.class, + userId, + resourceType, + userViewMapper::selectByPrimaryKey, + extUserViewMapper::getPrePos, + extUserViewMapper::getLastPos, + userViewMapper::update); + } + + + /** + * 启用视图 + * + * @param id + * @param userId + * @param orgId + */ + public void enable(String id, String userId, String orgId) { + checkView(userId, id, orgId); + UserView userView = userViewMapper.selectByPrimaryKey(id); + changeEnable(id, !userView.getEnable()); + + } + + private void changeEnable(String id, boolean enable) { + UserView userView = new UserView(); + userView.setId(id); + userView.setEnable(enable); + userViewMapper.update(userView); + } + + public String getSearchMode(String viewId) { + UserView userView = userViewMapper.selectByPrimaryKey(viewId); + return Optional.ofNullable(userView).map(UserView::getSearchMode).orElse(CombineSearch.SearchMode.AND.name()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/utils/ArchUtils.java b/backend/crm/src/main/java/cn/cordys/crm/system/utils/ArchUtils.java new file mode 100644 index 0000000..8785cba --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/utils/ArchUtils.java @@ -0,0 +1,24 @@ +package cn.cordys.crm.system.utils; + +public class ArchUtils { + + /** + * 将各种 os.arch 的叫法统一为 amd64 或 arm64 + */ + public static String getNormalizedArch() { + String arch = System.getProperty("os.arch"); + if (arch == null) { + return "unknown"; + } + + arch = arch.toLowerCase(); + + if (arch.contains("aarch64") || arch.contains("arm64")) { + return "arm64"; + } else if (arch.contains("amd64") || arch.contains("x86_64") || arch.contains("x64")) { + return "amd64"; + } else { + return arch; // 其他架构原样返回 + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/utils/CSHttpClient.java b/backend/crm/src/main/java/cn/cordys/crm/system/utils/CSHttpClient.java new file mode 100644 index 0000000..6ff7712 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/utils/CSHttpClient.java @@ -0,0 +1,144 @@ +package cn.cordys.crm.system.utils; + +import cn.cordys.common.util.JSON; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Year; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Map; + +/** + * CRM 系统 HTTP 客户端工具类。 + *

+ * 提供简单的 GET 请求能力,并从响应头 {@code Date} 解析服务端年份。 + * 线程安全,可复用。 + */ +@Slf4j +public class CSHttpClient { + + private static final HttpClient CLIENT = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(5)) + .build(); + + /** + * 基于完整 URL 的 GET 调用(保留以兼容旧用法)。 + * + * @param url 绝对 URL + * + * @return 下载信息 + */ + public static DownloadInfo sendGetRequest(String url) { + DownloadInfo info; + String dateHeader = null; + + try { + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(10)) + .header("Accept", "application/json") + .header("User-Agent", "CORDYS-CRM-Client/1.0") + .GET() + .build(); + + HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + dateHeader = response.headers().firstValue("Date").orElse(null); + info = handleResponse(response); + } catch (Exception e) { + log.warn("GET 请求失败: {}", e.toString()); + info = new DownloadInfo(); + } + + if (info == null) { + info = new DownloadInfo(); + } + info.setYear(dateHeader(dateHeader)); + return info; + } + + /** + * 基于基础地址与查询参数的 GET 调用,自动进行 URL 编码并忽略空参数。 + * + * @param queryParams 查询参数 Map(null/空值将被忽略) + * + * @return 下载信息 + */ + public static DownloadInfo sendGetRequest(Map queryParams) { + String baseUrl = "https://community.fit2cloud.com/api/version"; + String url = buildUrl(baseUrl, queryParams); + return sendGetRequest(url); + } + + /** + * 将查询参数拼接到基础 URL,并进行 UTF-8 编码。 + * + * @param baseUrl 基础地址 + * @param params 参数 + * + * @return 完整 URL + */ + private static String buildUrl(String baseUrl, Map params) { + if (params == null || params.isEmpty()) { + return baseUrl; + } + StringBuilder sb = new StringBuilder(baseUrl); + boolean hasQuery = baseUrl.contains("?"); + for (Map.Entry e : params.entrySet()) { + String key = e.getKey(); + String val = e.getValue(); + if (StringUtils.isAnyBlank(key, val)) { + continue; + } + sb.append(hasQuery ? '&' : '?'); + hasQuery = true; + sb.append(URLEncoder.encode(key, StandardCharsets.UTF_8)) + .append('=') + .append(URLEncoder.encode(val, StandardCharsets.UTF_8)); + } + return sb.toString(); + } + + private static DownloadInfo handleResponse(HttpResponse response) { + int statusCode = response.statusCode(); + String responseBody = response.body(); + if (statusCode >= 200 && statusCode < 300 && StringUtils.isNotBlank(responseBody)) { + try { + return JSON.parseObject(responseBody, DownloadInfo.class); + } catch (Exception ignored) { + return null; + } + } + return null; + } + + public static int dateHeader(String dateHeader) { + try { + if (StringUtils.isBlank(dateHeader)) { + return Year.now().getValue(); + } + ZonedDateTime gmt = ZonedDateTime.parse(dateHeader, DateTimeFormatter.RFC_1123_DATE_TIME); + return gmt.withZoneSameInstant(ZoneId.of("Asia/Shanghai")).getYear(); + } catch (Exception e) { + return Year.now().getValue(); + } + } + + @Data + public static class DownloadInfo { + private String latestVersion; + private String currentVersion; + private String edition; + private boolean updateAvailable; + private int year; + } +} \ No newline at end of file diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/utils/CopyrightUtils.java b/backend/crm/src/main/java/cn/cordys/crm/system/utils/CopyrightUtils.java new file mode 100644 index 0000000..c3839c0 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/utils/CopyrightUtils.java @@ -0,0 +1,13 @@ +package cn.cordys.crm.system.utils; + +public class CopyrightUtils { + private static final int START_YEAR = 2014; + + public static String getCopyright(int currentYear) { + if (START_YEAR == currentYear) { + return String.format("Copyright © %d 飞致云", currentYear); + } else { + return String.format("Copyright © %d-%d 飞致云", START_YEAR, currentYear); + } + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/utils/MailSender.java b/backend/crm/src/main/java/cn/cordys/crm/system/utils/MailSender.java new file mode 100644 index 0000000..fb95cef --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/utils/MailSender.java @@ -0,0 +1,149 @@ +package cn.cordys.crm.system.utils; + +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.constants.OrganizationConfigConstants; +import cn.cordys.crm.system.domain.OrganizationConfig; +import cn.cordys.crm.system.domain.OrganizationConfigDetail; +import cn.cordys.crm.system.dto.response.EmailDTO; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigDetailMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigMapper; +import jakarta.annotation.Resource; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; +import java.util.Properties; + +@Component +@Slf4j +public class MailSender { + + @Resource + private ExtOrganizationConfigMapper extOrganizationConfigMapper; + + @Resource + private ExtOrganizationConfigDetailMapper extOrganizationConfigDetailMapper; + + public void send(String subject, + String context, + String[] users, + String[] cc, + String organizationId) { + + OrganizationConfig config = extOrganizationConfigMapper.getOrganizationConfig( + organizationId, + OrganizationConfigConstants.ConfigType.EMAIL.name() + ); + if (config == null) { + log.error("邮件配置不存在"); + return; + } + + OrganizationConfigDetail detail = extOrganizationConfigDetailMapper.getOrganizationConfigDetail(config.getId()); + if (detail == null) { + log.error("邮件配置内容为空"); + return; + } + + EmailDTO emailDTO = JSON.parseObject( + new String(detail.getContent(), StandardCharsets.UTF_8), + EmailDTO.class + ); + + JavaMailSenderImpl mailSender = buildMailSender(emailDTO); + MimeMessage message = mailSender.createMimeMessage(); + + try { + MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name()); + helper.setFrom(buildFromAddress(mailSender, emailDTO)); + helper.setSubject(buildSubject(subject)); + helper.setText(context, true); + + log.info("收件人: {}", Arrays.toString(users)); + + if (cc != null && cc.length > 0) { + helper.setTo(users); + helper.setCc(cc); + mailSender.send(message); + } else { + for (String user : users) { + log.info("正在发送邮件给: {}", user); + //检查user 是否符合邮箱格式 + if (StringUtils.isBlank(user) || !user.contains("@")) { + log.warn("用户 {} 不符合邮箱格式,或者用户为空,将跳过", user); + continue; + } + helper.setTo(user); + mailSender.send(message); + } + } + } catch (Exception e) { + log.error("发送邮件失败", e); + } + } + + public JavaMailSenderImpl buildMailSender(EmailDTO emailDTO) { + JavaMailSenderImpl sender = new JavaMailSenderImpl(); + sender.setDefaultEncoding(StandardCharsets.UTF_8.name()); + sender.setProtocol("smtp"); + sender.setHost(emailDTO.getHost()); + sender.setPort(Integer.parseInt(emailDTO.getPort())); + sender.setUsername(emailDTO.getAccount()); + sender.setPassword(emailDTO.getPassword()); + + Properties props = new Properties(); + props.put("mail.smtp.auth", "true"); + props.put("mail.smtp.timeout", "30000"); + props.put("mail.smtp.connectiontimeout", "5000"); + props.put("mail.smtp.ssl.trust", sender.getHost()); + + if (BooleanUtils.toBoolean(emailDTO.getSsl())) { + sender.setProtocol("smtps"); + props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory"); + } + + if (BooleanUtils.toBoolean(emailDTO.getTsl())) { + props.put("mail.smtp.starttls.enable", "true"); + props.put("mail.smtp.starttls.required", "true"); + } + + sender.setJavaMailProperties(props); + return sender; + } + + private InternetAddress buildFromAddress(JavaMailSenderImpl sender, EmailDTO emailDTO) throws Exception { + String username = sender.getUsername(); + String email = Strings.CS.contains(username, "@") + ? username + : username + "@" + Objects.requireNonNull(sender.getHost()).substring(sender.getHost().indexOf('.') + 1); + + InternetAddress from = new InternetAddress(); + String smtpFrom = emailDTO.getFrom(); + + if (StringUtils.isBlank(smtpFrom)) { + from.setAddress(email); + from.setPersonal(username); + } else { + from.setAddress(smtpFrom.contains("@") ? smtpFrom : email); + from.setPersonal(smtpFrom); + } + return from; + } + + private String buildSubject(String subject) { + if (StringUtils.length(subject) > 60) { + subject = subject.substring(0, 59); + } + return "Cordys CRM " + subject; + } + +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/system/utils/MessageTemplateUtils.java b/backend/crm/src/main/java/cn/cordys/crm/system/utils/MessageTemplateUtils.java new file mode 100644 index 0000000..e1d04a1 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/system/utils/MessageTemplateUtils.java @@ -0,0 +1,119 @@ +package cn.cordys.crm.system.utils; + +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.security.UserDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.commons.lang3.time.DateFormatUtils; +import org.apache.commons.text.StringSubstitutor; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; + +public class MessageTemplateUtils { + + private static void setFieldNameMap(Field[] moduleFields, Map moduleMap) { + for (Field moduleField : moduleFields) { + Schema annotation = moduleField.getAnnotation(Schema.class); + String name = ""; + if (annotation != null) { + name = Translator.get(annotation.description()); + } + moduleMap.put(moduleField.getName(), name); + } + } + + /** + * 获取模块翻译后的名称map + * + * @return Map moduleMap + */ + public static Map getModuleMap() { + Field[] moduleFields = FieldUtils.getAllFields(NotificationConstants.Module.class); + Map moduleMap = new HashMap<>(); + setFieldNameMap(moduleFields, moduleMap); + return moduleMap; + } + + + /** + * 获取事件翻译后的名称map + * + * @return Map eventMap + */ + public static Map getEventMap() { + Map eventMap = new HashMap<>(); + Field[] eventFields = FieldUtils.getAllFields(NotificationConstants.Event.class); + setFieldNameMap(eventFields, eventMap); + return eventMap; + } + + /** + * 获取事件的默认模版 + * + * @return Map defaultTemplateMap + */ + public static Map getDefaultTemplateMap() { + Map defaultTemplateMap = new HashMap<>(); + Field[] defaultTemplateFields = FieldUtils.getAllFields(NotificationConstants.TemplateText.class); + setFieldNameMap(defaultTemplateFields, defaultTemplateMap); + return defaultTemplateMap; + } + + public static String getTemplate(String event) { + Map defaultTemplateMap = getDefaultTemplateMap(); + return defaultTemplateMap.get(event + "_TEXT"); + } + + public static String getContent(String template, Map context) { + // 处理 null + context.forEach((k, v) -> { + if (v == null) { + context.put(k, StringUtils.EMPTY); + } + }); + // 处理时间格式的数据 + handleTime(context); + // 处理人相关的数据 + handleUser(context); + StringSubstitutor sub = new StringSubstitutor(context); + return sub.replace(template); + } + + public static void handleTime(Map context) { + context.forEach((k, v) -> { + if (Strings.CI.endsWith(k, "Time")) { + try { + String value = v.toString(); + long time = Long.parseLong(value); + v = DateFormatUtils.format(time, "yyyy-MM-dd HH:mm:ss"); + context.put(k, v); + } catch (Exception ignore) { + } + } + }); + } + + public static void handleUser(Map context) { + ExtUserMapper userMapper = CommonBeanFactory.getBean(ExtUserMapper.class); + context.forEach((k, v) -> { + if (Strings.CI.endsWith(k, "User")) { + try { + String value = v.toString(); + assert userMapper != null; + UserDTO user = userMapper.selectByPhoneOrEmail(value); + context.put(k, user.getName()); + } catch (Exception ignore) { + } + } + }); + } + + +} diff --git a/backend/crm/src/main/resources/dict/industry.json b/backend/crm/src/main/resources/dict/industry.json new file mode 100644 index 0000000..f4cae12 --- /dev/null +++ b/backend/crm/src/main/resources/dict/industry.json @@ -0,0 +1,506 @@ +[ + { + "label": "农、林、牧、渔业", + "value": "A", + "children": [ + { + "label": "农业", + "value": "01" + }, + { + "label": "林业", + "value": "02" + }, + { + "label": "畜牧业", + "value": "03" + }, + { + "label": "渔业", + "value": "04" + }, + { + "label": "农、林、牧、渔专业及辅助性活动", + "value": "05" + } + ] + }, + { + "label": "采矿业", + "value": "B", + "children": [ + { + "label": "煤炭开采和洗选业", + "value": "06" + }, + { + "label": "石油和天然气开采业", + "value": "07" + }, + { + "label": "黑色金属矿采选业", + "value": "08" + }, + { + "label": "有色金属矿采选业", + "value": "09" + }, + { + "label": "非金属矿采选业", + "value": "10" + }, + { + "label": "开采专业及辅助性活动", + "value": "11" + }, + { + "label": "其他采矿业", + "value": "12" + } + ] + }, + { + "label": "制造业", + "value": "C", + "children": [ + { + "label": "农副食品加工业", + "value": "13" + }, + { + "label": "食品制造业", + "value": "14" + }, + { + "label": "酒、饮料和精制茶制造业", + "value": "15" + }, + { + "label": "烟草制品业", + "value": "16" + }, + { + "label": "纺织业", + "value": "17" + }, + { + "label": "纺织服装、服饰业", + "value": "18" + }, + { + "label": "皮革、毛皮、羽毛及其制品和制鞋业", + "value": "19" + }, + { + "label": "木材加工和木、竹、藤、棕、草制品业", + "value": "20" + }, + { + "label": "家具制造业", + "value": "21" + }, + { + "label": "造纸和纸制品业", + "value": "22" + }, + { + "label": "印刷和记录媒介复制业", + "value": "23" + }, + { + "label": "文教、工美、体育和娱乐用品制造业", + "value": "24" + }, + { + "label": "石油、煤炭及其他燃料加工业", + "value": "25" + }, + { + "label": "化学原料和化学制品制造业", + "value": "26" + }, + { + "label": "医药制造业", + "value": "27" + }, + { + "label": "化学纤维制造业", + "value": "28" + }, + { + "label": "橡胶和塑料制品业", + "value": "29" + }, + { + "label": "非金属矿物制品业", + "value": "30" + }, + { + "label": "黑色金属冶炼和压延加工业", + "value": "31" + }, + { + "label": "有色金属冶炼和压延加工业", + "value": "32" + }, + { + "label": "金属制品业", + "value": "33" + }, + { + "label": "通用设备制造业", + "value": "34" + }, + { + "label": "专用设备制造业", + "value": "35" + }, + { + "label": "汽车制造业", + "value": "36" + }, + { + "label": "铁路、船舶、航空航天和其他运输设备制造业", + "value": "37" + }, + { + "label": "电气机械和器材制造业", + "value": "38" + }, + { + "label": "计算机、通信和其他电子设备制造业", + "value": "39" + }, + { + "label": "仪器仪表制造业", + "value": "40" + }, + { + "label": "其他制造业", + "value": "41" + }, + { + "label": "废弃资源综合利用业", + "value": "42" + }, + { + "label": "金属制品、机械和设备修理业", + "value": "43" + } + ] + }, + { + "label": "电力、热力、燃气及水生产和供应业", + "value": "D", + "children": [ + { + "label": "电力、热力生产和供应业", + "value": "44" + }, + { + "label": "燃气生产和供应业", + "value": "45" + }, + { + "label": "水的生产和供应业", + "value": "46" + } + ] + }, + { + "label": "建筑业", + "value": "E", + "children": [ + { + "label": "房屋建筑业", + "value": "47" + }, + { + "label": "土木工程建筑业", + "value": "48" + }, + { + "label": "建筑安装业", + "value": "49" + }, + { + "label": "建筑装饰、装修和其他建筑业", + "value": "50" + } + ] + }, + { + "label": "批发和零售业", + "value": "F", + "children": [ + { + "label": "批发业", + "value": "51" + }, + { + "label": "零售业", + "value": "52" + } + ] + }, + { + "label": "交通运输、仓储和邮政业", + "value": "G", + "children": [ + { + "label": "铁路运输业", + "value": "53" + }, + { + "label": "道路运输业", + "value": "54" + }, + { + "label": "水上运输业", + "value": "55" + }, + { + "label": "航空运输业", + "value": "56" + }, + { + "label": "管道运输业", + "value": "57" + }, + { + "label": "多式联运和运输代理业", + "value": "58" + }, + { + "label": "装卸搬运和仓储业", + "value": "59" + }, + { + "label": "邮政业", + "value": "60" + } + ] + }, + { + "label": "住宿和餐饮业", + "value": "H", + "children": [ + { + "label": "住宿业", + "value": "61" + }, + { + "label": "餐饮业", + "value": "62" + } + ] + }, + { + "label": "信息传输、软件和信息技术服务业", + "value": "I", + "children": [ + { + "label": "电信、广播电视和卫星传输服务", + "value": "63" + }, + { + "label": "互联网和相关服务", + "value": "64" + }, + { + "label": "软件和信息技术服务业", + "value": "65" + } + ] + }, + { + "label": "金融业", + "value": "J", + "children": [ + { + "label": "货币金融服务", + "value": "66" + }, + { + "label": "资本市场服务", + "value": "67" + }, + { + "label": "保险业", + "value": "68" + }, + { + "label": "其他金融业", + "value": "69" + } + ] + }, + { + "label": "房地产业", + "value": "K", + "children": [ + { + "label": "房地产业", + "value": "70" + } + ] + }, + { + "label": "租赁和商务服务业", + "value": "L", + "children": [ + { + "label": "租赁业", + "value": "71" + }, + { + "label": "商务服务业", + "value": "72" + } + ] + }, + { + "label": "科学研究和技术服务业", + "value": "M", + "children": [ + { + "label": "研究和试验发展", + "value": "73" + }, + { + "label": "专业技术服务业", + "value": "74" + }, + { + "label": "科技推广和应用服务业", + "value": "75" + } + ] + }, + { + "label": "水利、环境和公共设施管理业", + "value": "N", + "children": [ + { + "label": "水利管理业", + "value": "76" + }, + { + "label": "生态保护和环境治理业", + "value": "77" + }, + { + "label": "公共设施管理业", + "value": "78" + } + ] + }, + { + "label": "居民服务、修理和其他服务业", + "value": "O", + "children": [ + { + "label": "居民服务业", + "value": "79" + }, + { + "label": "机动车、电子产品和日用产品修理业", + "value": "80" + }, + { + "label": "其他服务业", + "value": "81" + } + ] + }, + { + "label": "教育", + "value": "P", + "children": [ + { + "label": "教育", + "value": "82" + } + ] + }, + { + "label": "卫生和社会工作", + "value": "Q", + "children": [ + { + "label": "卫生", + "value": "83" + }, + { + "label": "社会工作", + "value": "84" + } + ] + }, + { + "label": "文化、体育和娱乐业", + "value": "R", + "children": [ + { + "label": "新闻和出版业", + "value": "85" + }, + { + "label": "广播、电视、电影和录音制作业", + "value": "86" + }, + { + "label": "文化艺术业", + "value": "87" + }, + { + "label": "体育", + "value": "88" + }, + { + "label": "娱乐业", + "value": "89" + } + ] + }, + { + "label": "公共管理、社会保障和社会组织", + "value": "S", + "children": [ + { + "label": "中国共产党机关", + "value": "90" + }, + { + "label": "国家机构", + "value": "91" + }, + { + "label": "人民政协、民主党派", + "value": "92" + }, + { + "label": "社会保障", + "value": "93" + }, + { + "label": "群众团体、社会团体和其他成员组织", + "value": "94" + }, + { + "label": "基层群众自治组织", + "value": "95" + } + ] + }, + { + "label": "国际组织", + "value": "T", + "children": [ + { + "label": "国际组织", + "value": "96" + } + ] + } +] \ No newline at end of file diff --git a/backend/crm/src/main/resources/form/field.json b/backend/crm/src/main/resources/form/field.json new file mode 100644 index 0000000..3294e80 --- /dev/null +++ b/backend/crm/src/main/resources/form/field.json @@ -0,0 +1,2553 @@ +{ + "customer": [ + { + "name": "基本信息", + "internalKey": "customerBasicInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "客户名称", + "internalKey": "customerName", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + }, + { + "key": "unique" + } + ], + "mobile": true + }, + { + "name": "客户行业", + "internalKey": "customerIndustry", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "互联网", + "value": "1" + }, + { + "label": "金融", + "value": "2" + }, + { + "label": "制造业", + "value": "3" + } + ] + }, + { + "name": "客户等级", + "internalKey": "customerLevel", + "type": "SELECT", + "showLabel": true, + "defaultValue": "1", + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "重要客户", + "value": "1" + }, + { + "label": "一般客户", + "value": "2" + }, + { + "label": "战略客户", + "value": "3" + } + ] + }, + { + "name": "客户类型", + "internalKey": "customerType", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "无", + "value": "1" + }, + { + "label": "最终客户", + "value": "2" + }, + { + "label": "代理商", + "value": "3" + } + ] + }, + { + "name": "客户来源", + "internalKey": "customerSource", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "线上", + "value": "1" + }, + { + "label": "线下", + "value": "2" + }, + { + "label": "维保", + "value": "3" + }, + { + "label": "续约", + "value": "4" + }, + { + "label": "扩容", + "value": "5" + }, + { + "label": "增购", + "value": "6" + } + ], + "showControlRules": [ + { + "value": "1", + "fieldIds": [ + "customerOnlineSource" + ] + } + ] + }, + { + "name": "线上来源详情", + "internalKey": "customerOnlineSource", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "400电话", + "value": "1" + }, + { + "label": "官网咨询", + "value": "2" + }, + { + "label": "社区用户", + "value": "3" + } + ] + }, + { + "name": "客户标签", + "internalKey": "customerTag", + "type": "INPUT_MULTIPLE", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1 + }, + { + "name": "地址信息", + "internalKey": "customerAddressInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "地区", + "internalKey": "customerArea", + "type": "LOCATION", + "locationType": "detail", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1 + }, + { + "name": "负责人信息", + "internalKey": "customerOwnerInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "负责人", + "internalKey": "customerOwner", + "type": "MEMBER", + "hasCurrentUser": true, + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ] + } + ], + "clue": [ + { + "name": "基本信息", + "internalKey": "clueBasicInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "公司名称", + "internalKey": "clueName", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "线索进度", + "internalKey": "clueProgress", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "初步意向", + "value": "1" + }, + { + "label": "强烈意愿", + "value": "2" + } + ] + }, + { + "name": "线索来源", + "internalKey": "clueSource", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "线上", + "value": "1" + }, + { + "label": "电话影响", + "value": "2" + }, + { + "label": "客户介绍", + "value": "3" + }, + { + "label": "线下活动", + "value": "4" + }, + { + "label": "社区用户", + "value": "5" + }, + { + "label": "GitHub", + "value": "6" + }, + { + "label": "维保,续约,增购", + "value": "7" + }, + { + "label": "其他", + "value": "8" + } + ], + "showControlRules": [ + { + "value": "1", + "fieldIds": [ + "clueOnlineSource" + ] + } + ] + }, + { + "name": "线上来源详情", + "internalKey": "clueOnlineSource", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "400电话", + "value": "1" + }, + { + "label": "官网咨询", + "value": "2" + }, + { + "label": "社区用户", + "value": "3" + } + ] + }, + { + "name": "客户需求", + "internalKey": "clueDemand", + "type": "TEXTAREA", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1 + }, + { + "name": "联系人名称", + "internalKey": "clueContactName", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true + }, + { + "name": "联系人电话", + "internalKey": "clueContactPhone", + "type": "PHONE", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true, + "format": "255", + "rules": [ + { + "key": "unique" + } + ] + }, + { + "name": "意向产品", + "internalKey": "clueProduct", + "type": "DATA_SOURCE_MULTIPLE", + "dataSourceType": "PRODUCT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true + }, + { + "name": "地址信息", + "internalKey": "clueAddressInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "地区", + "internalKey": "clueArea", + "type": "LOCATION", + "locationType": "detail", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1 + }, + { + "name": "负责人信息", + "internalKey": "clueOwnerInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "负责人", + "internalKey": "clueOwner", + "type": "MEMBER", + "hasCurrentUser": true, + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ] + } + ], + "opportunity": [ + { + "name": "基本信息", + "internalKey": "opportunityBasicInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "商机名称", + "internalKey": "opportunityName", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "客户名称", + "internalKey": "opportunityCustomer", + "type": "DATA_SOURCE", + "dataSourceType": "CUSTOMER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "业务编码", + "internalKey": "opportunityNo", + "type": "SERIAL_NUMBER", + "placeholder": "无需录入, 自动生成", + "showLabel": true, + "serialNumberRules": [ + "Opp", + "-", + "yyyyMM", + "-", + "6" + ], + "readable": true, + "editable": false, + "fieldWidth": 1, + "disableProps": [ + "serialNumberRules" + ] + }, + { + "name": "商机来源", + "internalKey": "opportunitySource", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "电话营销", + "value": "1" + }, + { + "label": "客户介绍", + "value": "2" + }, + { + "label": "线下活动", + "value": "3" + }, + { + "label": "社区用户", + "value": "4" + }, + { + "label": "Github", + "value": "5" + }, + { + "label": "维保,续约,增购", + "value": "6" + }, + { + "label": "其他", + "value": "7" + } + ] + }, + { + "name": "金额", + "internalKey": "opportunityPrice", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "numberFormat": "number", + "showThousandsSeparator": true, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "意向产品", + "internalKey": "opportunityProduct", + "type": "DATA_SOURCE_MULTIPLE", + "dataSourceType": "PRODUCT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true + }, + { + "name": "结束时间", + "internalKey": "opportunityEndTime", + "type": "DATE_TIME", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "dateType": "date", + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "可能性", + "internalKey": "opportunityWinRate", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "numberFormat": "percent", + "showThousandsSeparator": true + }, + { + "name": "联系人", + "internalKey": "opportunityContact", + "type": "DATA_SOURCE", + "dataSourceType": "CONTACT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true + }, + { + "name": "客户标签", + "internalKey": "opportunityCustomerTag", + "type": "INPUT_MULTIPLE", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1 + }, + { + "name": "备注信息", + "internalKey": "opportunityRemark", + "type": "TEXTAREA", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1 + }, + { + "name": "地址信息", + "internalKey": "opportunityAddressInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "地区", + "internalKey": "opportunityArea", + "type": "LOCATION", + "locationType": "detail", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1 + }, + { + "name": "负责人信息", + "internalKey": "opportunityOwnerInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "负责人", + "internalKey": "opportunityOwner", + "type": "MEMBER", + "hasCurrentUser": true, + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ] + } + ], + "product": [ + { + "name": "基本信息", + "internalKey": "productBasicInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "产品名称", + "internalKey": "productName", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + }, + { + "key": "unique" + } + ] + }, + { + "name": "产品价格", + "internalKey": "productPrice", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "numberFormat": "number", + "showThousandsSeparator": true + }, + { + "name": "状态", + "internalKey": "productStatus", + "type": "RADIO", + "direction": "horizontal", + "defaultValue": "1", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "上架", + "value": "1" + }, + { + "label": "下架", + "value": "2" + } + ], + "rules": [ + { + "key": "required" + } + ] + }, + { + "name": "描述", + "internalKey": "productDescription", + "type": "TEXTAREA", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1 + }, + { + "name": "图片信息", + "internalKey": "productPicInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "产品图片", + "internalKey": "productPic", + "type": "PICTURE", + "pictureShowType": "card", + "uploadSizeLimitEnable": true, + "uploadSizeLimit": 20, + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1 + } + ], + "contact": [ + { + "name": "客户名称", + "internalKey": "contactCustomer", + "type": "DATA_SOURCE", + "dataSourceType": "CUSTOMER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "姓名", + "internalKey": "contactName", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "邮箱", + "internalKey": "contactEmail", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true + }, + { + "name": "手机号", + "internalKey": "contactPhone", + "type": "PHONE", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "format": "255", + "rules": [ + { + "key": "required" + }, + { + "key": "unique" + } + ], + "mobile": true + }, + { + "name": "负责人", + "internalKey": "contactOwner", + "type": "MEMBER", + "hasCurrentUser": true, + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ] + } + ], + "record": [ + { + "name": "跟进类型", + "internalKey": "recordType", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "客户", + "value": "CUSTOMER" + }, + { + "label": "线索", + "value": "CLUE" + } + ], + "rules": [ + { + "key": "required" + } + ], + "showControlRules": [ + { + "value": "CUSTOMER", + "fieldIds": [ + "recordCustomer", + "recordOpportunity", + "recordContact" + ] + }, + { + "value": "CLUE", + "fieldIds": [ + "recordClue" + ] + } + ], + "mobile": true + }, + { + "name": "客户名称", + "internalKey": "recordCustomer", + "type": "DATA_SOURCE", + "dataSourceType": "CUSTOMER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "公司名称", + "internalKey": "recordClue", + "type": "DATA_SOURCE", + "dataSourceType": "CLUE", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "商机", + "internalKey": "recordOpportunity", + "type": "DATA_SOURCE", + "dataSourceType": "OPPORTUNITY", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true + }, + { + "name": "联系人", + "internalKey": "recordContact", + "type": "DATA_SOURCE", + "dataSourceType": "CONTACT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "跟进方式", + "internalKey": "recordMethod", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "到访", + "value": "1" + }, + { + "label": "电话", + "value": "2" + } + ], + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "跟进时间", + "internalKey": "recordTime", + "type": "DATE_TIME", + "dateType": "date", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "负责人", + "internalKey": "recordOwner", + "type": "MEMBER", + "hasCurrentUser": true, + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "意向产品", + "internalKey": "recordProduct", + "type": "DATA_SOURCE_MULTIPLE", + "dataSourceType": "PRODUCT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1 + }, + { + "name": "跟进内容", + "internalKey": "recordDescription", + "type": "TEXTAREA", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + } + ], + "plan": [ + { + "name": "跟进类型", + "internalKey": "planType", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "客户", + "value": "CUSTOMER" + }, + { + "label": "线索", + "value": "CLUE" + } + ], + "rules": [ + { + "key": "required" + } + ], + "showControlRules": [ + { + "value": "CUSTOMER", + "fieldIds": [ + "planCustomer", + "planOpportunity", + "planContact" + ] + }, + { + "value": "CLUE", + "fieldIds": [ + "planClue" + ] + } + ], + "mobile": true + }, + { + "name": "客户名称", + "internalKey": "planCustomer", + "type": "DATA_SOURCE", + "dataSourceType": "CUSTOMER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "公司名称", + "internalKey": "planClue", + "type": "DATA_SOURCE", + "dataSourceType": "CLUE", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "商机", + "internalKey": "planOpportunity", + "type": "DATA_SOURCE", + "dataSourceType": "OPPORTUNITY", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true + }, + { + "name": "联系人", + "internalKey": "planContact", + "type": "DATA_SOURCE", + "dataSourceType": "CONTACT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "预计开始时间", + "internalKey": "planStartTime", + "type": "DATE_TIME", + "dateType": "date", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "跟进方式", + "internalKey": "planMethod", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "到访", + "value": "1" + }, + { + "label": "电话", + "value": "2" + } + ], + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "负责人", + "internalKey": "planOwner", + "type": "MEMBER", + "hasCurrentUser": true, + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ] + }, + { + "name": "意向产品", + "internalKey": "planProduct", + "type": "DATA_SOURCE_MULTIPLE", + "dataSourceType": "PRODUCT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1 + }, + { + "name": "预计沟通内容", + "internalKey": "planContent", + "type": "TEXTAREA", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + } + ], + "price": [ + { + "name": "基本信息", + "internalKey": "priceBasicInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "价格表名称", + "internalKey": "priceName", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + }, + { + "key": "unique" + } + ], + "mobile": true + }, + { + "name": "状态", + "internalKey": "priceStatus", + "type": "RADIO", + "direction": "horizontal", + "defaultValue": "1", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "启用", + "value": "1" + }, + { + "label": "禁用", + "value": "2" + } + ], + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "产品信息", + "internalKey": "priceProducts", + "type": "SUB_PRODUCT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true, + "fixedColumn": 1, + "subFields": [ + { + "name": "产品", + "internalKey": "priceProduct", + "type": "DATA_SOURCE", + "dataSourceType": "PRODUCT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "产品SKU", + "internalKey": "priceProductSku", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1 + }, + { + "name": "产品定价", + "internalKey": "priceProductAmount", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "numberFormat": "number", + "decimalPlaces": true, + "precision": 2, + "showThousandsSeparator": true, + "mobile": true, + "rules": [ + { + "key": "required" + } + ] + }, + { + "name": "税点", + "internalKey": "priceProductTax", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "numberFormat": "percent", + "decimalPlaces": true, + "precision": 2 + }, + { + "name": "重要说明", + "internalKey": "priceProductSku", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1 + } + ] + }, + { + "name": "备注", + "internalKey": "priceRemark", + "type": "TEXTAREA", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true + } + ], + "quotation": [ + { + "name": "基本信息", + "internalKey": "quotationBasicInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "报价", + "internalKey": "quotationName", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + }, + { + "key": "unique" + } + ], + "mobile": true + }, + { + "name": "商机", + "internalKey": "quotationOpportunity", + "type": "DATA_SOURCE", + "dataSourceType": "OPPORTUNITY", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "联系人", + "internalKey": "quotationContact", + "type": "DATA_SOURCE", + "dataSourceType": "CONTACT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true + }, + { + "name": "报价日期", + "internalKey": "quotationTime", + "type": "DATE_TIME", + "dateType": "date", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true + }, + { + "name": "有效期至", + "internalKey": "quotationUntilTime", + "type": "DATE_TIME", + "dateType": "date", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true, + "rules": [ + { + "key": "required" + } + ] + }, + { + "name": "报价产品", + "internalKey": "quotationProducts", + "type": "SUB_PRICE", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "sumColumns": [ + "amount" + ], + "fixedColumn": 1, + "mobile": true, + "subFields": [ + { + "name": "产品", + "internalKey": "quotationProduct", + "type": "DATA_SOURCE", + "dataSourceType": "PRODUCT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true, + "rules": [ + { + "key": "required" + } + ], + "disabledProps": [ + "dataSourceType", + "rules.required" + ] + }, + { + "name": "价格表", + "internalKey": "quotationPrice", + "type": "DATA_SOURCE", + "dataSourceType": "PRICE", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true, + "disabledProps": [ + "dataSourceType" + ] + }, + { + "name": "产品定价", + "internalKey": "quotationProductAmount", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "numberFormat": "number", + "decimalPlaces": true, + "precision": 2, + "showThousandsSeparator": true + }, + { + "name": "折扣", + "internalKey": "quotationDiscount", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "numberFormat": "number", + "decimalPlaces": true, + "precision": 2 + }, + { + "name": "税点", + "internalKey": "quotationTax", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "numberFormat": "percent", + "decimalPlaces": true, + "precision": 2 + }, + { + "name": "金额", + "internalKey": "quotationAmount", + "type": "FORMULA", + "showLabel": true, + "readable": true, + "editable": false, + "fieldWidth": 1, + "mobile": true, + "showThousandsSeparator": true + } + ] + }, + { + "name": "累计金额", + "internalKey": "quotationTotalAmount", + "type": "FORMULA", + "showLabel": true, + "readable": true, + "editable": false, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + }, + { + "key": "unique" + } + ], + "mobile": true, + "showThousandsSeparator": true, + "ext_ver": "1.5.1" + } + ], + "contractPaymentPlan": [ + { + "name": "回款计划名称", + "internalKey": "contractPaymentPlanName", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true, + "ext_ver": "1.5.0" + }, + { + "name": "合同", + "internalKey": "contractPaymentPlanContract", + "type": "DATA_SOURCE", + "dataSourceType": "CONTRACT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "负责人", + "internalKey": "contractPaymentPlanOwner", + "type": "MEMBER", + "hasCurrentUser": true, + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "计划回款金额", + "internalKey": "contractPaymentPlanPlanAmount", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "numberFormat": "number", + "showThousandsSeparator": true, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "计划回款时间", + "internalKey": "contractPaymentPlanPlanEndTime", + "type": "DATE_TIME", + "dateType": "date", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + } + ], + "contract": [ + { + "name": "基本信息", + "internalKey": "contractBasicInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "合同", + "internalKey": "contractName", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true, + "rules": [ + { + "key": "required" + }, + { + "key": "unique" + } + ] + }, + { + "name": "客户名称", + "internalKey": "contractCustomer", + "type": "DATA_SOURCE", + "dataSourceType": "CUSTOMER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "合同编号", + "internalKey": "contractNo", + "type": "SERIAL_NUMBER", + "placeholder": "无需录入, 自动生成", + "showLabel": true, + "serialNumberRules": [ + "Con", + "-", + "yyyyMM", + "-", + "6" + ], + "readable": true, + "editable": false, + "fieldWidth": 1, + "mobile": true, + "disableProps": [ + "serialNumberRules" + ] + }, + { + "name": "合同报价信息", + "internalKey": "contractProducts", + "type": "SUB_PRODUCT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true, + "fixedColumn": 1, + "sumColumns": [ + "amount" + ], + "subFields": [ + { + "name": "产品", + "internalKey": "contractProduct", + "type": "DATA_SOURCE", + "dataSourceType": "PRODUCT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "产品单价", + "internalKey": "contractProductAmount", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "numberFormat": "number", + "decimalPlaces": true, + "precision": 2, + "showThousandsSeparator": true, + "mobile": true, + "rules": [ + { + "key": "required" + } + ] + }, + { + "name": "数量", + "internalKey": "contractProductNumber", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true, + "numberFormat": "number" + }, + { + "name": "金额", + "internalKey": "contractProductSumAmount", + "type": "FORMULA", + "showLabel": true, + "readable": true, + "mobile": true, + "editable": false, + "fieldWidth": 1, + "showThousandsSeparator": true + } + ] + }, + { + "name": "负责人", + "internalKey": "contractOwner", + "type": "MEMBER", + "hasCurrentUser": true, + "showLabel": true, + "readable": true, + "editable": true, + "mobile": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ] + }, + { + "name": "合同开始时间", + "internalKey": "contractStartTime", + "type": "DATE_TIME", + "dateType": "date", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true, + "ext_ver": "1.5.0" + }, + { + "name": "合同结束时间", + "internalKey": "contractEndTime", + "type": "DATE_TIME", + "dateType": "date", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true, + "ext_ver": "1.5.0" + }, + { + "name": "累计金额", + "internalKey": "contractTotalAmount", + "type": "FORMULA", + "showLabel": true, + "readable": true, + "editable": false, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + }, + { + "key": "unique" + } + ], + "mobile": true, + "showThousandsSeparator": true, + "ext_ver": "1.5.1" + } + ], + "invoice": [ + { + "name": "基本信息", + "internalKey": "invoiceBasicInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "发票名称", + "internalKey": "invoiceName", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "合同信息", + "internalKey": "invoiceContractBasicInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "合同名称", + "internalKey": "invoiceContract", + "type": "DATA_SOURCE", + "dataSourceType": "CONTRACT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true, + "showFields": [ + "contractCustomer", + "contractNo", + "contractTotalAmount" + ] + }, + { + "name": "发票信息", + "internalKey": "invoiceBasicInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "开票类型", + "internalKey": "invoiceType", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "mobile": true, + "fieldWidth": 1, + "options": [ + { + "label": "专票", + "value": "1" + }, + { + "label": "普票", + "value": "2" + } + ], + "rules": [ + { + "key": "required" + } + ] + }, + { + "name": "开票项目名称", + "internalKey": "invoiceProjectName", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "mobile": true, + "fieldWidth": 1, + "options": [ + { + "label": "产品1", + "value": "1" + }, + { + "label": "产品2", + "value": "2" + }, + { + "label": "产品3", + "value": "3" + } + ], + "rules": [ + { + "key": "required" + } + ] + }, + { + "name": "规格型号", + "internalKey": "invoiceSpecification", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "mobile": true, + "fieldWidth": 1, + "options": [ + { + "label": "sku1", + "value": "1" + }, + { + "label": "sku2", + "value": "2" + }, + { + "label": "sku3", + "value": "3" + } + ], + "rules": [ + { + "key": "required" + } + ] + }, + { + "name": "单位", + "internalKey": "invoiceUnit", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "mobile": true, + "fieldWidth": 1, + "options": [ + { + "label": "个", + "value": "1" + }, + { + "label": "套", + "value": "2" + }, + { + "label": "年", + "value": "3" + } + ], + "rules": [ + { + "key": "required" + } + ] + }, + { + "name": "数量", + "internalKey": "invoiceQuantity", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true, + "numberFormat": "number", + "rules": [ + { + "key": "required" + } + ] + }, + { + "name": "税率", + "internalKey": "invoiceTaxRate", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "mobile": true, + "fieldWidth": 1, + "numberFormat": "percent", + "rules": [ + { + "key": "required" + } + ] + }, + { + "name": "工商抬头", + "internalKey": "invoiceBusinessTitle", + "type": "DATA_SOURCE", + "dataSourceType": "BUSINESS_TITLE", + "showLabel": true, + "readable": true, + "editable": true, + "mobile": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "showFields": [ + "identificationNumber" + ] + }, + { + "name": "发票金额", + "internalKey": "invoiceAmount", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "numberFormat": "number", + "showThousandsSeparator": true, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "负责人", + "internalKey": "invoiceOwner", + "type": "MEMBER", + "hasCurrentUser": true, + "showLabel": true, + "readable": true, + "editable": true, + "mobile": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ] + } + ], + "contractPaymentRecord": [ + { + "name": "基本信息", + "internalKey": "contractPaymentRecordBasicInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "回款记录名称", + "internalKey": "contractPaymentRecordName", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "回款编码", + "internalKey": "contractPaymentRecordNo", + "type": "SERIAL_NUMBER", + "placeholder": "无需录入, 自动生成", + "showLabel": true, + "serialNumberRules": [ + "PAY", + "-", + "yyyyMM", + "-", + "6" + ], + "rules": [ + { + "key": "required" + } + ], + "readable": true, + "editable": false, + "fieldWidth": 1, + "mobile": true + }, + { + "name": "合同名称", + "internalKey": "contractPaymentRecordContract", + "type": "DATA_SOURCE", + "dataSourceType": "CONTRACT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "回款计划", + "internalKey": "contractPaymentRecordPlan", + "type": "DATA_SOURCE", + "dataSourceType": "PAYMENT_PLAN", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true + }, + { + "name": "负责人", + "internalKey": "contractPaymentRecordOwner", + "type": "MEMBER", + "hasCurrentUser": true, + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "回款信息", + "internalKey": "contractPaymentRecordInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "回款时间", + "internalKey": "contractPaymentRecordEndTime", + "type": "DATE_TIME", + "dateType": "date", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "回款金额", + "internalKey": "contractPaymentRecordAmount", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "numberFormat": "number", + "decimalPlaces": true, + "precision": 2, + "showThousandsSeparator": true, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "收款银行", + "internalKey": "contractPaymentRecordBank", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "中国银行", + "value": "1" + }, + { + "label": "中国农业银行", + "value": "2" + }, + { + "label": "中国工商银行", + "value": "3" + }, + { + "label": "中国建设银行", + "value": "4" + } + ], + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "收款银行账号", + "internalKey": "contractPaymentRecordBankNo", + "type": "SELECT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "options": [ + { + "label": "银行账号1", + "value": "1" + }, + { + "label": "银行账号2", + "value": "2" + }, + { + "label": "银行账号3", + "value": "3" + } + ], + "rules": [ + { + "key": "required" + } + ], + "mobile": true + } + ], + "order": [ + { + "name": "基本信息", + "internalKey": "orderBasicInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "订单编号", + "internalKey": "orderNo", + "type": "SERIAL_NUMBER", + "placeholder": "无需录入, 自动生成", + "showLabel": true, + "serialNumberRules": [ + "XSDD", + "-", + "yyyyMM", + "-", + "6" + ], + "readable": true, + "editable": false, + "fieldWidth": 1, + "mobile": true, + "disableProps": [ + "serialNumberRules" + ] + }, + { + "name": "关联客户", + "internalKey": "orderCustomer", + "type": "DATA_SOURCE", + "dataSourceType": "CUSTOMER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true + }, + { + "name": "关联合同", + "internalKey": "orderContract", + "type": "DATA_SOURCE", + "dataSourceType": "CONTRACT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true + }, + { + "name": "订单名称", + "internalKey": "orderName", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true, + "rules": [ + { + "key": "required" + }, + { + "key": "unique" + } + ] + }, + { + "name": "负责人", + "internalKey": "orderOwner", + "type": "MEMBER", + "hasCurrentUser": true, + "showLabel": true, + "readable": true, + "editable": true, + "mobile": true, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ] + }, + { + "name": "销售产品信息", + "internalKey": "orderProductInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "产品明细", + "internalKey": "orderProducts", + "type": "SUB_PRODUCT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true, + "fixedColumn": 1, + "sumColumns": [], + "subFields": [ + { + "name": "产品名称", + "internalKey": "orderProduct", + "type": "DATA_SOURCE", + "dataSourceType": "PRODUCT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true + }, + { + "name": "产品单价", + "internalKey": "orderProductPrice", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "numberFormat": "number", + "decimalPlaces": true, + "precision": 2, + "showThousandsSeparator": true, + "mobile": true + }, + { + "name": "数量", + "internalKey": "orderProductNumber", + "type": "INPUT_NUMBER", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true, + "numberFormat": "number" + }, + { + "name": "金额", + "internalKey": "orderProductAmount", + "type": "FORMULA", + "showLabel": true, + "readable": true, + "mobile": true, + "editable": false, + "fieldWidth": 1, + "showThousandsSeparator": true + } + ] + }, + { + "name": "订单金额", + "internalKey": "orderAmount", + "type": "FORMULA", + "showLabel": true, + "readable": true, + "editable": false, + "fieldWidth": 1, + "rules": [ + { + "key": "required" + } + ], + "mobile": true, + "showThousandsSeparator": true + }, + { + "name": "订单收货信息", + "internalKey": "orderReceivedInfo", + "type": "DIVIDER", + "showLabel": true, + "readable": true, + "fieldWidth": 1, + "dividerClass": "divider--normal", + "dividerColor": "#edf0f1", + "titleColor": "#323535" + }, + { + "name": "收货地址", + "internalKey": "orderDeliveryAddress", + "type": "LOCATION", + "locationType": "PCD", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1 + }, + { + "name": "收货人", + "internalKey": "orderConsignee", + "type": "INPUT", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true + }, + { + "name": "收货人联系方式", + "internalKey": "orderPhone", + "type": "PHONE", + "showLabel": true, + "readable": true, + "editable": true, + "fieldWidth": 1, + "mobile": true, + "format": "11" + } + ] +} \ No newline at end of file diff --git a/backend/crm/src/main/resources/form/form.json b/backend/crm/src/main/resources/form/form.json new file mode 100644 index 0000000..76d06e6 --- /dev/null +++ b/backend/crm/src/main/resources/form/form.json @@ -0,0 +1,8 @@ +{ + "layout": 1, + "labelPos": "top", + "inputWidth": "custom", + "optBtnPos": "flex-row", + "viewSize": "small", + "optBtnContent": [{"text": "保存", "enable": true}, {"text": "保存并继续添加", "enable": false}, {"text": "取消", "enable": true}] +} \ No newline at end of file diff --git a/backend/crm/src/main/resources/i18n/cordys-crm_en_US.properties b/backend/crm/src/main/resources/i18n/cordys-crm_en_US.properties new file mode 100644 index 0000000..fff2ff0 --- /dev/null +++ b/backend/crm/src/main/resources/i18n/cordys-crm_en_US.properties @@ -0,0 +1,985 @@ +# cordys en +message_task.event.length_range=Notification event type length must be between 1-255 +message_task.event.not_blank=Notification event type cannot be blank +message_task.receivers.length_range=Receiver ID list length must be between 1-1000 +message_task.receivers.not_blank=Receiver ID list cannot be blank +message_task.task_type.length_range=Task type length must be between 1-64 +message_task.task_type.not_blank=Task type cannot be blank +message_task.enable.not_blank=Enable value cannot be blank +message_task.use_default_template.not_blank=Default template flag cannot be blank +message_task.use_default_subject.not_blank=Default subject flag cannot be blank +message_task_blob.id.not_blank=Message notification task ID cannot be blank +message_task_blob.id.length_range=Message notification task ID length must be between 1-32 +announcement.subject.length_range=Announcement subject length must be between 1-255 +announcement.subject.not_blank=Announcement subject cannot be blank +announcement.content.not_blank=Announcement content cannot be blank +announcement.start_time.not_blank=Start time cannot be blank +announcement.end_time.not_blank=End time cannot be blank +announcement.organization_id.length_range=Receiver length must be between 1-50 +announcement.organization_id.not_blank=Receiver cannot be blank +notification.type.length_range=Notification type length must be between 1-255 +notification.type.not_blank=Notification type cannot be blank +notification.receiver.length_range=Receiver length must be between 1-50 +notification.receiver.not_blank=Receiver cannot be blank +notification.subject.length_range=Subject length must be between 1-255 +notification.subject.not_blank=Subject cannot be blank +notification.status.length_range=Status length must be between 1-64 +notification.status.not_blank=Status cannot be blank +notification.operator.length_range=Operator length must be between 1-50 +notification.operator.not_blank=Operator cannot be blank +notification.operation.length_range=Operation length must be between 1-50 +notification.operation.not_blank=Operation cannot be blank +notification.resource_type.length_range=Resource type length must be between 1-64 +notification.resource_type.not_blank=Resource type cannot be blank +notification.resource_name.length_range=Resource name length must be between 1-255 +notification.resource_name.not_blank=Resource name cannot be blank +notification.content.not_blank=Notification content cannot be blank +startTime_must_be_less_than_endTime=Start date must be earlier than end date +data_parsing_exception=Data parsing exception +organization_id_not_exist=No organization information for current user +department.name.length_range=Name length must be between 1-255 +department.name.not_blank=Name cannot be blank +department.parent.id.not_blank=Parent department ID cannot be blank +department.id.not_blank=Department ID cannot be blank +department.blank=Department does not exist +department.commander.id.not_blank=Department leader ID cannot be blank +email.exist=Email already exists +phone.exist=Phone number already exists +email.format_error=Invalid email format +code_not_exist=Authorization code is empty +phone.repeat=Phone number is duplicate +email.repeat=Email is duplicate +#personal +email_setting_subject=Reset Cordys account password +email_setting_content=You are resetting your Cordys password, verification code:

${code}

The code is valid for 10 minutes, please do not disclose it;

Cordys Team.

+email_setting_reset_error=Please do not request verification code frequently +email_setting_send_error=Failed to send email +email_setting_verify_success=Verification successful +email_setting_verify_error=Verification code is incorrect or expired +#org_setting +ui.setting=UI settings +announcement.blank=Announcement does not exist +email.update.error.id.wrong=Email update failed, incorrect primary key +email.setting=Email settings +email.config.not.exist.text=Please configure email settings first +email.config.is.null=Email configuration is empty +email.connection.failed=Connection failed +sync.organization.update.error.id.wrong=Sync organization update failed, incorrect primary key +sync.organization.type.exist=This organization already has this type of configuration +third.setting=Third-party settings +third.test.open.error=Failed to enable verification, please check configuration +sync.organization.http.error=HttpClient query failed +sync.organization.test.error=Connection test failed +third.config.not.exist=No configuration for this application type +third.config.un.enable=Application is disabled +auth.source.blank=Authentication information does not exist +auth.source.name.exists=Authentication name is duplicate +auth.source.configuration.blank=Authentication configuration is empty +auth.source.type.exists=Authentication type is duplicate +auth.id.not_blank=Authentication ID cannot be blank +auth.name.not_blank=Authentication name cannot be blank +auth.setting.no.exists=Authentication information not configured +auth.get.user.error=Failed to get user information +auth.login.un_enable=User is disabled +oauth.mapping.config.error=OAuth2 attribute mapping configuration error +oauth.mapping.value.null=OAuth2 user attribute mapping field is empty +auth.token.error=Failed to get access_token +auth.get.token.error=Failed to call interface to get access_token +auth.get.token.res.error=Failed to get access_token interface response +auth.get.user.res.error=Failed to get user identity interface response +# system module +home=Home +customer=Account +customer_pool=Account Pool +clue=Lead +clue_pool=Lead Pool +business=Opportunity +opportunity=Opportunity +data=Data +product=Product +contact=Contact +record=Follow-up record +plan=Follow-up plan +dashboard=Dashboard +setting=Settings +agent=Agent +tender=Tender +customForm=Custom Form +contractPaymentRecord=Payment record +task=Todo List +module.not_exist=Module does not exist +module.id.not_blank=Module ID cannot be blank +module.id.length_range=Module ID length must be between 1-32 +module.switch=Module switch +module.main.nav=Main navigation +module.form.not_exist=Module form does not exist +module.form.business_field.deleted=Opportunity field in form cannot be deleted +module.form.fields.repeat=Form field duplication +module.form.fields.option.repeat=Field {0} option is repeated +module.field.not_exist=Field does not exist +module.field.not_match_type=Field type mismatch +module.form.illegal.unique.check=Invalid form revalidation +module.form.illegal=Invalid form key +module.has.closed=Module is closed +module.clue.pool.setting=Lead pool settings +module.customer.pool.setting=Account pool settings +module.opportunity.rule.setting=Opportunity close rule +module.customer.capacity.setting=Account capacity settings +module.clue.capacity.setting=Lead capacity settings +module.form.setting=Form settings +module.reason.setting=Reason settings +module.opportunity_fail_rs=Opportunity failed +module.customer_pool_rs=Moved to account pool +module.clue_pool_rs=Moved to lead pool +module.quotation_approval=Quotation approval +module.contract_approval=Contract approval +module.invoice_approval=Invoice approval +module.resource_type.quotation=Quotation +module.resource_type.contract=Contract +module.resource_type.invoice=Invoice +module.resource_type.order=Order +approval.delete.resource=Delete {0}-{1} +approval.update.field=[{0}] updated to: {1} +module.desensitization_set=Desensitization set +log.sort=Sort +log.placeholder=Input placeholder +log.clue.link=Link Clue +log.customer.link=Link Customer +log.opportunity.link=Link Opportunity +log.plan.link=Link Follow Plan +log.contract.link=Link Contract +log.fields=Field configuration (TBD) +log.form.prop=Form configuration (TBD) +log.form.name=Form name +log.merge=Merge +clue_to_customer=Lead to Account +clue_to_contact=Lead to Contact +clue_to_opportunity=Lead to Opportunity +customer_to_opportunity=Account to Opportunity +clue_to_record=Write Record +customer_to_record=Write Record +opportunity_to_record=Write Record +plan_to_record=Write Record +contract_to_invoice=Create Invoice +contract_to_order=Create Order +# permission name +permission.system.name=System +permission.system.role.name=Role +permission.system.notice.name=Notification +permission.system.license=License +permission.system.setting.name=Enterprise +permission.process_setting.name=Process setting +permission.operation.log=Logs +permission.customer_management.name=Account +permission.customer_management.customer.name=Account +permission.customer_management.customer.recycle=Move to account pool +permission.customer_management.pool.name=Account pool +permission.clue_management.pool.name=Lead pool +permission.customer_management.pool.pick=Pick +permission.customer_management.pool.assign=Assign +permission.system.organization.name=Organization +permission.system.module_setting.name=Module +permission.customer.customer_contact.name=Contact +permission.clue_management.name=Lead +permission.clue_management.clue.name=Lead +permission.clue_management.clue.recycle=Move to lead pool +permission.clue_management.clue.resign=Resign +permission.opportunity_management.name=Opportunity +permission.opportunity_management.opportunity.name=Opportunity +permission.opportunity_management.opportunity_quotation.name=Quotation +permission.opportunity_management.opportunity_quotation.download=Download +permission.product_management.name=Product +permission.product_management.product.name=Product +permission.product_management.price.name=Price +permission.dashboard_management.name=Dashboard +permission.dashboard.name=Dashboard +permission.personal_info.name=Personal Info +permission.api_key.name=API Keys +no.operation.permission=No operation permission +permission.contract_management.name=Contract +permission.contract_payment_plan.name=Contract Payment Plan +permission.contract_payment_record.name=Contract Payment Record +permission.contract.name=Contract +permission.business_title.name=Business Title +permission.opportunity_quotation.name=Quotation +# permission operate +permission.read=View +permission.add=Add +permission.update=Edit +permission.edit=Edit +permission.delete=Delete +permission.transfer=Transfer +permission.import=Import +permission.export=Export +permission.download=Download +permission.recover=Recover +permission.pick=Pick +permission.assign=Assign +permission.merge=Merge +permission.archive=Archive +permission.voided=Void +permission.review=Review +permission.approve=Approve +permission.stage=Contract stage change +permission.payment=Payment +permission.order_management.name=Order management +permission.order.name=Order +# validation +enum_value_valid_message=Invalid enum value, must be +# organization +organization.config.organization_id_not_null=Organization ID cannot be blank +organization.config.type_not_null=Type cannot be blank +organization.config.detail_type_not_null=Detail type cannot be blank +organization.config.content_not_null=Content cannot be blank +organization.config.detail_id_not_null=Detail ID cannot be blank +# common +organization_id.not_blank=Organization ID cannot be blank! +organization_id.length_range=Organization ID length must be between {min}-{max}! +# clue or clue pool +clue_not_exist=The clues don't exist! +clue_ids_not_empty=Clues are not checked! +clue_pool_not_exist=Lead pool does not exist! +clue_pool_access_fail=Access failed, not a lead pool admin! +clue_pool_related=Lead pool has unassigned leads, deletion forbidden! +scope_id.not_blank=Scope ID cannot be blank! +scope_id.length_range=Scope ID length must be between {min}-{max}! +clue_capacity_not_exist=Lead capacity rule does not exist! +clue_owner_not_exist=Lead owner does not exist! +transform.miss.customer.permission=Missing permission to add customer! +transform.miss.opportunity.permission=Miss opportunity add permission! +transform.customer.error=Failed to transform customer, please check the log for details! +transform.opportunity.error=Failed to transform opportunity, please check the log for details! +#message +message.create_user=Creator +message.follow_people=Follower +message.operator=Operator +message.owner=Owner +message.handle_user=Handler (Handler on third-party platform, will not receive notification) +message.customer=Account +message.approval=Approval +message.clue=Lead +message.business=Opportunity +message.contract=Contract +message.announcement=Announcement +message.system=System +message.customer_add=New Account +message.customer_concat_add=New Concat +message.customer_collaboration_add=New Collaboration +message.customer_transferred_customer=Transferred Account +message.customer_automatic_move_high_seas=Account automatically moved to pool (by time) +message.customer_moved_high_seas=Account passively moved to pool +message.customer_deleted=Account deleted +message.high_seas_customer_distributed=Pool account assigned +message.customer_follow_up_plan_due=Follow-up plan due +message.clue_add=Create new lead +message.clue_automatic_move_pool=Lead automatically moved to pool +message.clue_moved_pool=Lead passively moved to pool +message.clue_convert_customer=Converted to account +message.clue_convert_business=Converted to opportunity +message.transfer_clue=Transferred lead +message.clue_deleted=Lead deleted +message.clue_distributed=Lead assigned +message.clue_import=Lead imported +message.clue_follow_up_plan_due=Lead follow-up plan due +message.business_add=Opportunity created +message.business_deleted=Opportunity deleted +message.business_transfer=Opportunity transferred +message.business_import=Opportunity imported +message.contract_add=New contract +message.contract_delete=Contract deleted +message.contract_archived=Contract archived +message.contract_void=Contract voided +message.contract_expired=Contract expired +message.contract_expiring=Contract expiring +message.contract_payment_plan_add=New payment plan +message.contract_payment_plan_delete=Payment plan deleted +message.business_follow_up_plan_due=Opportunity follow-up plan due +message.sync_organization_structure=Sync organization structure +message.business_quotation_approval=Opportunity quotation approved +message.business_quotation_deleted=Opportunity quotation deleted +message.business_quotation_expired=Opportunity quotation expired +message.business_quotation_expiring=Opportunity quotation expiring +message.customer_add_text=Attention! ${OPERATOR} created a new account ${name} for you, please be informed! +message.customer_concat_add_text=Attention! ${OPERATOR} created a new contact ${name} for you, please be informed! +message.customer_collaboration_add_text=Attention! ${name} becomes a collaborator for this account collaboration, please be informed! +message.customer_transferred_customer_text=Attention! ${OPERATOR} transferred account ${name} to you, please be informed! +message.customer_automatic_move_high_seas_text=Attention! According to system rules, your account ${name} has been moved to the pool, please be informed! +message.customer_moved_high_seas_text=Attention! ${OPERATOR} has moved your account ${name} to the pool, please be informed! +message.customer_deleted_text=Attention! Your account ${name} has been deleted by ${OPERATOR}! +message.high_seas_customer_distributed_text=Attention! ${name} has been assigned to you from the pool, please follow up promptly! +message.customer_follow_up_plan_due_text=Attention! The follow-up plan you created for ${name} is due, please follow up promptly! +message.clue_add_text=Attention! ${OPERATOR} created a new lead ${name} for you, please be informed! +message.clue_automatic_move_pool_text=Attention! According to system rules, your lead ${name} has been moved to the lead pool! +message.clue_moved_pool_text=Attention! ${OPERATOR} has moved your lead ${name} to the lead pool, please be informed! +message.clue_convert_customer_text=Attention\uFF01Your lead ${name} has been converted to account\uFF01please be informed! +message.clue_convert_business_text=Attention\uFF01Your lead ${name} has been converted to opportunity\uFF01please be informed! +message.customer.collaboration.add.text=Attention\uFF01 ${operator} adds ${uName} as your collaborator responsible for account {name}! please be informed! +message.customer.contact.add.text=Attention\uFF01 ${operator} creates a ${cName} contact for you, please be informed! +message.clue_relate_customer=Attention! Your lead ${name} is associated with account {customerName}! +message.transfer_clue_text=Attention! ${OPERATOR} transferred lead ${name} to you, please be informed! +message.clue_deleted_text=Attention! Your lead ${name} has been deleted by ${OPERATOR}! +message.clue_distributed_text=Attention! ${name} has been assigned to you from the lead pool, please follow up promptly! +message.clue_import_text=Attention! ${OPERATOR} has successfully imported ${count} leads, please claim them promptly! +message.clue_follow_up_plan_due_text=Attention! The follow-up plan you created for lead ${name} is due, please follow up promptly! +message.business_add_text=Attention! ${OPERATOR} created a new opportunity ${name} for you, please be informed! +message.business_deleted_text=Attention! Your opportunity ${name} has been deleted by ${OPERATOR}! +message.business_transfer_text=Attention! ${OPERATOR} transferred opportunity ${name} to you, please be informed! +message.business_import_text=Attention! ${OPERATOR} has successfully imported ${count} leads, please claim them promptly! +message.business_follow_up_plan_due_text=Attention! The follow-up plan you created for opportunity ${name} is due, please follow up promptly! +message.business_quotation_approval_text=${OPERATOR} approved ${name} quotation, the approval result is ${state} +message.business_quotation_deleted_text=${OPERATOR} deleted ${name} quotation +message.business_quotation_expiring_text=Your ${customerName} quotation is expiring in ${expireDays} days +message.business_quotation_expired_text=Your ${customerName} quotation has expired +message.contract_archived_text=Your ${customerName} contract has been archived +message.contract_void_text=Your ${customerName} contract has been voided +message.contract_expiring_text=Your ${customerName} contract is expiring in ${expireDays} days +message.contract_expired_text=Your ${customerName} contract has expired +message.contract_payment_expiring_text=Your ${customerName} contract payment plan is expiring in ${expireDays} days +message.contract_payment_expired_text=Your ${customerName} contract payment plan has expired +approval_reject={0} submitted {1} {2} but the approval has been rejected\u3002 +message.sync_organization_structure_text=System synchronized organization structure, please check! +message.contract_approval_text=${OPERATOR} reviewed ${name} contract, result is ${state} +message.order_approval_text=${OPERATOR} reviewed ${name} order, result is ${state} +message.invoice_approval_text=${OPERATOR} reviewed ${name} invoice, result is ${state} +message.approval_todo_text={OPERATOR} reviewed {type}: {name}, please approve! +message.contract_approval=contract approval +message.invoice_approval=invoice approval +message.order_approval=order approval +message.order=order +message_task.organization_id.not_blank=Organization ID cannot be blank +message_task.module.not_blank=Module cannot be blank +message_task.receivers.not_empty=Receiver cannot be blank +notice.default.subject=System Notification +notice.event.subject=Notification +#time +time.second=Second +time.minute=Minute +time.hour=Hour +time.day=Day +time.week=Week +time.month=Month +time.year=Year +#product +product.exist=Product already exists +product.shelves=On shelves +product.unShelves=Off shelves +product.name=Product name +product.price=Product price +product.status=Product status +product.id.empty=Product ID cannot be blank +product.price.min=Price must be greater than 0 +product.price.max=Price must be less than 10000000000 +product.length=Number of products cannot exceed 20 +#produce price +product.price.not.exist=Product price does not exist +product.price.import_tpl.name=Price_Import_Template +product.price.in.use.cannot.delete=The price list is linked to existing quotations and cannot be deleted\uFF01 +product.price.name.required=Price cannot be null +product.price.status=Price status cannot be null +# role +role.org_admin=Administrator +role.sales_manager=Sales Manager +role.sales_staff=Sales Staff +role.exist=Role already exists +role.not_exist=Role does not exist +internal.role.permission.error=Cannot operate on built-in role +permission.role.add_user=Add user +permission.role.remove_user=Remove user +role.add_user=Role {0} added user {1} +role.remove_user=Role {0} removed user {1} +role.log.dept.name=Specified department data +role.log.permission.name=Permission configuration +role.data_permission.all=All data +role.data_permission.dept_custom=Specified department data +role.data_permission.dept_and_child=Department and sub-department data +role.data_permission.self=Own data only +# customer or customer pool +customer_pool_not_exist=Account pool does not exist! +customer_pool_access_fail=Access failed, not a account pool admin! +customer_pool_related=Account pool has unassigned leads, deletion forbidden! +customer_capacity_not_exist=Account capacity rule does not exist! +customer_rule_condition_error=Parsing failed, invalid recovery rule format! +customer.relation.target_customer.exist=Account {0} already belongs to a group! +no.customer.merge.data=There is no customer data to merge! +password_is_incorrect=Invalid username or password +department_comment=XX Company/XX Division/R&D Department +employee_type_comment=Employee type: Regular/Intern/Outsourcing +user_import_template_name=User import template +user_import_template_sheet=Template +required=Required +file_cannot_be_null=File cannot be blank +check_import_excel_error=Validation failed, please check if the file is correct +user_import_table_header_missing=Header information missing +name_length=Name length cannot exceed 255 characters +phone_length=Phone number length cannot exceed 20 characters +email_format_error=Invalid email format +top_department_not_exist=Top-level department not entered +cannot_be_null=Cannot be blank +number=No. +row=Row +error=Error +department_create_fail=Failed to create department +man=Male +woman=Female +department_name_exist=Department name already exists +invalid_parameter=Invalid parameter +drag_node.not.exist=Drag node does not exist +formal=Regular +internship=Intern +outsourcing=Outsourcing +user_resource_exist=Employee has untransferred account resources, cannot be deleted +permission.organization.sync=Sync +permission.organization.user.reset_password=Reset password +department.internal=Built-in department cannot be deleted +department.employees.exist=Department has employees, deletion forbidden! +import_phone_validate=Invalid phone number +employee_length=Employee number length cannot exceed 255 characters +position_length=Position length cannot exceed 255 characters +products_info=Product information +# customer +customer.exist=Account already exists +customer.not.exist=Account does not exist +customer.capacity.over=Insufficient capacity for responsible person, remaining {0}, please contact admin! +capacity.scope.duplicate=Duplicate users in rule! +capacity.not.exist=Capacity rule does not exist! +customer.daily.pick.over=Exceeded daily pick limit! +customer.pre_owner.pick.limit=Previous owner pick day limit, pick after {0}! +customer.to.pool=Account {0} moved to pool {1} +customer.ref_resource.exist=Associated opportunity or contact exists, cannot delete! +customer.pool.occupied={0} has been occupied by another user, please refresh the page and try again! +customer.import_tpl.name=Customer_Import_Template +# clue +clue.exist=Lead already exists +clue.not.exist=Lead does not exist +clue.pool.occupied={0} has been occupied by another user, please refresh the page and try again! +clue.to.pool=Lead {0} moved to pool {1} +clue.stage=Lead stage +clue.import_tpl.name=Lead_Import_Template +# pool +pool.data.release.date=Pick after {0} +# customerContact +customer_contact.exist=Contact already exists +contact.import_tpl.name=Contact_Import_Template +# opportunity +opportunity.rule.not.exist=Opportunity close rule does not exist +opportunity.access_fail=Access failed, not an opportunity rule admin! +opportunity.add=Create +opportunity.update=Update +opportunity.import_tpl.name=Opportunity_Import_Template +opportunity.quotation.status.voided=Voided +opportunity.quotation.status.approved=Approved +opportunity.quotation.status.unapproved=Unapproved +opportunity.quotation.already.associated=The current quotation is linked to a contract and cannot be deleted! +opportunity.quotation.no.voided=The current quotation is linked to a contract and cannot be voided! +opportunity.quotation.batch.no.voided=Quotations are linked to a contract and cannot be voided! or No operation permission +opportunity.quotation.not.exist=Quotation does not exist! +opportunity.quotation.amount.exceed.max=Quotation amount exceeds maximum limit! +opportunity.quotation.field.required=Quotation field cannot be empty +opportunity.quotation.form.config.required=Quotation form configuration cannot be empty +opportunity.required=Opportunity name cannot be empty +opportunity.quotation.name.required=Quotation name cannot be empty +opportunity.quotation.product.required=Quotation product cannot be empty +opportunity.quotation.approval.status.required=Approval status cannot be empty +opportunity.quotation.id.required=Quotation ID cannot be empty +opportunity.quotation.product.amount.invalid=Quotation product amount cannot be empty +opportunity.quotation.product.invalid=Quotation product selection cannot be empty +# import +sheet.instruction=\n Description: \n 1\u3001The header font is red for "required ", bold for" unique ", and underlined for "multiple values". \n 2\u3001multi-value uses the following format: [label 1, label 2], the rest of the format please refer to the table header annotation. \n 3\u3001the import will filter out the wrong format or illegal characters. \n +field_validate_error={0} field parameter value is invalid, please check required fields and field types! +field_option_value_error={0} value is wrong or has been removed. Please select again! +record_not_found=Follow-up record does not exist +user_has_been_disabled=User has been disabled +user_not_exist=User does not exist +update_follow_up_record=Update follow-up record +delete_follow_up_record=Delete follow-up record +update_follow_up_plan=Update follow-up plan +delete_follow_up_plan=Delete follow-up plan +cancel_follow_up_plan=Cancel follow-up plan +plan_not_found=Follow-up plan does not exist +opportunity_exist=Account already has [%s] opportunity, cannot %s now! +update_opportunity=Update opportunity +opportunity_not_found=Opportunity does not exist +follow_record=Follow-up record +http_result_success=Operation successful +http_result_unknown_exception=Unknown system exception +http_result_validate=Parameter validation failed +http_result_unauthorized=User authentication failed +http_result_forbidden=Permission authentication failed +http_result_not_found=Resource not exist +user_phone_not_exist=User phone number does not exist +illegal_operation=Operation does not comply with company structure rules, sub-departments must belong to company level, please adjust. +# log +log.name=Name +log.updateTime=Update time +log.owner=Owner +log.internal=System built-in +log.dataScope=Data scope +log.description=Description +log.syncOrganization=Sync organization structure +log.enable.true=Enabled +log.enable.false=Disabled +log.contact=Contact +log.clueId=Company name +#opportunity log +log.amount=Amount +log.possible=Probability +log.products=Products +log.contactId=Contact +log.stage=Opportunity stage +log.orderStage=Order status +log.rate=Opportunity rate +log.opportunity.status=Status +log.opportunity.status.true=Open +log.opportunity.status.false=Closed +log.approvalStatus=Approval status +log.approvalStatus.VOIDED=Voided +log.approvalStatus.NONE=None +log.approvalStatus.APPROVED=Approved +log.approvalStatus.UNAPPROVED=Unapproved +log.approvalStatus.APPROVING=Approving +log.approvalStatus.PENDING=Pending +log.approvalStatus.REVOKED=Revoked +log.approvalStatus.AUTO_APPROVED=Auto Approved +log.approvalStatus.AUTO_UNAPPROVED=Auto Unapproved +log.customerId=Account name +log.collectionTime=Pick time +log.expectedEndTime=End time +log.actualEndTime=Actual end time +log.failureReason=Failure reason +log.deBoardEnable=Dashboard switch +log.weComEnable=WeCom switch +log.deAccount=Account +log.websiteLogo=WebsiteLogo +log.loginLogo=LoginLogo +log.loginBackground=LoginBackground +log.slogan=Slogan +log.websiteName=WebsiteName +log.logo=Logo +log.systemName=SystemName +log.helpDocs=HelpDocs +log.style=Theme Color +log.theme=Background Color +log.qccEnable=Qichacha switch +log.qccAddress=Qichacha Address +log.qccAccessKey=Access Key +log.qccSecretKey=Secret Key +ui.setting.default=Default +ui.setting.follow=Follow Theme Color +COMPETITOR_CHOSEN=Account chose competitor +PROJECT_FAILED=Project failed +COMPLEX_DECISION_CHAIN=Complex decision chain +BUDGET_LIMITATION=Budget limitation +REQUIREMENT_CHANGE=Requirement change +CREATE=Create +CLEAR_REQUIREMENTS=Clear requirements +SCHEME_VALIDATION=Scheme validation +PROJECT_PROPOSAL_REPORT=Project proposal report +BUSINESS_PROCUREMENT=Opportunity procurement +SUCCESS=Success +FAIL=Fail +# user +log.userName=Name +log.enable=Status +log.gender=Gender +log.phone=Phone number +log.email=Email +log.departmentId=Department +log.supervisorId=Supervisor +log.onboardingDate=Onboarding date +log.employeeId=Employee number +log.roles=Roles +log.position=Position +log.employeeType=Employee type +log.workCity=Work city +log.commander=Leader +log.userPassword=Password +null=- +#module log +log.module.switch=Module switch +log.module.form.operate.btn=Form operation button +#Announcement +log.subject=Announcement subject +log.content=Announcement content +log.startTime=Start time +log.endTime=End time +log.url=Link +log.renameUrl=Rename link +log.receiver=Receiver +#message +log.event=Notification event type +log.emailEnable=Email send switch +log.sysEnable=System switch +log.ownerEnable=Owner option +log.ownerLevel=Department leader level +log.roleEnable=Role option +log.userIdNames=Notification personnel name +log.roleIdNames=Role +log.times=Time configuration +#system log +#email +log.host=SMTP host +log.port=SMTP port +log.account=SMTP account +log.password=SMTP password +log.ssl=SSL switch +log.tsl=TSL switch +log.from=Sender +log.recipient=Recipient +#third +log.type=Application type +log.corpId=CorpID +log.agentId=Application ID +log.appKey=Application key +log.appSecret=Application secret +log.appId=Application AppID +log.startEnable=Sync start switch +log.oauth2Enable=OAuth2 switch +log.syncEnable=Sync switch +log.qrcodeEnable=QR code login switch +log.sqlBotAppSecret=Embedded script +log.sqlBotBoardEnable=Dashboard switch +log.sqlBotChatEnable=Chat switch +log.verify=Verification switch +log.redirectUrl=Callback URL +log.configuration=Authentication source configuration +log.deAppId=APP ID +log.deAppSecret=APP Secret +log.deUrl=DataEase URL +log.thirdType=Application type +log.dingTalkEnable=DingTalk notice +log.larkEnable=Lark notice +log.deModuleEmbedding=Module Embedding +log.deLinkIntegration=External link integration +log.deAutoSync=Automatic synchronization +log.deAccessKey=AccessKey +log.deSecretKey=SecretKey +log.deOrgID=DE OrgId +#product +log.price=Product price +log.status=Product status +product.import_tpl.name=Product_Import_Template +#common +common.picture.changed=Picture changed +common.attachment.changed=Attachment changed +common.field_value.repeat={0} field has duplicate value! +common.option.not_exist=Option does not exist! +log.follow_record_content=Follow-up content +log.estimated_time=Estimated start time +log.followMethod=Follow-up method +log.followTime=Follow-up time +log.opportunityId=Opportunity +log.estimatedTime=Estimated start time +log.method=Follow-up method +log.converted=Convert to record +import.data.cannot_be_null=The import data is empty! +#ExportTask +task_already_stopped=Cancel failed, task has completed export +task_not_found=Cancel failed, task has expired or does not exist +user_export_task_limit=There are currently 10 tasks being exported. To ensure export quality, please try again in 5 minutes! +export_head_list_is_empty=Header information cannot be blank +export_select_ids_is_empty=Selected data ID list cannot be blank +#license +license_valid_license_error=License authentication failed +license_valid_license_expired=License expired +dashboard_module_name_exist=Folder name already exists +dashboard_module_blank=Folder does not exist +dashboard_module_default_delete=Top-level folder cannot be deleted! +dashboard_module_cannot_delete=Folder contains dashboards, please clear dashboards manually +dashboard_name_exist=Dashboard name already exists +dashboard_blank=Dashboard does not exist +top_dashboard_module_not_support_add=Top-level folder does not support adding dashboards +log.dashboardModuleName=Folder +log.members=Viewable members +log.resourceId=Dashboard ID +dashboard_collect_exist=Dashboard already collected, please refresh the page! +view_blank=View does not exist or has been deleted! +#dict +dict.not_exist=Dictionary does not exist +system.auto.recycle=System auto recycle +system.auto.closed=System auto close +# import +sheet.data=Data +sheet.comment=Instructions +format.preview=Format preview +keep.decimal.places=Integers are limited to 10 bits and decimals to 4 bits +phone.tips=Supports "(area code)+phone number", "Mainland China+86, Hong Kong+852, Macau+853, Taiwan+886" +option=Supported options +location.c=Country +location.p=Province +location.pc=Province-City +location.pcd=Province-City-District +location.pcd.detail=Province-City-District-Detailed address +industry.tips=Categories - Major categories (refer to the Classification of National Economic Industries) +row.error.tip=Error in row {0}: +cell.not.unique=Not unique +import.error=Import exception, failed to parse row {0}: +illegal_header=Header information error, {0} missing +over.length=Length exceeds {0} +phone.wrong.format=Incorrect format +password_reset_error=Initial password error, password change failed! +file_name_illegal=Illegal file name: Characters are not allowed / +search.config.empty=The matching field cannot be empty, there must be at least one matching field +search.config.limit=A maximum of 5 matching fields can be configured for a single module +expire_time_not_null=Expire time can not null +user_apikey_limit=Can have up to 5 api keys +api_key_not_exist=API key does not exist +agent_name_exist=Agent name already exists +agent_module_blank=Folder does not exist +agent_blank=Agent does not exist +agent_collect_exist=Agent already collected, please refresh the page! +agent_module_cannot_delete=Folder contains agent, please clear agent manually +permission.agent_management.name=Agent +permission.agent.name=Agent +permission.tender_management.name=Tender +permission.tender.name=Tender +log.agentModuleName=Folder +log.script=Script +top.navigation=Top navigation +form.view.size=View size +small=Small +medium=Medium +large=Large +search=Search +notify=Notification +about=About +language=Language +help=Help center +event=Record/Plan +opportunity_stage_config_list=Opportunity stage configuration cannot exceed 10 +opportunity_stage_setting=Opportunity stage setting +opportunity_stage_delete=There is opportunity data in the current stage, deletion is prohibited! +opportunity_stage_not_exist=Opportunity stage does not exist +opportunity_stage_at_least_one=At least one opportunity in the active stage must be retained +order_stage_config_list=Order stage configuration cannot exceed 10 +order_stage_setting=Order stage setting +order_stage_delete=The current order status contains data, deletion is prohibited! +order_stage_not_exist=Order stage does not exist +order_not_exist=Order does not exist +contract_stage_config_list=Contract stage configuration cannot exceed 10 +contract_stage_setting=Contract stage setting +contract_stage_delete=The current contract status contains data, deletion is prohibited! +log.afootRollBack=Rollback in progress +log.endRollBack=Final stage rollback +dashboard_url_not_allowed=Dashboard address denied access +log.mkAddress=MaxKB Address +log.apikey=API Key +log.mkEnable=Status +price=Price +quotation=Quotation +contract=Contract +contractPaymentPlan=Payment Plan +#contract +contract.not.exist=Contract does not exist +contract.has.payment.record=Contract has payment record, cannot be deleted! +contract.has.invoice.cannot.delete=Contract has associated invoice, cannot be deleted! +contract.archived.cannot.edit=Archived contracts cannot be edited! +contract.void.cannot.edit=Voided contracts cannot be edited! +contract.archived.cannot.delete=Archived contracts cannot be deleted! +contract.archived.cannot.voided=Archived contracts cannot be voided! +contract.unapproved.cannot.edit=Unapproved contracts cannot be edited! +contract.payment_plan.status.pending=Not Completed +contract.payment_plan.status.partially_completed=Partially Completed +contract.payment_plan.status.completed=Completed +log.planStatus=Plan Status +contract.form.config.required=Contract form configuration cannot be empty +contract.field.required=Contract field cannot be empty +contract.approval_status.approved=Approved +contract.approval_status.approving=Approving +contract.approval_status.unapproved=Unapproved +contract.approval_status.revoked=Revoked +contract.approval_status.pending=Pending +contract.approval_status.none=- +contract.archived_status.archived=Archived +contract.archived_status.un_archived=Un Archived +contract.stage.pending_signing=Pending Signing +contract.stage.signed=Signed +contract.stage.change=Change +contract.stage.in_progress=In Progress +contract.stage.completed_performance=Completed Performance +contract.stage.void=Void +contract.stage.archived=Archived +log.contractStage=Contract Stage +contract.amount.exceed.max=Contract amount cannot exceed max limit +contract.name.required=Contract name cannot be empty +contract.customer.required=Account cannot be empty +owner.required=Owner cannot be empty +sum=SUM +data.permission=Data has changed, please refresh! +invoice=Invoice +invoice.form.config.required=Invoice form configuration cannot be empty +invoice.field.required=Invoice field cannot be empty +invoice.amount.exceed=The total amount of the invoice exceeds the contract amount! +permission.contract.invoice.name=Invoice record +resource.not.exist=Resource not exist +business_title.not.exist=Business title not exist +business_title.exist=Business title exist +business_title_import_tpl.name=Business_title_import_template +# contract payment record +record.not.exist=Payment record does not exist +record.amount.illegal=Payment amount must be greater than 0 +payment.record.import_tpl.name=Payment_record_import_template +custom_form_data.import_tpl.name={0}_Import_Template +log.identificationNumber=Identification Number +log.openingBank=Opening Bank +log.bankAccount=Bank Account +log.registrationAddress=Registration Address +log.phoneNumber=Phone Number +log.registeredCapital=Registered Capital +log.companySize=Company Size +log.registrationNumber=Registration Number +log.companyName=Company Name +log.city=City +log.province=Province +log.remark=Remark +log.scale=Scale +log.industry=Industry +log.global.search=Global search +THIRD_PARTY=Third party +CUSTOM=Custom +log.pool.members=Members +log.pool.owner=Administrator +log.pool.auto=Automatic recycling +approval.not.enabled=The approval feature is not enabled +log.formType=Form type +log.createExecute=Execute on create +log.updateExecute=Execute on update +log.duplicateApproverRule=Duplicate approver rule +log.submitterCanRevoke=Allow submitter to revoke +log.allowBatchProcess=Allow batch process +log.allowWithdraw=Allow withdraw +log.allowAddSign=Allow add sign +log.requireComment=Require comment +log.statusPermissions=Approval permission +approval_flow.log.description=Description +approval_flow.form_type.quotation=Quotation +approval_flow.form_type.contract=Contract +approval_flow.form_type.invoice=Invoice +approval_flow.form_type.order=Order +approval_flow.enable.true=Enable +approval_flow.enable.false=Disable +approval_flow.duplicate_approver_rule.first_only=First only +approval_flow.duplicate_approver_rule.sequential_all=Sequential all +approval_flow.duplicate_approver_rule.each=Each +log.nodeName=Node name +log.nodeType=Node type +log.approvalType=Approval type +log.approverType=Approver type +log.approverList=Approver +log.multiApproverMode=Multi approver mode +log.emptyApproverAction=Action when approver is empty +log.fallbackApprover=Approver when empty +log.sameSubmitterAction=Action when approver is submitter +log.ccType=CC type +log.ccList=CC list +log.passPostConfig=Pass post config +log.rejectPostConfig=Reject post config +log.fieldPermissions=Field permissions +log.conditionConfig=Condition config +approval_flow.approval_type.manual=Manual +approval_flow.approval_type.auto_pass=Auto pass +approval_flow.approval_type.auto_reject=Auto reject +approval_flow.approver_type.member=Specified member +approval_flow.approver_type.role=Role +approval_flow.approver_type.superior=Direct supervisor +approval_flow.approver_type.multiple_superior=Continuous supervisor +approval_flow.approver_type.dept_head=Department leader +approval_flow.approver_type.multiple_dept_head=Continuous department leader +approval_flow.multi_approver_mode.all=All approve +approval_flow.multi_approver_mode.any=Any approve +approval_flow.multi_approver_mode.sequential=Sequential +approval_flow.empty_approver_action.auto_pass=Auto pass +approval_flow.empty_approver_action.assign_specific=Assign specific +approval_flow.empty_approver_action.assign_admin=Assign admin +approval_flow.same_submitter_action.allow=Allow +approval_flow.same_submitter_action.skip=Skip +approval_flow.same_submitter_action.assign_superior=Assign superior +approval_flow.node_type.start=Start +approval_flow.node_type.end=End +approval_flow.node_type.approver=Approver +approval_flow.node_type.condition=Condition +approval_flow.node_type.default=Default +approval_flow.node_type.exception=Exception +approval_flow.level.supervisor.1=First-level supervisor +approval_flow.level.supervisor.2=Second-level supervisor +approval_flow.level.supervisor.3=Third-level supervisor +approval_flow.level.supervisor.4=Fourth-level supervisor +approval_flow.level.supervisor.5=Fifth-level supervisor +approval_flow.level.supervisor.6=Sixth-level supervisor +approval_flow.level.supervisor.7=Seventh-level supervisor +approval_flow.level.supervisor.8=Eighth-level supervisor +approval_flow.level.supervisor.9=Ninth-level supervisor +approval_flow.level.supervisor.10=Tenth-level supervisor +approval_flow.level.department.1=First-level department leader +approval_flow.level.department.2=Second-level department leader +approval_flow.level.department.3=Third-level department leader +approval_flow.level.department.4=Fourth-level department leader +approval_flow.level.department.5=Fifth-level department leader +approval_flow.level.department.6=Sixth-level department leader +approval_flow.level.department.7=Seventh-level department leader +approval_flow.level.department.8=Eighth-level department leader +approval_flow.level.department.9=Ninth-level department leader +approval_flow.level.department.10=Tenth-level department leader +order=Order +order.stage=Order status +order.stage.create=Create +order.stage.pending_shipment=Pending shipment +order.stage.partially_shipped=Partially shipped +order.stage.shipped=Shipped +order.stage.pending_acceptance=Pending acceptance +order.stage.completed=Completed +order.stage.voided=Voided +order.number.length.exceed=Please reconfigure the generation rule if the line number is longer than 50 characters! +ip_address.not_allowed=The IP address cannot be an intranet or reserved address! +user_export_task_prepared_limit=Data is being prepared for export, please try again later! +approval_flow.not.exist=Approval flow not yet started! +approval_flow.type.already.exists=Approval flow for this form type already exists! +approval_flow.field_permission.hidden=Hidden +approval_flow.field_permission.view=View +approval_flow.field_permission.edit=Edit +sign=sign +back=back +approve=approve +reject=reject +revoke=revoke +no.back.approval=Cannot back +no.revoke.approval=Cannot revoke +no.approval.next.node=No subsequent node found, check the approval flow configuration +auto.approval.passed=Auto passed +auto.approval.rejected=Auto rejected +no.approval.instance=No approval instance! +batch.update.reason=No permission or violation of field constraints! +# custom form +custom.form.not.exist=Custom form does not exist +custom.form.data.not.exist=Custom form data does not exist +custom.form.role.not.exist=Custom form role does not exist +custom.form.role.manage_all=Manage all data +custom.form.role.view_all=View all data +custom.form.role.manage_own=Manage own data +permission.custom_form.name=Custom Form +custom.form.name.duplicate=Custom form name already exists +log.adminUserIds=Admin +log.roleUsers=Role Members + + +webhook.enable=Invoke Webhook +webhook.describe=Description +webhook.url=External system Webhook URL +webhook.method=Request method +webhook.header=Request header JSON +webhook.body=Request body JSON +# form prop layout +formProp.layout.1=Single Column +formProp.layout.2=Double Column +formProp.layout.3=Triple Column +formProp.layout.4=Quad Column +# form prop labelPos +formProp.labelPos.top=Top +formProp.labelPos.left=Left +# form prop inputWidth +formProp.inputWidth.custom=Custom +formProp.inputWidth.full=Full Width +# form prop optBtnPos +formProp.optBtnPos.flex-row=Left +formProp.optBtnPos.justify-center=Center +formProp.optBtnPos.flex-row-reverse=Right +advanced_circulation_setting=Advanced circulation setting \ No newline at end of file diff --git a/backend/crm/src/main/resources/i18n/cordys-crm_zh_CN.properties b/backend/crm/src/main/resources/i18n/cordys-crm_zh_CN.properties new file mode 100644 index 0000000..db752b4 --- /dev/null +++ b/backend/crm/src/main/resources/i18n/cordys-crm_zh_CN.properties @@ -0,0 +1,984 @@ +# cordys cn +message_task.event.length_range=通知事件类型长度必须在1-255之间 +message_task.event.not_blank=通知事件类型不能为空 +message_task.receivers.length_range=接收人id集合长度必须在1-1000之间 +message_task.receivers.not_blank=接收人id集合不能为空 +message_task.task_type.length_range=任务类型长度必须在1-64之间 +message_task.task_type.not_blank==任务类型不能为空 +message_task.enable.not_blank=启用值不能为空 +message_task.use_default_template.not_blank=是否使用默认模版标识不能为空 +message_task.use_default_subject.not_blank=是否使用默认标题标识不能为空 +message_task_blob.id.not_blank=消息通知任务id不能为空 +message_task_blob.id.length_range=消息通知任务id长度必须在1-32之间 +announcement.subject.length_range=公告标题长度必须在1-255之间 +announcement.subject.not_blank=公告标题不能为空 +announcement.content.not_blank=公告内容不能为空 +announcement.start_time.not_blank=开始时间不能为空 +announcement.end_time.not_blank=结束时间不能为空 +announcement.organization_id.length_range=接收人长度必须在1-50之间 +announcement.organization_id.not_blank=接收人不能为空 +notification.type.length_range=通知类型长度必须在1-255之间 +notification.type.not_blank=通知类型不能为空 +notification.receiver.length_range=接收人长度必须在1-50之间 +notification.receiver.not_blank=接收人不能为空 +notification.subject.length_range=标题长度必须在1-255之间 +notification.subject.not_blank=标题不能为空 +notification.status.length_range=状态长度必须在1-64之间 +notification.status.not_blank=状态不能为空 +notification.operator.length_range=操作人长度必须在1-50之间 +notification.operator.not_blank=操作人不能为空 +notification.operation.length_range=操作长度必须在1-50之间 +notification.operation.not_blank=操作不能为空 +notification.resource_type.length_range=资源类型长度必须在1-64之间 +notification.resource_type.not_blank=资源类型不能为空 +notification.resource_name.length_range=资源名称长度必须在1-255之间 +notification.resource_name.not_blank=资源名称不能为空 +notification.content.not_blank=通知内容不能为空 +startTime_must_be_less_than_endTime=开始日期必须小于结束日期 +data_parsing_exception=数据解析异常 +organization_id_not_exist=当前用户暂无组织信息 +department.name.length_range=名称长度必须在1-255之间 +department.name.not_blank=名称不能为空 +department.parent.id.not_blank=父部门 id 不能为空 +department.id.not_blank=部门id不能为空 +department.blank=部门不存在 +department.commander.id.not_blank=部门负责人 id 不能为空 +email.exist=邮箱已存在 +phone.exist=手机号已存在 +email.format_error=邮箱格式错误 +code_not_exist=授权码为空 +phone.repeat=手机号重复 +email.repeat=邮箱重复 +#personal +email_setting_subject=重设 Cordys 账号密码 +email_setting_content=您正在 重设Cordys密码,验证码:

${code}

验证码 10 分钟内有效,请勿泄漏;

Cordys团队。

+email_setting_reset_error=请勿频繁请求验证码 +email_setting_send_error=邮件发送失败 +email_setting_verify_success=验证成功 +email_setting_verify_error=验证码错误或已过期 +#org_setting +ui.setting=界面设置 +announcement.blank=公告不存在 +email.update.error.id.wrong=邮件更新失败,更新主键错误 +email.setting=邮件设置 +email.config.not.exist.text=请先配置邮件设置 +email.config.is.null=邮件配置为空 +email.connection.failed=连接失败 +sync.organization.update.error.id.wrong=同步组织更新失败,更新主键错误 +sync.organization.type.exist=当前组织已有该类型配置 +third.setting=三方设置 +third.test.open.error=开启校验失败,请确认配置内容 +sync.organization.http.error=HttpClient 查询失败 +sync.organization.test.error=连接测试失败 +third.config.not.exist=当前类型应用未配置 +third.config.un.enable=应用被禁用 +auth.source.blank=认证信息不存在 +auth.source.name.exists=认证名称重复 +auth.source.configuration.blank=认证配置为空 +auth.source.type.exists=认证类型重复 +auth.id.not_blank=认证ID不能为空 +auth.name.not_blank=认证名称不能为空 +auth.setting.no.exists=未配置认证信息 +auth.get.user.error=获取用户信息失败 +auth.login.un_enable=用户被禁用 +oauth.mapping.config.error=OAuth2属性映射配置错误 +oauth.mapping.value.null=OAuth2用户属性映射字段为空值 +auth.token.error=获取access_token失败 +auth.get.token.error=调用接口获取access_token失败 +auth.get.token.res.error=获取access_token接口返回结果失败 +auth.get.user.res.error=获取访问用户身份接口返回结果失败 +# system module +home=首页 +customer=客户 +customer_pool=公海 +clue=线索 +clue_pool=线索池 +business=商机 +opportunity=商机 +data=数据 +product=产品 +contact=联系人 +record=跟进记录 +plan=跟进计划 +dashboard=仪表板 +setting=系统 +agent=智能体 +tender=标讯 +task=待办 +customForm=自定义表单 +contractPaymentRecord=回款记录 +module.not_exist=模块不存在 +module.id.not_blank=模块ID不能为空 +module.id.length_range=模块ID长度必须在1-32之间 +module.switch=模块开关 +module.main.nav=主导航 +module.form.not_exist=模块表单不存在 +module.form.business_field.deleted=表单业务字段无法删除 +module.form.fields.repeat=表单字段重复 +module.form.fields.option.repeat={0}选项重复 +module.field.not_exist=字段不存在 +module.field.not_match_type=不匹配的字段类型 +module.form.illegal.unique.check=非法的表单重复校验 +module.form.illegal=非法的表单标识 +module.has.closed=模块已关闭 +module.clue.pool.setting=线索池设置 +module.customer.pool.setting=公海设置 +module.opportunity.rule.setting=商机关闭规则 +module.customer.capacity.setting=客户库容设置 +module.clue.capacity.setting=线索库容设置 +module.form.setting=表单设置 +module.reason.setting=原因设置 +module.opportunity_fail_rs=商机失败 +module.customer_pool_rs=移入公海 +module.clue_pool_rs=移入线索池 +module.quotation_approval=报价审批 +module.contract_approval=合同审批 +module.invoice_approval=发票审批 +module.resource_type.quotation=报价单 +module.resource_type.contract=合同 +module.resource_type.invoice=发票 +module.resource_type.order=订单 +approval.delete.resource=删除{0}-{1} +approval.update.field=[{0}]更新为: {1} +module.desensitization_set=脱敏设置 +log.sort=排序 +log.placeholder=输入提示文字 +log.clue.link=表单联动-线索 +log.customer.link=表单联动-客户 +log.opportunity.link=表单联动-商机 +log.contract.link=表单联动-合同 +log.plan.link=表单联动-跟进计划 +log.fields=字段属性(待定) +log.form.prop=表单属性(待定) +log.form.name=表单名称 +log.merge=合并 +clue_to_customer=线索转客户 +clue_to_contact=线索转联系人 +clue_to_opportunity=线索转商机 +customer_to_opportunity=新建商机 +clue_to_record=写记录 +customer_to_record=写记录 +opportunity_to_record=写记录 +plan_to_record=写记录 +contract_to_invoice=新建发票 +contract_to_order=新建订单 +# permission name +permission.system.name=系统管理 +permission.system.role.name=角色权限 +permission.system.notice.name=消息设置 +permission.system.license=License +permission.system.setting.name=企业设置 +permission.process_setting.name=流程设置 +permission.operation.log=系统日志 +permission.customer_management.name=客户管理 +permission.customer_management.customer.name=客户 +permission.customer_management.customer.recycle=移入公海 +permission.customer_management.pool.name=公海 +permission.clue_management.pool.name=线索池 +permission.customer_management.pool.pick=领取 +permission.customer_management.pool.assign=分配 +permission.system.organization.name=组织架构 +permission.system.module_setting.name=模块设置 +permission.customer.customer_contact.name=联系人 +permission.clue_management.name=线索管理 +permission.clue_management.clue.name=线索 +permission.clue_management.clue.recycle=移入线索池 +permission.clue_management.clue.resign=反签 +permission.opportunity_management.name=商机管理 +permission.opportunity_management.opportunity.name=商机 +permission.opportunity_management.opportunity_quotation.name=报价 +permission.opportunity_management.opportunity_quotation.download=下载 +permission.product_management.name=产品管理 +permission.product_management.product.name=产品 +permission.product_management.price.name=价格表 +permission.dashboard_management.name=仪表板 +permission.dashboard.name=仪表板 +permission.personal_info.name=个人信息 +permission.api_key.name=API Keys +no.operation.permission=没有操作权限 +permission.contract_management.name=合同管理 +permission.contract_payment_plan.name=回款计划 +permission.contract_payment_record.name=回款记录 +permission.contract.name=合同 +permission.business_title.name=工商抬头 +permission.opportunity_quotation.name=报价 +# permission operate +permission.read=查看 +permission.add=添加 +permission.update=编辑 +permission.edit=编辑 +permission.transfer=转移 +permission.delete=删除 +permission.import=导入 +permission.export=导出 +permission.download=下载 +permission.recover=恢复 +permission.pick=领取 +permission.assign=分配 +permission.merge=合并 +permission.archive=归档 +permission.voided=作废 +permission.review=审核 +permission.approval=审批 +permission.stage=合同阶段变更 +permission.payment=回款 +permission.order_management.name=订单管理 +permission.order.name=订单 +# validation +enum_value_valid_message=枚举值不合法,必须为 +# organization +organization.config.organization_id_not_null=组织ID不能为空 +organization.config.type_not_null=类型不能为空 +organization.config.detail_type_not_null=详情类型不能为空 +organization.config.content_not_null=内容不能为空 +organization.config.detail_id_not_null=详情ID不能为空 +# common +organization_id.not_blank=组织ID不能为空! +organization_id.length_range=组织ID长度必须在{min}-{max}之间! +import.data.cannot_be_null=导入数据为空! +# clue or clue pool +clue_not_exist=线索不存在! +clue_ids_not_empty=未勾选线索! +clue_pool_not_exist=线索池不存在! +clue_pool_access_fail=访问失败,非线索池管理员! +clue_pool_related=线索池存在未分配的线索,禁止删除! +scope_id.not_blank=范围ID不能为空! +scope_id.length_range=范围ID长度必须在{min}-{max}之间! +clue_capacity_not_exist=线索库容规则不存在! +clue_owner_not_exist=线索负责人不存在! +transform.miss.customer.permission=缺少客户新增权限! +transform.miss.opportunity.permission=缺少商机新增权限! +transform.customer.error=客户转换失败,请查看日志! +transform.opportunity.error=商机转换失败,请查看日志! +#message +message.create_user=创建人 +message.follow_people=关注人 +message.operator=操作人 +message.owner=负责人 +message.handle_user=处理人 (第三方平台的处理人,不会接收到通知) +message.customer=客户管理 +message.clue=线索管理 +message.business=商机管理 +message.contract=合同管理 +message.announcement=公告 +message.system=系统 +message.customer_add=新建客户 +message.customer_concat_add=新建联系人 +message.customer_collaboration_add=新建协作人 +message.customer_transferred_customer=被转移客户 +message.customer_automatic_move_high_seas=客户自动移入公海(到时间) +message.customer_moved_high_seas=客户被动移入公海 +message.customer_deleted=客户被删除 +message.high_seas_customer_distributed=公海客户被分配 +message.customer_follow_up_plan_due=跟进计划到期 +message.clue_add=新建线索 +message.clue_automatic_move_pool=自动移入线索池 +message.clue_moved_pool=被动移入线索池 +message.clue_convert_customer=转为客户 +message.clue_convert_business=转为商机 +message.transfer_clue=转移线索 +message.clue_deleted=删除线索 +message.clue_distributed=分配线索 +message.clue_import=导入线索 +message.clue_follow_up_plan_due=跟进计划到期 +message.business_add=新建商机 +message.business_deleted=商机删除 +message.business_transfer=商机转移 +message.business_import=商机导入 +message.contract_add=新建合同 +message.contract_delete=删除合同 +message.contract_archived=合同归档 +message.contract_void=合同作废 +message.contract_expired=合同到期 +message.contract_expiring=合同即将到期 +message.contract_payment_expired=回款计划到期 +message.contract_payment_expiring=回款计划即将到期 +message.contract_approval=合同审批 +message.invoice_approval=发票审批 +message.order=订单管理 +message.order_approval=订单审批 +message.business_follow_up_plan_due=跟进计划到期 +message.sync_organization_structure=同步组织架构 +message.business_quotation_approval=报价审批 +message.business_quotation_deleted=报价删除 +message.business_quotation_expired=报价到期 +message.business_quotation_expiring=报价即将到期 +message.customer_add_text=请注意!${OPERATOR}新建${name}客户给您,请知悉! +message.customer_concat_add_text=请注意!${OPERATOR}新建${name}联系人给您,请知悉! +message.customer_collaboration_add_text=请注意!${name}成为该客户的协作人,请知悉! +message.customer_transferred_customer_text=请注意!${OPERATOR}将${name}客户转移给您,请知悉! +message.customer_automatic_move_high_seas_text=请注意!根据系统规则,您负责的${name}客户已被移入公海,请知悉! +message.customer_moved_high_seas_text=请注意!${OPERATOR}已将您负责的${name}移入公海,请知悉! +message.customer_deleted_text=请注意!您负责的${name}已被${OPERATOR}删除! +message.high_seas_customer_distributed_text=请注意!${name}已由公海分配给您,请及时跟进处理! +message.customer_follow_up_plan_due_text=请注意!您创建的${name}跟进计划,已到预定时间,请及时跟进! +message.clue_add_text=请注意!${OPERATOR}新建${name}线索给您,请知悉! +message.clue_automatic_move_pool_text=请注意!根据系统规则,您负责的${name}的销售线索,已被移入线索池! +message.clue_moved_pool_text=请注意!${OPERATOR}已将您负责的${name}线索移入线索池,请知悉! +message.clue_convert_customer_text=请注意!您负责的 ${name} 线索,已成功转为客户!请知悉! +message.clue_convert_business_text=请注意!您负责的 ${name} 线索,已成功转为商机!请知悉! +message.customer.collaboration.add.text=请注意!${operator} 将 ${uName} 添加为您负责客户 ${name} 的协作人!请知悉! +message.customer.contact.add.text=请注意!${operator} 新建 ${cName} 联系人给您,请知悉! +message.clue_relate_customer=${name} 线索关联了客户 ${customerName}! +message.transfer_clue_text=请注意!${OPERATOR}将${name}线索转移给您,请知悉! +message.clue_deleted_text=请注意!您负责的${name}线索,已被${OPERATOR}删除! +message.clue_distributed_text=请注意!${name}已由线索池分配给您,请及时跟进处理! +message.clue_import_text=请注意!${OPERATOR}已成功导入${count}条线索,请及时领取! +message.clue_follow_up_plan_due_text=请注意!您创建的${name}线索跟进计划,已到预定时间,请及时跟进! +message.business_add_text=请注意!${OPERATOR}新建${name}商机给您,请知悉! +message.business_deleted_text=请注意!您负责的${name}商机,已被${OPERATOR}删除! +message.business_transfer_text=请注意!${OPERATOR}将${name}商机转移给您,请知悉! +message.business_import_text=请注意!${OPERATOR}已成功导入${count}条线索,请及时领取! +message.business_follow_up_plan_due_text=请注意!您创建的${name}商机跟进计划,已到预定时间,请及时跟进! +message.business_quotation_approval_text=${OPERATOR}审批了${name}报价,审批结果为${state} +message.business_quotation_deleted_text=${OPERATOR}删除了${name}报价 +message.business_quotation_expiring_text=您负责的${customerName}报价还有${expireDays}天到期 +message.business_quotation_expired_text=您负责的${customerName}的报价已经到期 +message.contract_archived_text=您负责的${customerName}合同已被归档 +message.contract_void_text=您负责的${customerName}合同已被作废 +message.contract_expiring_text=您负责的${customerName}合同还有${expireDays}天到期 +message.contract_expired_text=您负责的${customerName}合同已经到期 +message.contract_payment_expiring_text=您负责的${customerName}合同的回款计划还有${expireDays}天到期 +message.contract_payment_expired_text=您负责的${customerName}合同的回款计划已经到期 +approval_reject={0} 提交了 {1} {2},审批已驳回。 +message.sync_organization_structure_text=同步组织架构完成 +message.contract_approval_text=${OPERATOR}审批了${name}合同,审批结果为${state} +message.order_approval_text=${OPERATOR}审批了${name}订单,审批结果为${state} +message.invoice_approval_text=${OPERATOR}审批了${name}发票,审批结果为${state} +message.approval_todo_text=${OPERATOR}提交了${type}: ${name},请您审批! +message_task.organization_id.not_blank=组织ID不能为空 +message_task.module.not_blank=模块不能为空 +message_task.receivers.not_empty=接收人不能为空 +notice.default.subject=系统通知 +notice.event.subject=通知 +#time +time.second=秒 +time.minute=分 +time.hour=时 +time.day=天 +time.week=周 +time.month=月 +time.year=年 +#product +product.exist=产品已存在 +product.shelves=上架 +product.unShelves=下架 +product.name=产品名称 +product.price=产品价格 +product.status=产品状态 +product.id.empty=产品ID不能为空 +product.price.min=价格必须大于0 +product.price.max=价格必须小于10000000000 +product.length=产品个数不能超过20个 +#produce price +product.price.not.exist=价格表不存在 +product.price.import_tpl.name=价格表导入模板 +product.price.in.use.cannot.delete=价格表已被报价单关联,无法删除! +product.price.name.required=价格表不能为空 +product.price.status.required=状态不能为空 +product.price.id.required=价格表ID不能为空 +# role +role.org_admin=管理员 +role.sales_manager=销售经理 +role.sales_staff=销售专员 +role.exist=角色已存在 +role.not_exist=角色不存在 +internal.role.permission.error=无法操作该内置角色 +permission.role.add_user=添加用户 +permission.role.remove_user=移除用户 +role.add_user=角色 {0} 添加用户 {1} +role.remove_user=角色 {0} 移除用户 {1} +role.log.dept.name=指定部门数据 +role.log.permission.name=权限配置 +role.data_permission.all=全部数据 +role.data_permission.dept_custom=指定部门数据 +role.data_permission.dept_and_child=本部门数据 +role.data_permission.self=仅本人数据 +# customer or customer pool +customer_pool_not_exist=公海池不存在! +customer_pool_access_fail=访问失败,非公海池管理员! +customer_pool_related=公海池存在未分配的线索,禁止删除! +customer_capacity_not_exist=客户库容规则不存在! +customer_rule_condition_error=解析失败,回收规则格式有误! +customer.relation.target_customer.exist=客户 {0} 已有归属集团! +no.customer.merge.data=没有可合并的客户数据! +password_is_incorrect=用户名或密码不正确 +department_comment=XX公司/XX事业部/研发部 +employee_type_comment=员工类型:正式/实习/外包 +user_import_template_name=用户导入模板 +user_import_template_sheet=模版 +required=必填 +file_cannot_be_null=文件不能为空 +check_import_excel_error=校验失败,请检查文件是否正确 +user_import_table_header_missing=表头信息缺失 +name_length=姓名长度不能超过255个字符 +phone_length=电话长度不能超过20个字符 +email_format_error=邮箱格式错误 +top_department_not_exist=一级部门未录入 +cannot_be_null=不能为空 +number=第 +row=行 +error=出错 +department_create_fail=创建部门失败 +man=男 +woman=女 +department_name_exist=部门名称已存在 +invalid_parameter=非法的参数 +drag_node.not.exist=拖拽节点不存在 +formal=正式 +internship=实习 +outsourcing=外包 +user_resource_exist=该员工存在未转移的客户资源,无法删除 +permission.organization.sync=同步 +permission.organization.user.reset_password=重置密码 +department.internal=内置部门不允许删除 +department.employees.exist=部门下存在员工,无法删除 +import_phone_validate=手机号不合法 +employee_length=工号长度不能超过255个字符 +position_length=职位长度不能超过255个字符 +products_info=产品信息 +# customer +customer.exist=客户已存在 +customer.not.exist=客户不存在 +customer.capacity.over=负责人库容不足,剩余{0},请联系管理员! +capacity.scope.duplicate=规则中存在重复的用户! +capacity.not.exist=库容规则不存在! +customer.daily.pick.over=超出每日领取上限! +customer.pre_owner.pick.limit=前归属人领取天数限制,{0}后领取! +customer.to.pool=客户 {0} 移入公海 {1} +customer.ref_resource.exist=存在关联的商机或联系人,无法删除! +customer.pool.occupied={0} 已被其他用户占用, 请刷新页面重新操作! +customer.import_tpl.name=客户导入模板 +# clue +clue.exist=线索已存在 +clue.not.exist=线索不存在 +clue.pool.occupied={0} 已被其他用户占用, 请刷新页面重新操作! +clue.to.pool=线索 {0} 移入线索池 {1} +clue.stage=线索阶段 +clue.import_tpl.name=线索导入模板 +# pool +pool.data.release.date={0} 后领取 +# customerContact +customer_contact.exist=客户联系人已存在 +contact.import_tpl.name=联系人导入模板 +# opportunity +opportunity.rule.not.exist=商机关闭规则不存在 +opportunity.access_fail=访问失败,非商机规则管理员! +opportunity.add=创建 +opportunity.update=更新 +opportunity.import_tpl.name=商机导入模板 +opportunity.quotation.status.voided=作废 +opportunity.quotation.status.approved=通过 +opportunity.quotation.status.unapproved=不通过 +opportunity.quotation.already.associated=当前报价已被合同关联,禁止删除! +opportunity.quotation.no.voided=当前报价已被合同关联,禁止作废! +opportunity.quotation.batch.no.voided=报价已被合同关联,禁止作废!或 无操作权限! +opportunity.quotation.not.exist=报价不存在! +opportunity.quotation.amount.exceed.max=报价金额超出最大限制! +opportunity.required=商机不能为空 +opportunity.quotation.name.required=报价名称不能为空 +opportunity.quotation.product.required=报价产品信息不能为空 +opportunity.quotation.approval.status.required=审批状态不能为空 +opportunity.quotation.id.required=报价ID不能为空 +opportunity.quotation.product.amount.invalid=报价产品金额不能为空 +opportunity.quotation.product.invalid=报价产品选择不能为空 +# import +sheet.instruction=\n 说明:\n 1、表头字体红色表示 "必填",加粗表示 "唯一",下划线表示支持 "多值"。\n 2、多值采用以下格式:[标签1,标签2],其余格式请参考表头注释。\n 3、导入时会过滤格式错误或非法字符。\n +field_validate_error={0}字段参数值不合法,请检查必填项和字段类型! +field_option_value_error={0} 选项值错误或已被移除,请重新选择! +record_not_found=跟进记录不存在 +user_has_been_disabled=用户已被禁用 +user_not_exist=用户不存在 +update_follow_up_record=更新跟进记录 +delete_follow_up_record=删除跟进记录 +update_follow_up_plan=更新跟进计划 +delete_follow_up_plan=删除跟进计划 +cancel_follow_up_plan=取消跟进计划 +plan_not_found=跟进计划不存在 +opportunity_exist=该客户已经存在[%s]商机,当前无法%s! +update_opportunity=更新商机 +opportunity_not_found=商机不存在 +opportunity.quotation.field.required=报价字段不能为空 +opportunity.quotation.form.config.required=报价表单配置不能为空 +follow_record=跟进记录 +http_result_success=操作成功 +http_result_unknown_exception=系统未知异常 +http_result_validate=参数校验失败 +http_result_unauthorized=用户认证失败 +http_result_forbidden=权限认证失败 +http_result_not_found=资源不存在 +user_phone_not_exist=用户手机号不存在 +illegal_operation=该操作不符合公司架构规则,子部门必须隶属于公司层级之下,请重新调整。 +# 日志 +log.name=名称 +log.updateTime=更新时间 +log.owner=负责人 +log.internal=系统内置 +log.dataScope=数据范围 +log.description=描述 +log.syncOrganization=同步组织架构 +log.enable.true=启用 +log.enable.false=禁用 +log.contact=联系人 +log.clueId=公司名称 +#opportunity log +log.amount=金额 +log.possible=可能性 +log.products=产品 +log.contactId=联系人 +log.stage=商机阶段 +log.orderStage=订单状态 +log.rate=赢率 +log.opportunity.status=状态 +log.opportunity.status.true=开启 +log.opportunity.status.false=关闭 +log.approvalStatus=审批状态 +log.approvalStatus.VOIDED=作废 +log.approvalStatus.NONE=无 +log.approvalStatus.APPROVED=通过 +log.approvalStatus.UNAPPROVED=不通过 +log.approvalStatus.APPROVING=审批中 +log.approvalStatus.PENDING=待提审 +log.approvalStatus.REVOKED=撤销 +log.approvalStatus.AUTO_APPROVED=自动通过 +log.approvalStatus.AUTO_UNAPPROVED=自动驳回 +log.customerId=客户名称 +log.collectionTime=领取时间 +log.expectedEndTime=结束时间 +log.actualEndTime=实际结束时间 +log.failureReason=失败原因 +log.deBoardEnable=仪表板开关 +log.weComEnable=企业微信开关 +log.deAccount=账号 +log.deModuleEmbedding=嵌入模块 +log.deLinkIntegration=外链集成 +log.deAutoSync=自动同步 +log.deAccessKey=AccessKey +log.deSecretKey=SecretKey +log.deOrgID=DE组织id +log.qccEnable=企查查开关 +log.qccAddress=企查查地址 +log.qccAccessKey=AccessKey +log.qccSecretKey=SecretKey +COMPETITOR_CHOSEN=客户选择竞品 +PROJECT_FAILED=立项失败 +COMPLEX_DECISION_CHAIN=决策链复杂 +BUDGET_LIMITATION=预算限制 +REQUIREMENT_CHANGE=需求变化 +CREATE=新建 +CLEAR_REQUIREMENTS=需求明确 +SCHEME_VALIDATION=方案验证 +PROJECT_PROPOSAL_REPORT=项目汇报 +BUSINESS_PROCUREMENT=商务采购 +SUCCESS=成功 +FAIL=失败 +# user +log.userName=姓名 +log.enable=状态 +log.gender=性别 +log.phone=手机号 +log.email=邮箱 +log.departmentId=部门 +log.supervisorId=直属上级 +log.onboardingDate=入职日期 +log.employeeId=工号 +log.roles=角色 +log.position=职位 +log.employeeType=员工类型 +log.workCity=工作城市 +log.commander=负责人 +log.userPassword=密码 +null=- +#module log +log.module.switch=模块开关 +log.module.form.operate.btn=表单操作按钮 +#Announcement +log.subject=公告标题 +log.content=公告内容 +log.startTime=开始时间 +log.endTime=结束时间 +log.url=链接 +log.renameUrl=重命名链接 +log.receiver=接收者 +#message +log.event=通知事件类型 +log.emailEnable=邮件发送开关 +log.sysEnable=系统开关 +log.ownerEnable=负责人选项 +log.ownerLevel=部门负责人层级 +log.roleEnable=角色选项 +log.userIdNames=通知人员名称 +log.roleIdNames=角色 +log.times=时间配置 +#system log +#email +log.host=SMTP 主机 +log.port=SMTP 端口 +log.account=SMTP账号 +log.password=SMTP密码 +log.ssl=SSL开关 +log.tsl=TSL开关 +log.from=指定发件人 +log.recipient=指定收件人 +#third +log.type=应用类型 +log.corpId=企业 ID +log.agentId=应用 ID +log.appKey=应用 key +log.appSecret=应用密钥 +log.syncEnable=同步开关 +log.qrcodeEnable=扫码登录开关 +log.sqlBotAppSecret=嵌入脚本 +log.sqlBotBoardEnable=仪表板开关 +log.sqlBotChatEnable=问数开关 +log.startEnable=同步用户 +log.oauth2Enable=OAuth2 开关 +log.appId=内部应用 ID +log.verify=验证通过开关 +log.redirectUrl=回调地址 +log.configuration=认证源配置 +log.deAppId=APP ID +log.deAppSecret=APP Secret +log.deUrl=DataEase 地址 +log.thirdType=应用类型 +log.dingTalkEnable=钉钉通知 +log.larkEnable=飞书通知 +log.websiteLogo=网站 Logo +log.loginLogo=登录 Logo +log.loginBackground=登录背景图 +log.slogan=标语 +log.websiteName=网站名称 +log.logo=Logo +log.systemName=系统名称 +log.helpDocs=帮助文档 +log.style=主题色 +log.theme=背景色 +ui.setting.default=默认 +ui.setting.follow=跟随主题色 +#product +log.price=产品价格 +log.status=产品状态 +product.import_tpl.name=产品导入模板 +#common +common.picture.changed=图片变更 +common.attachment.changed=附件变更 +common.field_value.repeat={0} 字段存在重复值! +common.option.not_exist=选项不存在! +log.follow_record_content=跟进内容 +log.estimated_time=预计开始时间 +log.followMethod=跟进方式 +log.followTime=跟进时间 +log.opportunityId=商机 +log.estimatedTime=预计开始时间 +log.method=跟进方式 +log.converted=转为记录 +#ExportTask +task_already_stopped=取消失败,任务已完成导出 +task_not_found=取消失败,任务已过期或不存在 +user_export_task_limit=当前有10个任务正在导出,为确保文件导出质量,建议您5分钟后再试! +export_head_list_is_empty=表头信息不能为空 +export_select_ids_is_empty=勾选的数据 id 集合不能为空 +#license +license_valid_license_error=授权认证失败 +license_valid_license_expired=授权已过期 +dashboard_module_name_exist=文件夹名称已存在 +dashboard_module_blank=文件夹不存在 +dashboard_module_default_delete=顶级文件夹不允许删除! +dashboard_module_cannot_delete=文件夹内包含仪表板时不可删除,需手动清空仪表板 +dashboard_name_exist=仪表板名称已存在 +dashboard_blank=仪表板不存在 +top_dashboard_module_not_support_add=顶级文件夹不支持添加仪表板 +log.dashboardModuleName=文件夹 +log.members=可查看成员 +log.resourceId=仪表板 ID +dashboard_collect_exist=仪表板已收藏,请刷新页面! +view_blank=视图不存在,或已被刪除! +#dict +dict.not_exist=字典不存在 +system.auto.recycle=系统自动回收 +system.auto.closed=系统自动关闭 +# import +sheet.data=数据 +sheet.comment=填写须知 +format.preview=格式预览 +keep.decimal.places=整数限10位,小数限4位 +phone.tips=支持"(区号)+手机号","中国大陆+86,中国香港+852,中国澳门+853,中国台湾+886" +option=支持选项 +location.c=国家 +location.p=省 +location.pc=省-市 +location.pcd=省-市-区 +location.pcd.detail=省-市-区-详细地址 +industry.tips=门类-大类 (参考《国民经济行业分类》) +row.error.tip=第{0}行错误: +cell.not.unique=不唯一 +import.error=导入异常,第{0}行解析失败: +illegal_header=表头信息有误,{0}缺失 +over.length=长度超过{0} +phone.wrong.format=格式不对 +password_reset_error=初始密码错误,修改密码失败! +file_name_illegal=文件名不合法:不允许包含字符 / +search.config.empty=匹配字段不能为空,至少有一个匹配字段 +search.config.limit=单个模块最多允许配置5个匹配字段 +expire_time_not_null=过期时间不能为空! +user_apikey_limit=最多能有5个 Api Key +api_key_not_exist=ApiKey不存在 +agent_name_exist=智能体名称已存在 +agent_module_blank=文件夹不存在 +agent_blank=智能体不存在 +agent_collect_exist=智能体已收藏,请刷新页面! +agent_module_cannot_delete=文件夹内包含智能体时不可删除,需手动清空智能体 +permission.agent_management.name=智能体 +permission.tender_management.name=标讯 +permission.agent.name=智能体 +permission.tender.name=标讯 +log.agentModuleName=文件夹 +log.script=脚本 +top.navigation=顶部导航 +form.view.size=表单视图 +small=小 +medium=中 +large=大 +search=搜索 +notify=消息通知 +about=关于 +language=语言 +help=帮助中心 +event=记录/计划 +opportunity_stage_config_list=商机阶段配置不能超过10个 +opportunity_stage_setting=商机阶段设置 +opportunity_stage_delete=当前商机阶段存在商机数据,禁止删除! +opportunity_stage_not_exist=商机阶段不存在 +opportunity_stage_at_least_one=进行中商机阶段至少保留一个 +order_stage_config_list=订单状态流设置不能超过10个 +order_stage_setting=订单状态流设置 +order_stage_delete=当前订单状态存在数据,禁止删除! +order_stage_not_exist=订单状态不存在 +order_not_exist=订单不存在 +contract_stage_config_list=合同状态流设置不能超过10个 +contract_stage_setting=合同阶段设置 +contract_stage_delete=当前合同状态存在数据,禁止删除! +log.afootRollBack=进行中阶段回退 +log.endRollBack=完结阶段回退 +dashboard_url_not_allowed=仪表板地址拒绝访问 +log.mkAddress=MaxKB 地址 +log.apiKey=API Key +log.mkEnable=状态 +price=价格表 +quotation=报价 +contract=合同 +contractPaymentPlan=回款计划 +#contract +contract.not.exist=合同不存在 +contract.has.payment.record=合同已有回款记录,无法删除! +contract.has.invoice.cannot.delete=合同已有关联发票,无法删除! +contract.archived.cannot.edit=合同已归档,无法编辑! +contract.void.cannot.edit=合同已作废,无法编辑! +contract.archived.cannot.delete=合同已归档,无法删除! +contract.archived.cannot.voided=合同已归档,无法作废! +contract.unapproved.cannot.edit=合同未通过,无法更新合同阶段! +contract.payment_plan.status.pending=未完成 +contract.payment_plan.status.partially_completed=部分完成 +contract.payment_plan.status.completed=已完成 +contract.form.config.required=合同表单配置不能为空 +contract.field.required=合同字段不能为空 +contract.approval_status.approved=已通过 +contract.approval_status.approving=审批中 +contract.approval_status.unapproved=已驳回 +contract.approval_status.revoked=已撤销 +contract.approval_status.pending=待提审 +contract.approval_status.none=- +contract.archived_status.archived=已归档 +contract.archived_status.un_archived=未归档 +contract.stage.pending_signing=待签署 +contract.stage.signed=已签署 +contract.stage.change=合同变更 +contract.stage.in_progress=履行中 +contract.stage.completed_performance=履行完毕 +contract.stage.void=作废 +contract.stage.archived=归档 +log.planStatus=计划状态 +log.contractStage=合同阶段 +contract.amount.exceed.max=合同金额超出最大限制! +contract.name.required=合同不能为空 +contract.customer.required=客户不能为空 +owner.required=负责人不能为空 +sum=汇总 +data.permission=数据有变更,请刷新! +invoice=发票 +invoice.form.config.required=发票表单配置不能为空 +invoice.field.required=发票字段不能为空 +invoice.amount.exceed=发票总金额超过合同金额! +permission.contract.invoice.name=发票记录 +resource.not.exist=资源不存在 +business_title.not.exist=工商抬头不存在 +business_title.exist=工商抬头已存在 +business_title_import_tpl.name=工商抬头导入模板 +# contract payment record +record.amount.illegal=回款金额需大于0 +record.not.exist=回款记录不存在 +payment.record.import_tpl.name=回款记录导入模板 +custom_form_data.import_tpl.name={0}导入模板 +log.identificationNumber=纳税人识别号 +log.openingBank=开户银行 +log.bankAccount=银行账户 +log.registrationAddress=注册地址 +log.phoneNumber=注册电话 +log.registeredCapital=注册资本 +log.companySize=公司规模 +log.registrationNumber=工商注册账号 +log.city=市 +log.province=省 +log.remark=备注 +log.scale=企业规模 +log.industry=国标行业 +log.companyName=公司名称 +log.global.search=高级搜索开关 +THIRD_PARTY=第三方 +CUSTOM=自定义 +log.pool.members=成员 +log.pool.owner=管理员 +log.pool.auto=自动回收 +approval.not.enabled=审批功能未启用 +log.formType=表单类型 +log.createExecute=执行时机-新建 +log.updateExecute=执行时机-编辑 +log.duplicateApproverRule=重复审批人规则 +log.submitterCanRevoke=允许提交人撤销 +log.allowBatchProcess=允许批量处理 +log.allowWithdraw=允许撤回 +log.allowAddSign=允许加签 +log.requireComment=必须填写审批意见 +log.statusPermissions=审批权限 +approval_flow.log.description=流程描述 +approval_flow.form_type.quotation=报价 +approval_flow.form_type.contract=合同 +approval_flow.form_type.invoice=发票 +approval_flow.form_type.order=订单 +approval_flow.enable.true=启用 +approval_flow.enable.false=禁用 +approval_flow.duplicate_approver_rule.first_only=仅首个节点需审批 +approval_flow.duplicate_approver_rule.sequential_all=仅连续审批时自动同意 +approval_flow.duplicate_approver_rule.each=每个节点都需要审批 +log.nodeName=节点名称 +log.nodeType=节点类型 +log.approvalType=审批类型 +log.approverType=审批人类型 +log.approverList=审批人 +log.multiApproverMode=多人审批方式 +log.emptyApproverAction=审批人为空时动作 +log.fallbackApprover=审批人为空时审批人 +log.sameSubmitterAction=审批人与提交人相同时动作 +log.ccType=抄送人类型 +log.ccList=抄送人 +log.passPostConfig=审批通过后配置 +log.rejectPostConfig=审批驳回后配置 +log.fieldPermissions=字段权限配置 +log.conditionConfig=条件配置 +approval_flow.approval_type.manual=人工审批 +approval_flow.approval_type.auto_pass=自动通过 +approval_flow.approval_type.auto_reject=自动驳回 +approval_flow.approver_type.member=指定成员 +approval_flow.approver_type.role=指定角色 +approval_flow.approver_type.superior=直属上级 +approval_flow.approver_type.multiple_superior=连续多级上级 +approval_flow.approver_type.dept_head=部门负责人 +approval_flow.approver_type.multiple_dept_head=连续多级部门负责人 +approval_flow.multi_approver_mode.all=会签(需所有审批人同意) +approval_flow.multi_approver_mode.any=或签(一人同意即可) +approval_flow.multi_approver_mode.sequential=依次审批 +approval_flow.empty_approver_action.auto_pass=自动通过 +approval_flow.empty_approver_action.assign_specific=指定人员审批 +approval_flow.empty_approver_action.assign_admin=转交给审批管理员 +approval_flow.same_submitter_action.allow=由提交人审批 +approval_flow.same_submitter_action.skip=自动跳过 +approval_flow.same_submitter_action.assign_superior=转交给直属上级审批 +approval_flow.node_type.start=开始节点 +approval_flow.node_type.end=结束节点 +approval_flow.node_type.approver=审批节点 +approval_flow.node_type.condition=条件节点 +approval_flow.node_type.default=默认节点 +approval_flow.node_type.exception=异常节点 +approval_flow.level.supervisor.1=第一级上级 +approval_flow.level.supervisor.2=第二级上级 +approval_flow.level.supervisor.3=第三级上级 +approval_flow.level.supervisor.4=第四级上级 +approval_flow.level.supervisor.5=第五级上级 +approval_flow.level.supervisor.6=第六级上级 +approval_flow.level.supervisor.7=第七级上级 +approval_flow.level.supervisor.8=第八级上级 +approval_flow.level.supervisor.9=第九级上级 +approval_flow.level.supervisor.10=第十级上级 +approval_flow.level.department.1=第一级部门 +approval_flow.level.department.2=第二级部门 +approval_flow.level.department.3=第三级部门 +approval_flow.level.department.4=第四级部门 +approval_flow.level.department.5=第五级部门 +approval_flow.level.department.6=第六级部门 +approval_flow.level.department.7=第七级部门 +approval_flow.level.department.8=第八级部门 +approval_flow.level.department.9=第九级部门 +approval_flow.level.department.10=第十级部门 +order=订单 +order.stage=订单状态 +order.stage.create=新建 +order.stage.pending_shipment=待发货 +order.stage.partially_shipped=部分发货 +order.stage.shipped=已发货 +order.stage.pending_acceptance=待验收 +order.stage.completed=已完成 +order.stage.voided=已作废 +number.length.exceed=业务流水号长度超出50个字符,请重新配置生成规则! +ip_address.not_allowed=不允许使用内网或保留地址 +user_export_task_prepared_limit=当前正在准备导出数据,请稍后再试! +approval_flow.not.exist=没有开启的审批流! +approval_flow.type.already.exists=该表单类型的审批流已存在! +approval_flow.field_permission.hidden=隐藏 +approval_flow.field_permission.view=查看 +approval_flow.field_permission.edit=编辑 +sign=加签 +back=退回 +approve=同意 +reject=驳回 +revoke=撤回 +no.back.approval=无法退回 +no.revoke.approval=无法撤销 +no.approval.next.node=未找到后续节点,请检查审批流配置 +auto.approval.passed=自动通过 +auto.approval.rejected=自动驳回 +no.approval.instance=审批实例为空! +batch.update.reason=无权限或违反字段约束! +# custom form +custom.form.not.exist=自定义表单不存在 +custom.form.data.not.exist=自定义表单数据不存在 +custom.form.role.not.exist=自定义表单角色不存在 +custom.form.role.manage_all=管理全部数据 +custom.form.role.view_all=查看全部数据 +custom.form.role.manage_own=管理本人数据 +permission.custom_form.name=自定义表单 +custom.form.name.duplicate=自定义表单名称已存在 +log.adminUserIds=管理员 +log.roleUsers=角色成员 + +webhook.enable=调用 webhook +webhook.describe=说明 +webhook.url=外部系统 Webhook 地址 +webhook.method=请求方式 +webhook.header=请求头 JSON +webhook.body=请求体 JSON +# form prop layout +formProp.layout.1=单列 +formProp.layout.2=双列 +formProp.layout.3=三列 +formProp.layout.4=四列 +# form prop labelPos +formProp.labelPos.top=上下 +formProp.labelPos.left=左右 +# form prop inputWidth +formProp.inputWidth.custom=自定义 +formProp.inputWidth.full=整行 +# form prop optBtnPos +formProp.optBtnPos.flex-row=居左 +formProp.optBtnPos.justify-center=居中 +formProp.optBtnPos.flex-row-reverse=居右 +advanced_circulation_setting=高级流转设置 \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_1__init.sql b/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_2__system_setting.sql b/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_2__system_setting.sql new file mode 100644 index 0000000..e7f2738 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_2__system_setting.sql @@ -0,0 +1,602 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +CREATE TABLE sys_user +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '名称', + `phone` VARCHAR(11) COMMENT '手机号', + `email` VARCHAR(255) COMMENT '邮箱', + `password` VARCHAR(255) NOT NULL COMMENT '密码', + `language` VARCHAR(32) COMMENT '语言', + `last_organization_id` VARCHAR(50) COMMENT '当前组织ID', + `gender` BIT(1) NOT NULL DEFAULT 0 COMMENT '性别(0-男/1-女)', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '用户(员工)' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_email ON sys_user (email DESC); +CREATE INDEX idx_create_time ON sys_user (create_time DESC); +CREATE INDEX idx_update_time ON sys_user (update_time ASC); +CREATE INDEX idx_create_user ON sys_user (create_user ASC); +CREATE INDEX idx_update_user ON sys_user (update_user ASC); + +CREATE TABLE sys_user_extend +( + `id` VARCHAR(32) NOT NULL COMMENT '用户id', + `avatar` VARCHAR(255) COMMENT '头像内容', + `platform_info` LONGBLOB COMMENT '对接平台信息', + PRIMARY KEY (id) +) COMMENT = '用户扩展内容' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS worker_node +( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'auto increment id', + host_name VARCHAR(64) NOT NULL COMMENT 'host name', + port VARCHAR(64) NOT NULL COMMENT 'port', + type INT NOT NULL COMMENT 'node type: ACTUAL or CONTAINER', + launch_date BIGINT NOT NULL COMMENT 'launch date', + modified BIGINT NOT NULL COMMENT 'modified time', + created BIGINT NOT NULL COMMENT 'created time', + PRIMARY KEY (ID) +) + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci COMMENT = 'DB WorkerID Assigner for UID Generator'; + +CREATE TABLE sys_operation_log +( + `id` VARCHAR(32) NOT NULL COMMENT '主键', + `organization_id` VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '组织id', + `type` VARCHAR(32) NOT NULL COMMENT '操作类型/add/update/delete', + `module` VARCHAR(32) NOT NULL COMMENT '操作模块', + `resource_id` VARCHAR(32) NOT NULL COMMENT '资源id', + `resource_name` VARCHAR(255) COMMENT '资源名称', + `detail` VARCHAR(500) COMMENT '操作详情', + `create_time` BIGINT NOT NULL COMMENT '操作时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '操作人', + `path` VARCHAR(255) COMMENT '操作路径', + `method` VARCHAR(255) NOT NULL COMMENT '操作方法', + PRIMARY KEY (id) +) COMMENT = '操作日志' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE sys_login_log +( + `id` VARCHAR(32) NOT NULL COMMENT '主键', + `create_time` BIGINT NOT NULL COMMENT '操作时间', + `operator` VARCHAR(32) NOT NULL COMMENT '操作人', + `login_address` VARCHAR(255) COMMENT '登录地', + `platform` VARCHAR(32) COMMENT '平台', + PRIMARY KEY (id) +) COMMENT = '登入日志' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_operator ON sys_login_log (operator ASC); +CREATE INDEX idx_create_time ON sys_login_log (create_time ASC); + +CREATE INDEX idx_organization_id ON sys_operation_log (organization_id ASC); +CREATE INDEX idx_resource_id ON sys_operation_log (resource_id ASC); +CREATE INDEX idx_type ON sys_operation_log (type ASC); + +CREATE TABLE sys_operation_log_blob +( + `id` VARCHAR(32) NOT NULL COMMENT '主键', + `original_value` LONGBLOB COMMENT '变更前内容', + `modified_value` LONGBLOB COMMENT '变更后内容', + PRIMARY KEY (id) +) COMMENT = '操作日志内容详情' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `schedule` +( + `id` varchar(32) COLLATE utf8mb4_general_ci NOT NULL, + `key` varchar(50) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'qrtz UUID', + `type` varchar(50) COLLATE utf8mb4_general_ci NOT NULL COMMENT '执行类型 cron', + `value` varchar(255) COLLATE utf8mb4_general_ci NOT NULL COMMENT 'cron 表达式', + `job` varchar(64) COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Schedule Job Class Name', + `resource_type` varchar(50) COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'NONE' COMMENT '资源类型 API_IMPORT,API_SCENARIO,UI_SCENARIO,LOAD_TEST,TEST_PLAN,CLEAN_REPORT,BUG_SYNC', + `enable` bit(1) DEFAULT NULL COMMENT '是否开启', + `resource_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '资源ID,api_scenario ui_scenario load_test', + `create_user` varchar(32) COLLATE utf8mb4_general_ci NOT NULL COMMENT '创建人', + `create_time` bigint NOT NULL COMMENT '创建时间', + `update_time` bigint NOT NULL COMMENT '更新时间', + `organization_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '组织ID', + `name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '名称', + `config` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '配置', + `num` bigint NOT NULL COMMENT '业务ID', + PRIMARY KEY (`id`), + KEY `idx_resource_id` (`resource_id`), + KEY `idx_create_user` (`create_user`), + KEY `idx_create_time` (`create_time` DESC), + KEY `idx_update_time` (`update_time` DESC), + KEY `idx_organization_id` (`organization_id`), + KEY `idx_enable` (`enable`), + KEY `idx_name` (`name`), + KEY `idx_type` (`type`), + KEY `idx_resource_type` (`resource_type`), + KEY `idx_num` (`num`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci COMMENT ='定时任务'; + + +CREATE TABLE IF NOT EXISTS `user_key` +( + `id` varchar(32) COLLATE utf8mb4_general_ci NOT NULL COMMENT 'user_key ID', + `create_user` varchar(32) COLLATE utf8mb4_general_ci NOT NULL COMMENT '用户ID', + `access_key` varchar(50) COLLATE utf8mb4_general_ci NOT NULL COMMENT 'access_key', + `secret_key` varchar(50) COLLATE utf8mb4_general_ci NOT NULL COMMENT 'secret key', + `create_time` bigint NOT NULL COMMENT '创建时间', + `enable` bit(1) NOT NULL DEFAULT b'1' COMMENT '状态', + `forever` bit(1) NOT NULL DEFAULT b'1' COMMENT '是否永久有效', + `expire_time` bigint DEFAULT NULL COMMENT '到期时间', + `description` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_ak` (`access_key`), + KEY `idx_create_user` (`create_user`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci COMMENT ='用户api key'; + + +CREATE TABLE IF NOT EXISTS sys_notification +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `type` VARCHAR(64) NOT NULL COMMENT '通知类型', + `receiver` VARCHAR(32) NOT NULL COMMENT '接收人', + `subject` VARCHAR(255) NOT NULL COMMENT '标题', + `status` VARCHAR(64) NOT NULL COMMENT '状态', + `operator` VARCHAR(32) NOT NULL COMMENT '操作人', + `operation` VARCHAR(50) NOT NULL COMMENT '操作', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `resource_id` VARCHAR(32) COMMENT '资源ID', + `resource_type` VARCHAR(64) NOT NULL COMMENT '资源类型', + `resource_name` VARCHAR(255) NOT NULL COMMENT '资源名称', + `content` BLOB NOT NULL COMMENT '通知内容', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '消息通知' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + + + +CREATE INDEX idx_receiver ON sys_notification (receiver ASC); +CREATE INDEX idx_create_time ON sys_notification (create_time DESC); +CREATE INDEX idx_type ON sys_notification (type ASC); +CREATE INDEX idx_subject ON sys_notification (subject ASC); +CREATE INDEX idx_resource_type ON sys_notification (resource_type ASC); +CREATE INDEX idx_operator ON sys_notification (operator ASC); +CREATE INDEX idx_organization_id ON sys_notification (organization_id ASC); +CREATE INDEX idx_resource_id ON sys_notification (resource_id ASC); + + +CREATE TABLE sys_message_task +( + `id` VARCHAR(50) NOT NULL COMMENT '', + `event` VARCHAR(255) NOT NULL COMMENT '通知事件类型', + `task_type` VARCHAR(64) NOT NULL COMMENT '任务类型', + `email_enable` BIT NOT NULL DEFAULT 0 COMMENT '邮件启用', + `sys_enable` BIT NOT NULL DEFAULT 0 COMMENT '系统启用', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `template` BLOB COMMENT '消息模版', + `create_user` VARCHAR(50) NOT NULL COMMENT '创建人', + `create_time` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `update_user` VARCHAR(50) NOT NULL COMMENT '修改人', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + PRIMARY KEY (id) +) COMMENT = '消息通知任务' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + + +CREATE INDEX idx_create_time ON sys_message_task (create_time DESC); +CREATE INDEX idx_task_type ON sys_message_task (task_type ASC); +CREATE INDEX idx_event ON sys_message_task (event ASC); +CREATE INDEX idx_organization_id ON sys_message_task (organization_id ASC); +CREATE INDEX idx_email_enable ON sys_message_task (email_enable ASC); +CREATE INDEX idx_sys_enable ON sys_message_task (sys_enable ASC); + +CREATE TABLE sys_announcement +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `subject` VARCHAR(255) NOT NULL COMMENT '公告标题', + `content` BLOB NOT NULL COMMENT '公告内容', + `start_time` BIGINT NOT NULL COMMENT '开始时间', + `end_time` BIGINT NOT NULL COMMENT '结束时间', + `url` VARCHAR(255) COMMENT '链接', + `rename_url` VARCHAR(255) COMMENT '重命名链接', + `receiver` BLOB NOT NULL COMMENT '接收人id(销售ids/角色ids/部门ids)', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `notice` BIT NOT NULL DEFAULT 0 COMMENT '转为通知', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + `receive_type` BLOB NOT NULL COMMENT '接收类型组合', + PRIMARY KEY (id) +) COMMENT = '公告' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organizationId ON sys_announcement (organization_id ASC); +CREATE INDEX idx_create_time ON sys_announcement (create_time DESC); + +CREATE TABLE sys_organization +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '名称', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '组织架构' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE sys_organization_user +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `department_id` VARCHAR(32) COMMENT '部门id', + `resource_user_id` VARCHAR(64) COMMENT '三方唯一id', + `user_id` VARCHAR(32) NOT NULL COMMENT '用户id', + `enable` BIT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + `employee_id` VARCHAR(255) DEFAULT '' COMMENT '工号', + `position` VARCHAR(255) DEFAULT '' COMMENT '职位', + `employee_type` VARCHAR(255) DEFAULT '' COMMENT '员工类型', + `supervisor_id` VARCHAR(32) DEFAULT '' COMMENT '直属上级', + `work_city` VARCHAR(255) DEFAULT '' COMMENT '工作城市', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '组织成员' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON sys_organization_user (organization_id ASC); +CREATE INDEX idx_department_id ON sys_organization_user (department_id ASC); +CREATE INDEX idx_user_id ON sys_organization_user (user_id ASC); + +CREATE TABLE sys_role +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '角色名称', + `internal` BIT NOT NULL DEFAULT 0 COMMENT '是否是内置角色', + `data_scope` VARCHAR(30) NOT NULL COMMENT '数据范围(全部数据权限/指定部门权限/本部门数据权限/本部门及以下数据权限/仅本人数据)', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + `description` VARCHAR(1000) COMMENT '描述', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + PRIMARY KEY (id) +) COMMENT = '角色' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE sys_role_permission +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `role_id` VARCHAR(32) NOT NULL COMMENT '角色id', + `permission_id` VARCHAR(255) NOT NULL COMMENT '权限id', + PRIMARY KEY (id) +) COMMENT = '角色权限' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_role_id ON sys_role_permission (role_id ASC); + +CREATE TABLE sys_role_scope_dept +( + `id` VARCHAR(32) NOT NULL COMMENT 'ID', + `role_id` VARCHAR(32) NOT NULL COMMENT '角色ID', + `department_id` VARCHAR(32) NOT NULL COMMENT '部门ID', + PRIMARY KEY (id) +) COMMENT = '角色与部门的关联表,角色 data_scope 为指定部门时使用' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE sys_user_role +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `role_id` VARCHAR(32) NOT NULL COMMENT '角色id', + `user_id` VARCHAR(32) NOT NULL COMMENT '用户id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '用户关联角色' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_role_id ON sys_user_role (role_id ASC); +CREATE INDEX idx_user_id ON sys_user_role (user_id ASC); + +CREATE TABLE sys_organization_config_detail +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `config_id` VARCHAR(32) NOT NULL COMMENT '配置id', + `type` VARCHAR(64) NOT NULL COMMENT '配置内容类型', + `enable` BIT NOT NULL DEFAULT 0 COMMENT '是否启用', + `name` VARCHAR(255) NOT NULL DEFAULT 'NONE' COMMENT '配置名称', + `content` BLOB NOT NULL COMMENT '配置内容', + `description` VARCHAR(1000) COMMENT '描述', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '企业设置详情表' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_config_id ON sys_organization_config_detail (config_id ASC); +CREATE INDEX idx_type ON sys_organization_config_detail (type ASC); +CREATE INDEX idx_name ON sys_organization_config_detail (name ASC); + +CREATE TABLE sys_organization_config +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `type` VARCHAR(64) NOT NULL COMMENT '配置类型', + `organization_id` VARCHAR(32) NOT NULL COMMENT '企业id', + `sync` BIT(1) NOT NULL DEFAULT 0 COMMENT '是否同步(只对同步企业生效)', + `sync_resource` VARCHAR(255) COMMENT '同步来源', + `enable` BIT(1) NOT NULL DEFAULT 0 COMMENT '是否开启', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + + PRIMARY KEY (id) +) COMMENT = '企业设置' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organizationId ON sys_organization_config (organization_id ASC); +CREATE INDEX idx_type ON sys_organization_config (type ASC); + + +CREATE TABLE sys_department +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '名称', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `parent_id` VARCHAR(32) NOT NULL COMMENT '父级', + `pos` BIGINT NOT NULL COMMENT '排序', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + `resource` VARCHAR(255) NOT NULL COMMENT '来源', + `resource_id` VARCHAR(255) COMMENT '来源id', + PRIMARY KEY (id) +) COMMENT = '部门表' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource ON sys_department (resource ASC); +CREATE INDEX idx_organization_id ON sys_department (organization_id ASC); +CREATE INDEX idx_parent_id ON sys_department (parent_id ASC); + + +CREATE TABLE sys_department_commander +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `user_id` VARCHAR(32) NOT NULL COMMENT '用户id', + `department_id` VARCHAR(32) NOT NULL COMMENT '部门id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '部门责任人表' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_user_id ON sys_department_commander (user_id ASC); +CREATE INDEX idx_department_id ON sys_department_commander (department_id ASC); + +## module setting +CREATE TABLE sys_module +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `module_key` VARCHAR(20) NOT NULL COMMENT '模块KEY', + `enable` BIT(1) NOT NULL DEFAULT 1 COMMENT '启用/禁用', + `pos` BIGINT NOT NULL COMMENT '自定义排序', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_user` VARCHAR(32) NOT NULL COMMENT '修改人', + `update_time` BIGINT NOT NULL COMMENT '修改时间', + PRIMARY KEY (id) +) COMMENT = '模块配置' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON sys_module (organization_id ASC); + +CREATE TABLE sys_module_form +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `form_key` VARCHAR(20) NOT NULL COMMENT '表单Key', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '模块表单配置' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON sys_module_form (organization_id ASC); + +CREATE TABLE sys_module_form_blob +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `prop` TEXT COMMENT '属性', + PRIMARY KEY (id) +) COMMENT = '模块表单属性配置' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE sys_module_field +( + `id` VARCHAR(32) NOT NULL COMMENT 'ID', + `form_id` VARCHAR(32) NOT NULL COMMENT '所属表单ID', + `internal_key` VARCHAR(255) COMMENT '字段内置Key', + `name` VARCHAR(255) NOT NULL COMMENT '名称', + `type` VARCHAR(20) NOT NULL COMMENT '类型', + `mobile` BIT(1) NOT NULL DEFAULT 0 COMMENT '是否移动端', + `pos` BIGINT NOT NULL COMMENT '排序', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + PRIMARY KEY (id) +) COMMENT = '模块字段配置' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_form_id_internal_key ON sys_module_field (form_id, internal_key); +CREATE INDEX idx_mobile ON sys_module_field (mobile ASC); + +CREATE TABLE sys_module_field_blob +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `prop` TEXT COMMENT '属性', + PRIMARY KEY (id) +) COMMENT = '模块字段属性配置' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE sys_parameter +( + `param_key` VARCHAR(100) NOT NULL COMMENT 'key', + `param_value` VARCHAR(255) COMMENT 'value', + `type` VARCHAR(100) NOT NULL DEFAULT 'text' COMMENT 'type', + PRIMARY KEY (param_key) +) COMMENT = '系统参数' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE product +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '名称', + `price` DECIMAL(14, 4) COMMENT '价格', + `status` VARCHAR(32) NOT NULL COMMENT '状态', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织机构id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '产品' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON product (organization_id ASC); +CREATE INDEX idx_name ON product (name ASC); + + +CREATE TABLE product_field +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '产品id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` VARCHAR(255) NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '产品自定义属性' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id_field_id_field_value ON product_field (resource_id, field_id, field_value); + +CREATE TABLE product_field_blob +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '产品id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` TEXT NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '客户自定义属性大文本' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON product_field_blob (resource_id); + +CREATE TABLE sys_attachment +( + `id` VARCHAR(32) NOT NULL COMMENT 'ID', + `name` VARCHAR(255) NOT NULL COMMENT '名称', + `type` VARCHAR(50) COMMENT '类型', + `size` BIGINT(255) COMMENT '大小', + `storage` VARCHAR(50) NOT NULL COMMENT '存储方式', + `resource_id` VARCHAR(32) COMMENT '资源ID', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织ID', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '系统附件' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + + +CREATE INDEX idx_storage ON sys_attachment (storage ASC); +CREATE INDEX idx_org_id ON sys_attachment (organization_id ASC); +CREATE INDEX idx_resource_id ON sys_attachment (resource_id ASC); + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; + + diff --git a/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_3__qrtz_schema.sql b/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_3__qrtz_schema.sql new file mode 100644 index 0000000..6b38c4c --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_3__qrtz_schema.sql @@ -0,0 +1,190 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- quartz start +CREATE TABLE `QRTZ_JOB_DETAILS` +( + `SCHED_NAME` VARCHAR(120) NOT NULL, + `JOB_NAME` VARCHAR(200) NOT NULL, + `JOB_GROUP` VARCHAR(200) NOT NULL, + `DESCRIPTION` VARCHAR(250) DEFAULT NULL, + `JOB_CLASS_NAME` VARCHAR(250) NOT NULL, + `IS_DURABLE` VARCHAR(1) NOT NULL, + `IS_NONCONCURRENT` VARCHAR(1) NOT NULL, + `IS_UPDATE_DATA` VARCHAR(1) NOT NULL, + `REQUESTS_RECOVERY` VARCHAR(1) NOT NULL, + `JOB_DATA` BLOB, + PRIMARY KEY (`SCHED_NAME`, `JOB_NAME`, `JOB_GROUP`), + KEY `IDX_QRTZ_J_REQ_RECOVERY` (`SCHED_NAME`, `REQUESTS_RECOVERY`), + KEY `IDX_QRTZ_J_GRP` (`SCHED_NAME`, `JOB_GROUP`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE utf8mb4_general_ci; + +CREATE TABLE `QRTZ_TRIGGERS` +( + `SCHED_NAME` VARCHAR(120) NOT NULL, + `TRIGGER_NAME` VARCHAR(200) NOT NULL, + `TRIGGER_GROUP` VARCHAR(200) NOT NULL, + `JOB_NAME` VARCHAR(200) NOT NULL, + `JOB_GROUP` VARCHAR(200) NOT NULL, + `DESCRIPTION` VARCHAR(250) DEFAULT NULL, + `NEXT_FIRE_TIME` BIGINT DEFAULT NULL, + `PREV_FIRE_TIME` BIGINT DEFAULT NULL, + `PRIORITY` INT DEFAULT NULL, + `TRIGGER_STATE` VARCHAR(16) NOT NULL, + `TRIGGER_TYPE` VARCHAR(8) NOT NULL, + `START_TIME` BIGINT NOT NULL, + `END_TIME` BIGINT DEFAULT NULL, + `CALENDAR_NAME` VARCHAR(200) DEFAULT NULL, + `MISFIRE_INSTR` SMALLINT DEFAULT NULL, + `JOB_DATA` BLOB, + PRIMARY KEY (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`), + KEY `IDX_QRTZ_T_J` (`SCHED_NAME`, `JOB_NAME`, `JOB_GROUP`), + KEY `IDX_QRTZ_T_JG` (`SCHED_NAME`, `JOB_GROUP`), + KEY `IDX_QRTZ_T_C` (`SCHED_NAME`, `CALENDAR_NAME`), + KEY `IDX_QRTZ_T_G` (`SCHED_NAME`, `TRIGGER_GROUP`), + KEY `IDX_QRTZ_T_STATE` (`SCHED_NAME`, `TRIGGER_STATE`), + KEY `IDX_QRTZ_T_N_STATE` (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`, `TRIGGER_STATE`), + KEY `IDX_QRTZ_T_N_G_STATE` (`SCHED_NAME`, `TRIGGER_GROUP`, `TRIGGER_STATE`), + KEY `IDX_QRTZ_T_NEXT_FIRE_TIME` (`SCHED_NAME`, `NEXT_FIRE_TIME`), + KEY `IDX_QRTZ_T_NFT_ST` (`SCHED_NAME`, `TRIGGER_STATE`, `NEXT_FIRE_TIME`), + KEY `IDX_QRTZ_T_NFT_MISFIRE` (`SCHED_NAME`, `MISFIRE_INSTR`, `NEXT_FIRE_TIME`), + KEY `IDX_QRTZ_T_NFT_ST_MISFIRE` (`SCHED_NAME`, `MISFIRE_INSTR`, `NEXT_FIRE_TIME`, `TRIGGER_STATE`), + KEY `IDX_QRTZ_T_NFT_ST_MISFIRE_GRP` (`SCHED_NAME`, `MISFIRE_INSTR`, `NEXT_FIRE_TIME`, `TRIGGER_GROUP`, + `TRIGGER_STATE`), + CONSTRAINT `qrtz_triggers_ibfk_1` FOREIGN KEY (`SCHED_NAME`, `JOB_NAME`, `JOB_GROUP`) REFERENCES `QRTZ_JOB_DETAILS` (`SCHED_NAME`, `JOB_NAME`, `JOB_GROUP`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE utf8mb4_general_ci; + +CREATE TABLE `QRTZ_BLOB_TRIGGERS` +( + `SCHED_NAME` VARCHAR(120) NOT NULL, + `TRIGGER_NAME` VARCHAR(200) NOT NULL, + `TRIGGER_GROUP` VARCHAR(200) NOT NULL, + `BLOB_DATA` BLOB, + PRIMARY KEY (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`), + KEY `SCHED_NAME` (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`), + CONSTRAINT `qrtz_blob_triggers_ibfk_1` FOREIGN KEY (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`) REFERENCES `QRTZ_TRIGGERS` (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE utf8mb4_general_ci; + +CREATE TABLE `QRTZ_CALENDARS` +( + `SCHED_NAME` VARCHAR(120) NOT NULL, + `CALENDAR_NAME` VARCHAR(200) NOT NULL, + `CALENDAR` BLOB NOT NULL, + PRIMARY KEY (`SCHED_NAME`, `CALENDAR_NAME`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE utf8mb4_general_ci; + +CREATE TABLE `QRTZ_CRON_TRIGGERS` +( + `SCHED_NAME` VARCHAR(120) NOT NULL, + `TRIGGER_NAME` VARCHAR(200) NOT NULL, + `TRIGGER_GROUP` VARCHAR(200) NOT NULL, + `CRON_EXPRESSION` VARCHAR(120) NOT NULL, + `TIME_ZONE_ID` VARCHAR(80) DEFAULT NULL, + PRIMARY KEY (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`), + CONSTRAINT `qrtz_cron_triggers_ibfk_1` FOREIGN KEY (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`) REFERENCES `QRTZ_TRIGGERS` (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE utf8mb4_general_ci; + +CREATE TABLE `QRTZ_FIRED_TRIGGERS` +( + `SCHED_NAME` VARCHAR(120) NOT NULL, + `ENTRY_ID` VARCHAR(95) NOT NULL, + `TRIGGER_NAME` VARCHAR(200) NOT NULL, + `TRIGGER_GROUP` VARCHAR(200) NOT NULL, + `INSTANCE_NAME` VARCHAR(200) NOT NULL, + `FIRED_TIME` BIGINT NOT NULL, + `SCHED_TIME` BIGINT NOT NULL, + `PRIORITY` INT NOT NULL, + `STATE` VARCHAR(16) NOT NULL, + `JOB_NAME` VARCHAR(200) DEFAULT NULL, + `JOB_GROUP` VARCHAR(200) DEFAULT NULL, + `IS_NONCONCURRENT` VARCHAR(1) DEFAULT NULL, + `REQUESTS_RECOVERY` VARCHAR(1) DEFAULT NULL, + PRIMARY KEY (`SCHED_NAME`, `ENTRY_ID`), + KEY `IDX_QRTZ_FT_TRIG_INST_NAME` (`SCHED_NAME`, `INSTANCE_NAME`), + KEY `IDX_QRTZ_FT_INST_JOB_REQ_RCVRY` (`SCHED_NAME`, `INSTANCE_NAME`, `REQUESTS_RECOVERY`), + KEY `IDX_QRTZ_FT_J_G` (`SCHED_NAME`, `JOB_NAME`, `JOB_GROUP`), + KEY `IDX_QRTZ_FT_JG` (`SCHED_NAME`, `JOB_GROUP`), + KEY `IDX_QRTZ_FT_T_G` (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`), + KEY `IDX_QRTZ_FT_TG` (`SCHED_NAME`, `TRIGGER_GROUP`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE utf8mb4_general_ci; + +CREATE TABLE `QRTZ_LOCKS` +( + `SCHED_NAME` VARCHAR(120) NOT NULL, + `LOCK_NAME` VARCHAR(40) NOT NULL, + PRIMARY KEY (`SCHED_NAME`, `LOCK_NAME`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE utf8mb4_general_ci; + +CREATE TABLE `QRTZ_PAUSED_TRIGGER_GRPS` +( + `SCHED_NAME` VARCHAR(120) NOT NULL, + `TRIGGER_GROUP` VARCHAR(200) NOT NULL, + PRIMARY KEY (`SCHED_NAME`, `TRIGGER_GROUP`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE utf8mb4_general_ci; + +CREATE TABLE `QRTZ_SCHEDULER_STATE` +( + `SCHED_NAME` VARCHAR(120) NOT NULL, + `INSTANCE_NAME` VARCHAR(200) NOT NULL, + `LAST_CHECKIN_TIME` BIGINT NOT NULL, + `CHECKIN_INTERVAL` BIGINT NOT NULL, + PRIMARY KEY (`SCHED_NAME`, `INSTANCE_NAME`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE utf8mb4_general_ci; + +CREATE TABLE `QRTZ_SIMPLE_TRIGGERS` +( + `SCHED_NAME` VARCHAR(120) NOT NULL, + `TRIGGER_NAME` VARCHAR(200) NOT NULL, + `TRIGGER_GROUP` VARCHAR(200) NOT NULL, + `REPEAT_COUNT` BIGINT NOT NULL, + `REPEAT_INTERVAL` BIGINT NOT NULL, + `TIMES_TRIGGERED` BIGINT NOT NULL, + PRIMARY KEY (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`), + CONSTRAINT `qrtz_simple_triggers_ibfk_1` FOREIGN KEY (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`) REFERENCES `QRTZ_TRIGGERS` (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE utf8mb4_general_ci; + +CREATE TABLE `QRTZ_SIMPROP_TRIGGERS` +( + `SCHED_NAME` VARCHAR(120) NOT NULL, + `TRIGGER_NAME` VARCHAR(200) NOT NULL, + `TRIGGER_GROUP` VARCHAR(200) NOT NULL, + `STR_PROP_1` VARCHAR(512) DEFAULT NULL, + `STR_PROP_2` VARCHAR(512) DEFAULT NULL, + `STR_PROP_3` VARCHAR(512) DEFAULT NULL, + `INT_PROP_1` INT DEFAULT NULL, + `INT_PROP_2` INT DEFAULT NULL, + `LONG_PROP_1` BIGINT DEFAULT NULL, + `LONG_PROP_2` BIGINT DEFAULT NULL, + `DEC_PROP_1` DECIMAL(13, 4) DEFAULT NULL, + `DEC_PROP_2` DECIMAL(13, 4) DEFAULT NULL, + `BOOL_PROP_1` VARCHAR(1) DEFAULT NULL, + `BOOL_PROP_2` VARCHAR(1) DEFAULT NULL, + PRIMARY KEY (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`), + CONSTRAINT `qrtz_simprop_triggers_ibfk_1` FOREIGN KEY (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`) REFERENCES `QRTZ_TRIGGERS` (`SCHED_NAME`, `TRIGGER_NAME`, `TRIGGER_GROUP`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE utf8mb4_general_ci; + + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_4__customer.sql b/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_4__customer.sql new file mode 100644 index 0000000..1bb08e2 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_4__customer.sql @@ -0,0 +1,232 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +CREATE TABLE customer +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '客户名称', + `owner` VARCHAR(32) COMMENT '负责人', + `collection_time` BIGINT(255) COMMENT '领取时间', + `pool_id` VARCHAR(32) COMMENT '公海ID', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + `in_shared_pool` BIT(1) NOT NULL DEFAULT 0 COMMENT '是否在公海池', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `follower` VARCHAR(32) COMMENT '最新跟进人', + `follow_time` BIGINT COMMENT '最新跟进时间', + PRIMARY KEY (id) +) COMMENT = '客户' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON customer (organization_id ASC); +CREATE INDEX idx_pool_id ON customer (pool_id ASC); +CREATE INDEX idx_follower ON customer (follower ASC); +CREATE INDEX idx_follow_time ON customer (follow_time ASC); + +CREATE TABLE customer_pool +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `scope_id` TEXT NOT NULL COMMENT '范围ID', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织ID', + `name` VARCHAR(255) NOT NULL COMMENT '公海池名称', + `owner_id` TEXT NOT NULL COMMENT '管理员ID', + `enable` BIT(1) NOT NULL DEFAULT 1 COMMENT '启用/禁用', + `auto` BIT(1) NOT NULL DEFAULT 0 COMMENT '自动回收', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '公海池' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON customer_pool (organization_id ASC); + +CREATE TABLE customer_pool_pick_rule +( + `id` VARCHAR(32) NOT NULL COMMENT 'ID', + `pool_id` VARCHAR(32) NOT NULL COMMENT '公海池ID', + `limit_on_number` BIT(1) NOT NULL DEFAULT 1 COMMENT '是否限制领取数量', + `pick_number` INT COMMENT '领取数量', + `limit_pre_owner` BIT(1) NOT NULL DEFAULT 1 COMMENT '是否限制前归属人领取', + `pick_interval_days` INT COMMENT '领取间隔天数', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + PRIMARY KEY (id) +) COMMENT = '公海池领取规则' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_pool_id ON customer_pool_pick_rule (pool_id ASC); + +CREATE TABLE customer_pool_recycle_rule +( + `id` VARCHAR(32) NOT NULL COMMENT 'ID', + `pool_id` VARCHAR(32) NOT NULL COMMENT '公海池ID', + `operator` VARCHAR(10) COMMENT '操作符', + `condition` TEXT COMMENT '回收条件', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '公海池回收规则' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_pool_id ON customer_pool_recycle_rule (pool_id ASC); + +CREATE TABLE customer_capacity +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织架构ID', + `scope_id` TEXT NOT NULL COMMENT '范围ID', + `capacity` INT COMMENT '库容;NULL:不限制', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '客户容量设置' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON customer_capacity (organization_id ASC); + +CREATE TABLE customer_field +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '客户id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` VARCHAR(255) NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '客户自定义属性' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id_field_id_field_value ON customer_field (resource_id, field_id, field_value); + +CREATE TABLE customer_field_blob +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '客户id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` TEXT NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '客户自定义属性大文本' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON customer_field_blob (resource_id); + +CREATE TABLE customer_contact +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `customer_id` VARCHAR(32) NOT NULL COMMENT '客户id', + `name` VARCHAR(255) NOT NULL COMMENT '联系人姓名', + `phone` VARCHAR(30) COMMENT '联系人电话', + `owner` VARCHAR(32) NOT NULL COMMENT '责任人', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + `enable` BIT(1) NOT NULL DEFAULT 0 COMMENT '是否停用', + `disable_reason` VARCHAR(255) COMMENT '停用原因', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + PRIMARY KEY (id) +) COMMENT = '客户联系人' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_customer_id ON customer_contact (customer_id); + +CREATE TABLE customer_contact_field +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '客户id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` VARCHAR(255) NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '客户联系人自定义属性' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id_field_id_field_value ON customer_contact_field (resource_id, field_id, field_value); + +CREATE TABLE customer_contact_field_blob +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '客户id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` TEXT NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '客户联系人自定义属性大文本' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON customer_contact_field_blob (resource_id); + +CREATE TABLE customer_collaboration +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + `user_id` VARCHAR(32) NOT NULL COMMENT '协作人id', + `customer_id` VARCHAR(32) NOT NULL COMMENT '客户id', + `collaboration_type` VARCHAR(50) NOT NULL COMMENT '协作类型(只读/协作)', + PRIMARY KEY (id) +) COMMENT = '客户协作人' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_customer_id ON customer_collaboration (customer_id); + + +CREATE TABLE customer_owner +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `customer_id` VARCHAR(32) NOT NULL COMMENT '客户id', + `owner` VARCHAR(32) NOT NULL COMMENT '责任人', + `collection_time` BIGINT NOT NULL COMMENT '领取时间', + `end_time` BIGINT NOT NULL COMMENT '结束时间', + `operator` VARCHAR(32) NOT NULL COMMENT '操作人', + PRIMARY KEY (id) +) COMMENT = '客户历史责任人' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_customer_id ON customer_owner (customer_id ASC); + +CREATE TABLE customer_relation( + `id` VARCHAR(32) NOT NULL COMMENT 'id' , + `source_customer_id` VARCHAR(32) NOT NULL COMMENT '客户ID(集团)' , + `target_customer_id` VARCHAR(32) NOT NULL COMMENT '客户ID(子公司)' , + `create_time` BIGINT NOT NULL COMMENT '创建时间' , + PRIMARY KEY (id) +) COMMENT = '客户关系' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_5__clue.sql b/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_5__clue.sql new file mode 100644 index 0000000..ce04c60 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_5__clue.sql @@ -0,0 +1,161 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +CREATE TABLE clue_pool +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '线索池名称', + `scope_id` TEXT NOT NULL COMMENT '成员id', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织架构id', + `owner_id` TEXT NOT NULL COMMENT '管理员id', + `enable` BIT(1) NOT NULL DEFAULT 1 COMMENT '启用/禁用', + `auto` BIT(1) NOT NULL DEFAULT 0 COMMENT '自动回收', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '线索池' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON clue_pool (organization_id ASC); + +CREATE TABLE clue_pool_pick_rule +( + `id` VARCHAR(32) NOT NULL COMMENT 'ID', + `pool_id` VARCHAR(32) NOT NULL COMMENT '线索池ID', + `limit_on_number` BIT(1) NOT NULL DEFAULT 1 COMMENT '是否限制领取数量', + `pick_number` INT COMMENT '领取数量', + `limit_pre_owner` BIT(1) NOT NULL DEFAULT 1 COMMENT '是否限制前归属人领取', + `pick_interval_days` INT COMMENT '领取间隔天数', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + PRIMARY KEY (id) +) COMMENT = '线索池领取规则' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_pool_id ON clue_pool_pick_rule (pool_id ASC); + +CREATE TABLE clue_pool_recycle_rule +( + `id` VARCHAR(32) NOT NULL COMMENT 'ID', + `pool_id` VARCHAR(32) NOT NULL COMMENT '线索池ID', + `operator` VARCHAR(10) COMMENT '操作符', + `condition` TEXT COMMENT '回收条件', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '线索池回收规则' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_pool_id ON clue_pool_recycle_rule (pool_id ASC); + +CREATE TABLE clue_capacity +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织架构ID', + `scope_id` TEXT NOT NULL COMMENT '范围ID', + `capacity` INT COMMENT '库容;NULL:不限制', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '线索池库容设置' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON clue_capacity (organization_id ASC); + +CREATE TABLE clue( + `id` VARCHAR(32) NOT NULL COMMENT 'id' , + `name` VARCHAR(255) NOT NULL COMMENT '客户名称' , + `owner` VARCHAR(32) COMMENT '负责人' , + `last_stage` VARCHAR(30) COMMENT '上次修改前的线索状态' , + `stage` VARCHAR(30) NOT NULL COMMENT '线索状态' , + `collection_time` BIGINT COMMENT '领取时间' , + `contact` VARCHAR(255) COMMENT '联系人名称' , + `phone` VARCHAR(30) COMMENT '联系人电话' , + `products` VARCHAR(1000) NOT NULL COMMENT '意向产品', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id' , + `create_time` BIGINT NOT NULL COMMENT '创建时间' , + `update_time` BIGINT NOT NULL COMMENT '更新时间' , + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人' , + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人' , + `transition_type` VARCHAR(30) DEFAULT 'NONE' COMMENT '转移成客户或者商机' , + `transition_id` VARCHAR(32) COMMENT '客户id或者商机id' , + `in_shared_pool` BIT(1) NOT NULL DEFAULT 0 COMMENT '是否在线索池' , + `follower` VARCHAR(32) COMMENT '最新跟进人' , + `follow_time` BIGINT(255) COMMENT '最新跟进时间' , + `pool_id` VARCHAR(32) COMMENT '线索池ID' , + PRIMARY KEY (id) +) COMMENT = '线索' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON `clue` (organization_id ASC); +CREATE INDEX idx_pool_id ON `clue` (pool_id ASC); +CREATE INDEX idx_follower ON clue (follower ASC); +CREATE INDEX idx_follow_time ON clue (follow_time ASC); + +CREATE TABLE clue_field +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '线索id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` VARCHAR(255) NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '线索自定义属性' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id_field_id_field_value ON clue_field (resource_id, field_id, field_value); + + +CREATE TABLE clue_field_blob +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '客户联系人id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` TEXT NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '线索自定义属性大文本' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON clue_field_blob (resource_id ASC); + + +CREATE TABLE clue_owner +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `clue_id` VARCHAR(32) NOT NULL COMMENT '线索id', + `owner` VARCHAR(32) NOT NULL COMMENT '责任人', + `collection_time` BIGINT NOT NULL COMMENT '领取时间', + `end_time` BIGINT NOT NULL COMMENT '结束时间', + `operator` VARCHAR(32) NOT NULL COMMENT '操作人', + PRIMARY KEY (id) +) COMMENT = '线索历史责任人' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_clue_id ON clue_owner (clue_id ASC); + + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; diff --git a/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_6__opportunity.sql b/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_6__opportunity.sql new file mode 100644 index 0000000..fdc1a8e --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.0/ddl/V1.0.0_6__opportunity.sql @@ -0,0 +1,213 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +CREATE TABLE opportunity_rule +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '规则名称', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织ID', + `owner_id` TEXT NOT NULL COMMENT '管理员ID', + `scope_id` TEXT NOT NULL COMMENT '范围ID', + `enable` BIT(1) NOT NULL DEFAULT 1 COMMENT '启用/禁用', + `auto` BIT(1) NOT NULL DEFAULT 0 COMMENT '自动回收', + `operator` VARCHAR(10) COMMENT '操作符', + `condition` TEXT COMMENT '回收条件', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '商机关闭规则' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON opportunity_rule (organization_id ASC); + + +CREATE TABLE opportunity +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `customer_id` VARCHAR(32) NOT NULL COMMENT '客户id', + `name` VARCHAR(255) NOT NULL COMMENT '商机名称', + `amount` DECIMAL(20, 10) COMMENT '金额', + `possible` DECIMAL(20, 10) COMMENT '可能性', + `products` VARCHAR(1000) NOT NULL COMMENT '意向产品', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `last_stage` VARCHAR(32) COMMENT '上次修改前的商机阶段', + `stage` VARCHAR(32) NOT NULL COMMENT '商机阶段', + `status` BIT(1) NOT NULL DEFAULT 1 COMMENT '状态', + `contact_id` VARCHAR(32) NOT NULL COMMENT '联系人id', + `owner` VARCHAR(32) NOT NULL COMMENT '负责人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `follower` VARCHAR(32) COMMENT '最新跟进人', + `follow_time` BIGINT COMMENT '最新跟进时间', + PRIMARY KEY (id) +) COMMENT = '商机' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_customer_id ON opportunity (customer_id ASC); +CREATE INDEX idx_organization_id ON opportunity (organization_id ASC); +CREATE INDEX idx_status ON opportunity (status ASC); +CREATE INDEX idx_follower ON opportunity (follower ASC); +CREATE INDEX idx_follow_time ON opportunity (follow_time ASC); + + +CREATE TABLE opportunity_field +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '商机id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` VARCHAR(255) NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '商机自定义属性' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id_field_id_field_value ON opportunity_field (resource_id, field_id, field_value); + +CREATE TABLE opportunity_field_blob +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '商机id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` TEXT NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '商机自定义属性大文本' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON opportunity_field_blob (resource_id); + +CREATE TABLE follow_up_record +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `customer_id` VARCHAR(32) COMMENT '客户id', + `opportunity_id` VARCHAR(32) COMMENT '商机id', + `type` VARCHAR(32) NOT NULL COMMENT '类型', + `clue_id` VARCHAR(32) COMMENT '线索id', + `content` VARCHAR(2000) NOT NULL COMMENT '跟进内容', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `follow_time` BIGINT NOT NULL COMMENT '跟进时间', + `follow_method` VARCHAR(32) NOT NULL COMMENT '跟进方式', + `owner` VARCHAR(32) NOT NULL COMMENT '负责人', + `contact_id` VARCHAR(32) COMMENT '联系人id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '跟进记录' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_customer_id ON follow_up_record (customer_id ASC); +CREATE INDEX idx_organization_id ON follow_up_record (organization_id ASC); +CREATE INDEX idx_opportunity_id ON follow_up_record (opportunity_id ASC); +CREATE INDEX idx_clue_id ON follow_up_record (clue_id ASC); +CREATE INDEX idx_owner ON follow_up_record (owner ASC); +CREATE INDEX idx_contact_id ON follow_up_record (contact_id ASC); + + +CREATE TABLE follow_up_record_field +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '跟进记录id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` VARCHAR(255) NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '跟进记录自定义属性' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id_field_id_field_value ON follow_up_record_field (resource_id, field_id, field_value); + +CREATE TABLE follow_up_record_field_blob +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '跟进记录id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` TEXT NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '跟进记录自定义属性大文本' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON follow_up_record_field_blob (resource_id); + + + +CREATE TABLE follow_up_plan +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `customer_id` VARCHAR(32) COMMENT '客户id', + `opportunity_id` VARCHAR(32) COMMENT '商机id', + `type` VARCHAR(32) NOT NULL COMMENT '类型', + `clue_id` VARCHAR(32) COMMENT '线索id', + `content` VARCHAR(1000) NOT NULL COMMENT '预计沟通内容', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `owner` VARCHAR(32) NOT NULL COMMENT '负责人', + `contact_id` VARCHAR(32) COMMENT '联系人', + `estimated_time` BIGINT(255) NOT NULL COMMENT '预计开始时间', + `method` VARCHAR(32) NOT NULL COMMENT '跟进方式', + `status` VARCHAR(64) COMMENT '状态', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '跟进计划' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_customer_id ON follow_up_plan (customer_id ASC); +CREATE INDEX idx_organization_id ON follow_up_plan (organization_id ASC); +CREATE INDEX idx_opportunity_id ON follow_up_plan (opportunity_id ASC); +CREATE INDEX idx_clue_id ON follow_up_plan (clue_id ASC); +CREATE INDEX idx_owner ON follow_up_plan (owner ASC); +CREATE INDEX idx_contact_id ON follow_up_plan (contact_id ASC); + + +CREATE TABLE follow_up_plan_field +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '跟进计划id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` VARCHAR(255) NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '跟进计划自定义属性' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id_field_id_field_value ON follow_up_plan_field (resource_id, field_id, field_value); + + +CREATE TABLE follow_up_plan_field_blob +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '跟进计划id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` TEXT NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '跟进计划自定义属性大文本' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON follow_up_plan_field_blob (resource_id ASC); + + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; diff --git a/backend/crm/src/main/resources/migration/1.0.0/dml/V1.0.0_2_1__data.sql b/backend/crm/src/main/resources/migration/1.0.0/dml/V1.0.0_2_1__data.sql new file mode 100644 index 0000000..b756622 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.0/dml/V1.0.0_2_1__data.sql @@ -0,0 +1,109 @@ +-- set innodb lock wait timeout + +SET SESSION innodb_lock_wait_timeout = 7200; + +-- 初始化用户 +INSERT INTO `sys_user` ( `id`, `name`, `email`, `password`, `gender`, `phone`, `language`, `last_organization_id`, `create_time`, `update_time`, `create_user`, `update_user` ) +VALUES + ( 'admin', 'Administrator', 'admin@cordys-crm.io', MD5( 'CordysCRM' ), 1, '4000520755', 'zh_CN', '100001', 1716175907000, 1729752373360, '717345437786112', 'admin' ); + +SET @internal_department_id = UUID_SHORT();-- 初始化用户基本信息 + +INSERT INTO `sys_department` ( `id`, `name`, `organization_id`, `parent_id`, `pos`, `create_time`, `update_time`, `create_user`, `update_user`, `resource`, `resource_id` ) +VALUES + ( @internal_department_id, '公司名称', '100001', 'NONE', 100001, 1736240043609, 1736240043609, 'admin', 'admin', 'INTERNAL', NULL );-- 初始化默认组织 + +INSERT INTO `sys_organization` ( `id`, `name`, `create_time`, `update_time`, `create_user`, `update_user` ) +VALUES + ( '100001', 'default', 1736152274610, 1736152274610, 'admin', 'admin' );-- 初始化组织管理员 + +INSERT INTO sys_role ( id, NAME, internal, data_scope, create_time, update_time, create_user, update_user, description, organization_id ) +VALUES + ( 'org_admin', 'org_admin', 1, 'ALL', UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 'admin', 'admin', '', '100001' );-- 初始化销售经理 + +INSERT INTO sys_role ( id, NAME, internal, data_scope, create_time, update_time, create_user, update_user, description, organization_id ) +VALUES + ( 'sales_manager', 'sales_manager', 1, 'DEPT_AND_CHILD', UNIX_TIMESTAMP() * 1000 + 1, UNIX_TIMESTAMP() * 1000 + 1, 'admin', 'admin', '', '100001' );-- 初始化销售专员 + +INSERT INTO sys_role ( id, NAME, internal, data_scope, create_time, update_time, create_user, update_user, description, organization_id ) +VALUES + ( 'sales_staff', 'sales_staff', 1, 'SELF', UNIX_TIMESTAMP() * 1000 + 2, UNIX_TIMESTAMP() * 1000 + 2, 'admin', 'admin', '', '100001' );-- set innodb lock wait timeout to default + +SET @customer_addID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_addID, 'CUSTOMER_ADD', 'CUSTOMER', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @customer_transferred_customerID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_transferred_customerID, 'CUSTOMER_TRANSFERRED_CUSTOMER', 'CUSTOMER', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @customer_automatic_move_high_seasID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_automatic_move_high_seasID, 'CUSTOMER_AUTOMATIC_MOVE_HIGH_SEAS', 'CUSTOMER', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @customer_moved_high_seasID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_moved_high_seasID, 'CUSTOMER_MOVED_HIGH_SEAS', 'CUSTOMER', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + + +SET @customer_deletedID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_deletedID, 'CUSTOMER_DELETED', 'CUSTOMER', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @high_seas_customer_distributedID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@high_seas_customer_distributedID, 'HIGH_SEAS_CUSTOMER_DISTRIBUTED', 'CUSTOMER', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @customer_follow_up_plan_dueID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_follow_up_plan_dueID, 'CUSTOMER_FOLLOW_UP_PLAN_DUE', 'CUSTOMER', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @clue_automatic_move_poolID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@clue_automatic_move_poolID, 'CLUE_AUTOMATIC_MOVE_POOL', 'CLUE', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @clue_moved_poolID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@clue_moved_poolID, 'CLUE_MOVED_POOL', 'CLUE', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @clue_convert_customerID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@clue_convert_customerID, 'CLUE_CONVERT_CUSTOMER', 'CLUE', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @transfer_clueID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@transfer_clueID, 'TRANSFER_CLUE', 'CLUE', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @clue_deletedID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@clue_deletedID, 'CLUE_DELETED', 'CLUE', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @clue_distributedID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@clue_distributedID, 'CLUE_DISTRIBUTED', 'CLUE', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @clue_importID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@clue_importID, 'CLUE_IMPORT', 'CLUE', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @clue_follow_up_plan_dueID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@clue_follow_up_plan_dueID, 'CLUE_FOLLOW_UP_PLAN_DUE', 'CLUE', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @business_deletedID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@business_deletedID, 'BUSINESS_DELETED', 'OPPORTUNITY', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @business_transferID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@business_transferID, 'BUSINESS_TRANSFER', 'OPPORTUNITY', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @business_importID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@business_importID, 'BUSINESS_IMPORT', 'OPPORTUNITY', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @business_follow_up_plan_dueID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@business_follow_up_plan_dueID, 'BUSINESS_FOLLOW_UP_PLAN_DUE', 'OPPORTUNITY', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.0.0/dml/V1.0.0_2_2__permission.sql b/backend/crm/src/main/resources/migration/1.0.0/dml/V1.0.0_2_2__permission.sql new file mode 100644 index 0000000..f833f7a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.0/dml/V1.0.0_2_2__permission.sql @@ -0,0 +1,287 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- 管理员 +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142848', 'org_admin', 'SYS_ORGANIZATION:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142849', 'org_admin', 'SYS_ORGANIZATION:ADD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142850', 'org_admin', 'SYS_ORGANIZATION:UPDATE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142851', 'org_admin', 'SYS_ORGANIZATION:DELETE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142852', 'org_admin', 'SYS_ORGANIZATION:IMPORT'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142853', 'org_admin', 'SYS_ORGANIZATION:SYNC'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142854', 'org_admin', 'SYS_ORGANIZATION_USER:RESET_PASSWORD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142855', 'org_admin', 'SYSTEM_ROLE:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142856', 'org_admin', 'SYSTEM_ROLE:ADD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142857', 'org_admin', 'SYSTEM_ROLE:UPDATE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142858', 'org_admin', 'SYSTEM_ROLE:DELETE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142859', 'org_admin', 'SYSTEM_ROLE:ADD_USER'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142860', 'org_admin', 'SYSTEM_ROLE:REMOVE_USER'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142861', 'org_admin', 'MODULE_SETTING:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142862', 'org_admin', 'MODULE_SETTING:UPDATE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142863', 'org_admin', 'SYSTEM_NOTICE:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142864', 'org_admin', 'SYSTEM_NOTICE:ADD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142865', 'org_admin', 'SYSTEM_NOTICE:UPDATE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142866', 'org_admin', 'SYSTEM_NOTICE:DELETE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142867', 'org_admin', 'SYSTEM_SETTING:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142868', 'org_admin', 'SYSTEM_SETTING:UPDATE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142869', 'org_admin', 'CUSTOMER_MANAGEMENT:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142870', 'org_admin', 'CUSTOMER_MANAGEMENT:ADD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142871', 'org_admin', 'CUSTOMER_MANAGEMENT:UPDATE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142872', 'org_admin', 'CUSTOMER_MANAGEMENT:RECYCLE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142873', 'org_admin', 'CUSTOMER_MANAGEMENT:DELETE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142874', 'org_admin', 'CUSTOMER_MANAGEMENT_POOL:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142875', 'org_admin', 'CUSTOMER_MANAGEMENT_POOL:PICK'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142876', 'org_admin', 'CUSTOMER_MANAGEMENT_POOL:ASSIGN'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142877', 'org_admin', 'CUSTOMER_MANAGEMENT_POOL:DELETE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142878', 'org_admin', 'CUSTOMER_MANAGEMENT_CONTACT:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142879', 'org_admin', 'CUSTOMER_MANAGEMENT_CONTACT:ADD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142880', 'org_admin', 'CUSTOMER_MANAGEMENT_CONTACT:UPDATE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142881', 'org_admin', 'CUSTOMER_MANAGEMENT_CONTACT:DELETE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142882', 'org_admin', 'CLUE_MANAGEMENT:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142883', 'org_admin', 'CLUE_MANAGEMENT:ADD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142884', 'org_admin', 'CLUE_MANAGEMENT:UPDATE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142885', 'org_admin', 'CLUE_MANAGEMENT:RECYCLE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142886', 'org_admin', 'CLUE_MANAGEMENT:DELETE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142887', 'org_admin', 'CLUE_MANAGEMENT_POOL:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142888', 'org_admin', 'CLUE_MANAGEMENT_POOL:PICK'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142889', 'org_admin', 'CLUE_MANAGEMENT_POOL:ASSIGN'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142890', 'org_admin', 'CLUE_MANAGEMENT_POOL:DELETE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142891', 'org_admin', 'OPPORTUNITY_MANAGEMENT:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142892', 'org_admin', 'OPPORTUNITY_MANAGEMENT:ADD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142893', 'org_admin', 'OPPORTUNITY_MANAGEMENT:UPDATE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142894', 'org_admin', 'OPPORTUNITY_MANAGEMENT:DELETE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142895', 'org_admin', 'PRODUCT_MANAGEMENT:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142896', 'org_admin', 'PRODUCT_MANAGEMENT:ADD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142897', 'org_admin', 'PRODUCT_MANAGEMENT:UPDATE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1076902920142898', 'org_admin', 'PRODUCT_MANAGEMENT:DELETE'); +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES ('1076902920142899', 'org_admin', 'OPERATION_LOG:READ'); +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES ('1076902920142900', 'org_admin', 'SYSTEM_SETTING:ADD'); +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES ('1076902920142901', 'org_admin', 'SYSTEM_SETTING:DELETE'); + +-- 销售经理 +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120642867085312', 'sales_manager', 'CUSTOMER_MANAGEMENT:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120642867085313', 'sales_manager', 'CUSTOMER_MANAGEMENT:ADD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120642867085314', 'sales_manager', 'CUSTOMER_MANAGEMENT:UPDATE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120642867085315', 'sales_manager', 'CUSTOMER_MANAGEMENT:RECYCLE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120642867085316', 'sales_manager', 'CUSTOMER_MANAGEMENT_POOL:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120642867085317', 'sales_manager', 'CUSTOMER_MANAGEMENT_POOL:PICK'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120660046954496', 'sales_manager', 'CUSTOMER_MANAGEMENT_CONTACT:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120660046954497', 'sales_manager', 'CUSTOMER_MANAGEMENT_CONTACT:ADD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120660046954498', 'sales_manager', 'CUSTOMER_MANAGEMENT_CONTACT:UPDATE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120660046954499', 'sales_manager', 'CLUE_MANAGEMENT:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120660046954500', 'sales_manager', 'CLUE_MANAGEMENT:ADD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120660046954501', 'sales_manager', 'CLUE_MANAGEMENT:UPDATE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120660046954502', 'sales_manager', 'CUSTOMER_MANAGEMENT:RECYCLE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120660046954503', 'sales_manager', 'CLUE_MANAGEMENT_POOL:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120660046954504', 'sales_manager', 'CLUE_MANAGEMENT_POOL:PICK'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120660046954505', 'sales_manager', 'OPPORTUNITY_MANAGEMENT:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120660046954506', 'sales_manager', 'OPPORTUNITY_MANAGEMENT:ADD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1120660046954507', 'sales_manager', 'OPPORTUNITY_MANAGEMENT:UPDATE'); +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES ('1120660046954508', 'sales_manager', 'CLUE_MANAGEMENT:RECYCLE'); + +-- 销售专员 +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492608', 'sales_staff', 'CUSTOMER_MANAGEMENT:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492609', 'sales_staff', 'CUSTOMER_MANAGEMENT:ADD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492610', 'sales_staff', 'CUSTOMER_MANAGEMENT:UPDATE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492611', 'sales_staff', 'CUSTOMER_MANAGEMENT:RECYCLE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492612', 'sales_staff', 'CUSTOMER_MANAGEMENT_POOL:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492613', 'sales_staff', 'CUSTOMER_MANAGEMENT_POOL:PICK'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492614', 'sales_staff', 'CUSTOMER_MANAGEMENT_CONTACT:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492615', 'sales_staff', 'CUSTOMER_MANAGEMENT_CONTACT:ADD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492616', 'sales_staff', 'CUSTOMER_MANAGEMENT_CONTACT:UPDATE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492617', 'sales_staff', 'CLUE_MANAGEMENT:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492618', 'sales_staff', 'CLUE_MANAGEMENT:ADD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492619', 'sales_staff', 'CLUE_MANAGEMENT:UPDATE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492620', 'sales_staff', 'CUSTOMER_MANAGEMENT:RECYCLE'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492621', 'sales_staff', 'CLUE_MANAGEMENT_POOL:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492622', 'sales_staff', 'CLUE_MANAGEMENT_POOL:PICK'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492623', 'sales_staff', 'OPPORTUNITY_MANAGEMENT:READ'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492624', 'sales_staff', 'OPPORTUNITY_MANAGEMENT:ADD'); +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES ('1117962807492625', 'sales_staff', 'OPPORTUNITY_MANAGEMENT:UPDATE'); +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES ('1117962807492626', 'sales_staff', 'CLUE_MANAGEMENT:RECYCLE'); + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.0.1/ddl/V1.0.1_1__init.sql b/backend/crm/src/main/resources/migration/1.0.1/ddl/V1.0.1_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.1/ddl/V1.0.1_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.0.1/ddl/V1.0.1_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.0.1/ddl/V1.0.1_2__ga_ddl.sql new file mode 100644 index 0000000..459d5d6 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.1/ddl/V1.0.1_2__ga_ddl.sql @@ -0,0 +1,36 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +CREATE TABLE export_task +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `file_name` VARCHAR(255) NOT NULL COMMENT '文件名', + `resource_type` VARCHAR(50) NOT NULL COMMENT '资源类型', + `file_id` VARCHAR(32) NOT NULL COMMENT '文件id', + `status` VARCHAR(50) NOT NULL COMMENT '状态', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '导出任务' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON export_task(organization_id ASC); +CREATE INDEX idx_status ON export_task(status ASC); +CREATE INDEX idx_resource_type ON export_task(resource_type ASC); +CREATE INDEX idx_create_time ON export_task(create_time ASC); +CREATE INDEX idx_create_user ON export_task(create_user ASC); + +ALTER TABLE customer_capacity ADD COLUMN `filter` TEXT COMMENT '过滤条件'; + +ALTER TABLE opportunity ADD COLUMN expected_end_time BIGINT NOT NULL COMMENT '结束时间'; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; + + + diff --git a/backend/crm/src/main/resources/migration/1.0.1/dml/V1.0.1_2_1__data.sql b/backend/crm/src/main/resources/migration/1.0.1/dml/V1.0.1_2_1__data.sql new file mode 100644 index 0000000..8de5a67 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.1/dml/V1.0.1_2_1__data.sql @@ -0,0 +1,5 @@ +-- set innodb lock wait timeout + +SET SESSION innodb_lock_wait_timeout = 7200; + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.0.1/dml/V1.0.1_2_2__permission.sql b/backend/crm/src/main/resources/migration/1.0.1/dml/V1.0.1_2_2__permission.sql new file mode 100644 index 0000000..3283329 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.1/dml/V1.0.1_2_2__permission.sql @@ -0,0 +1,59 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- 管理员 +SET @opportunity_resignID = UUID_SHORT();-- 初始化OPPORTUNITY_MANAGEMENT:RESIGN权限 +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES (@opportunity_resignID, 'org_admin', 'OPPORTUNITY_MANAGEMENT:RESIGN'); + +SET @customer_exportOrgID = UUID_SHORT(); +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (@customer_exportOrgID, 'org_admin', 'CUSTOMER_MANAGEMENT:EXPORT'); + +SET @opportunity_exportOrgID = UUID_SHORT(); +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (@opportunity_exportOrgID, 'org_admin', 'OPPORTUNITY_MANAGEMENT:EXPORT'); + +SET @clue_exportOrgID = UUID_SHORT(); +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES ( @clue_exportOrgID, 'org_admin', 'CLUE_MANAGEMENT:EXPORT'); + +-- 销售经理 +SET @customer_exportManID = UUID_SHORT(); +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (@customer_exportManID, 'sales_manager', 'CUSTOMER_MANAGEMENT:EXPORT'); + +SET @opportunity_exportManID = UUID_SHORT(); +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (@opportunity_exportManID, 'sales_manager', 'OPPORTUNITY_MANAGEMENT:EXPORT'); + +SET @clue_exportManID = UUID_SHORT(); +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (@clue_exportManID, 'sales_manager', 'CLUE_MANAGEMENT:EXPORT'); + +-- 销售专员 +SET @customer_exportStaID = UUID_SHORT(); +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES ('1117962807492627', 'sales_staff', 'CUSTOMER_MANAGEMENT:EXPORT'); + +SET @opportunity_exportStaID = UUID_SHORT(); +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (@opportunity_exportStaID, 'sales_staff', 'OPPORTUNITY_MANAGEMENT:EXPORT'); + +SET @clue_exportStaID = UUID_SHORT(); +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (@clue_exportStaID, 'sales_staff', 'CLUE_MANAGEMENT:EXPORT'); + + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.0.2/ddl/V1.0.2_1__init.sql b/backend/crm/src/main/resources/migration/1.0.2/ddl/V1.0.2_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.2/ddl/V1.0.2_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.0.2/ddl/V1.0.2_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.0.2/ddl/V1.0.2_2__ga_ddl.sql new file mode 100644 index 0000000..3d67dbd --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.2/ddl/V1.0.2_2__ga_ddl.sql @@ -0,0 +1,8 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; + + + diff --git a/backend/crm/src/main/resources/migration/1.0.2/dml/V1.0.2_2_1__data.sql b/backend/crm/src/main/resources/migration/1.0.2/dml/V1.0.2_2_1__data.sql new file mode 100644 index 0000000..8de5a67 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.2/dml/V1.0.2_2_1__data.sql @@ -0,0 +1,5 @@ +-- set innodb lock wait timeout + +SET SESSION innodb_lock_wait_timeout = 7200; + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.0.2/dml/V1.0.2_2_2__permission.sql b/backend/crm/src/main/resources/migration/1.0.2/dml/V1.0.2_2_2__permission.sql new file mode 100644 index 0000000..ed0968e --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.2/dml/V1.0.2_2_2__permission.sql @@ -0,0 +1,4 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.0.3/ddl/V1.0.3_1__init.sql b/backend/crm/src/main/resources/migration/1.0.3/ddl/V1.0.3_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.3/ddl/V1.0.3_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.0.3/ddl/V1.0.3_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.0.3/ddl/V1.0.3_2__ga_ddl.sql new file mode 100644 index 0000000..e2d1ec0 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.3/ddl/V1.0.3_2__ga_ddl.sql @@ -0,0 +1,14 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +alter table product + add pos BIGINT NOT NULL DEFAULT 0 COMMENT '排序' after status; + + + + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; + + + diff --git a/backend/crm/src/main/resources/migration/1.0.3/dml/V1.0.3_2_1__data.sql b/backend/crm/src/main/resources/migration/1.0.3/dml/V1.0.3_2_1__data.sql new file mode 100644 index 0000000..605af05 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.3/dml/V1.0.3_2_1__data.sql @@ -0,0 +1,13 @@ +-- set innodb lock wait timeout + +SET SESSION innodb_lock_wait_timeout = 7200; + +-- 初始化产品排序字段 +SET @pos := 0; + +UPDATE product +SET pos = (@pos := @pos + 4096) + ORDER BY id; + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.0.3/dml/V1.0.3_2_2__permission.sql b/backend/crm/src/main/resources/migration/1.0.3/dml/V1.0.3_2_2__permission.sql new file mode 100644 index 0000000..ed0968e --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.3/dml/V1.0.3_2_2__permission.sql @@ -0,0 +1,4 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.0.4/ddl/V1.0.4_1__init.sql b/backend/crm/src/main/resources/migration/1.0.4/ddl/V1.0.4_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.4/ddl/V1.0.4_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.0.4/ddl/V1.0.4_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.0.4/ddl/V1.0.4_2__ga_ddl.sql new file mode 100644 index 0000000..f6c1bab --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.0.4/ddl/V1.0.4_2__ga_ddl.sql @@ -0,0 +1,14 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- 改成非必填 +ALTER TABLE opportunity MODIFY COLUMN expected_end_time bigint(20) NULL COMMENT '结束时间'; +ALTER TABLE opportunity MODIFY COLUMN products varchar(1000) NULL COMMENT '意向产品'; +ALTER TABLE clue MODIFY COLUMN products varchar(1000) NULL COMMENT '意向产品'; +ALTER TABLE product MODIFY COLUMN price DECIMAL(14, 4) NULL COMMENT '价格'; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; + + + diff --git a/backend/crm/src/main/resources/migration/1.1.0/ddl/V1.1.0_1__init.sql b/backend/crm/src/main/resources/migration/1.1.0/ddl/V1.1.0_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.0/ddl/V1.1.0_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.0/ddl/V1.1.0_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.1.0/ddl/V1.1.0_2__ga_ddl.sql new file mode 100644 index 0000000..4a80795 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.0/ddl/V1.1.0_2__ga_ddl.sql @@ -0,0 +1,97 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +alter table sys_message_task + add we_com_enable bit default b'0' null comment '企业微信启用' after sys_enable; + +CREATE TABLE dashboard_module +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `name` VARCHAR(255) NOT NULL COMMENT '名称', + `parent_id` VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '父节点id', + `pos` BIGINT NOT NULL DEFAULT 0 COMMENT '同一节点下顺序', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '仪表板模块' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON dashboard_module (organization_id ASC); +CREATE INDEX idx_name ON dashboard_module (name ASC); +CREATE INDEX idx_pos ON dashboard_module (pos ASC); +CREATE INDEX idx_parent_id ON dashboard_module (parent_id ASC); + + +CREATE TABLE dashboard +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '名称', + `resource_id` VARCHAR(50) NOT NULL COMMENT '三方资源id', + `dashboard_module_id` VARCHAR(32) NOT NULL COMMENT '模块id', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `pos` BIGINT NOT NULL DEFAULT 0 COMMENT '同一节点下顺序', + `scope_id` TEXT NOT NULL COMMENT '范围', + `description` VARCHAR(1000) COMMENT '描述', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '仪表板' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_name ON dashboard (name ASC); +CREATE INDEX idx_dashboard_module_id ON dashboard (dashboard_module_id ASC); + + +ALTER TABLE opportunity + ADD COLUMN actual_end_time BIGINT COMMENT '实际结束时间'; +ALTER TABLE opportunity + ADD COLUMN failure_reason VARCHAR(50) COMMENT '失败原因'; + + +CREATE TABLE sys_license +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `license_code` LONGTEXT NOT NULL COMMENT 'license_code', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = 'license' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + + +CREATE TABLE dashboard_collection +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `user_id` VARCHAR(32) NOT NULL COMMENT '用户id', + `dashboard_id` VARCHAR(32) NOT NULL COMMENT '仪表板id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '仪表板收藏' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_user_id ON dashboard_collection (user_id ASC); + + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; + + + diff --git a/backend/crm/src/main/resources/migration/1.1.0/dml/V1.1.0_2_1__data.sql b/backend/crm/src/main/resources/migration/1.1.0/dml/V1.1.0_2_1__data.sql new file mode 100644 index 0000000..4471a1f --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.0/dml/V1.1.0_2_1__data.sql @@ -0,0 +1,13 @@ +-- set innodb lock wait timeout + +SET SESSION innodb_lock_wait_timeout = 7200; + +-- 初始化仪表板菜单 +insert into sys_module value (UUID_SHORT(), '100001', 'dashboard', false, 7, + 'admin', UNIX_TIMESTAMP() * 1000, 'admin', UNIX_TIMESTAMP() * 1000); + +INSERT INTO `dashboard_module`(`id`, `organization_id`, `name`, `parent_id`, `pos`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES + (UUID_SHORT(), '100001', '默认文件夹', 'NONE', 0, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 'admin', 'admin'); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.0/dml/V1.1.0_2_2__permission.sql b/backend/crm/src/main/resources/migration/1.1.0/dml/V1.1.0_2_2__permission.sql new file mode 100644 index 0000000..52ff7e9 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.0/dml/V1.1.0_2_2__permission.sql @@ -0,0 +1,14 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'DASHBOARD:READ'), + (UUID_SHORT(), 'org_admin', 'DASHBOARD:ADD'), + (UUID_SHORT(), 'org_admin', 'DASHBOARD:UPDATE'), + (UUID_SHORT(), 'org_admin', 'DASHBOARD:DELETE'), + (UUID_SHORT(), 'org_admin', 'LICENSE:READ'), + (UUID_SHORT(), 'org_admin', 'LICENSE:EDIT'); + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.1/ddl/V1.1.1_1__init.sql b/backend/crm/src/main/resources/migration/1.1.1/ddl/V1.1.1_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.1/ddl/V1.1.1_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.1/ddl/V1.1.1_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.1.1/ddl/V1.1.1_2__ga_ddl.sql new file mode 100644 index 0000000..ad63327 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.1/ddl/V1.1.1_2__ga_ddl.sql @@ -0,0 +1,130 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- Add pool pick rule +ALTER TABLE customer_pool_pick_rule + ADD limit_new BIT(1) NOT NULL DEFAULT 0 COMMENT '是否限制新数据' after pick_interval_days; +ALTER TABLE customer_pool_pick_rule + ADD new_pick_interval INT COMMENT '新数据领取保护' after limit_new; + +ALTER TABLE clue_pool_pick_rule + ADD limit_new BIT(1) NOT NULL DEFAULT 0 COMMENT '是否限制新数据' after pick_interval_days; +ALTER TABLE clue_pool_pick_rule + ADD new_pick_interval INT COMMENT '新数据领取保护' after limit_new; + + +CREATE TABLE sys_user_view +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `user_id` VARCHAR(32) NOT NULL COMMENT '用户id', + `name` VARCHAR(255) NOT NULL COMMENT '视图名称', + `fixed` BIT(1) NOT NULL DEFAULT 0 COMMENT '是否固定', + `resource_type` VARCHAR(50) NOT NULL COMMENT '资源类型(客户/线索/商机)', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `pos` BIGINT NOT NULL COMMENT '排序', + `enable` bit(1) NOT NULL DEFAULT 1 COMMENT '状态', + `search_mode` VARCHAR(10) NOT NULL DEFAULT 'AND' COMMENT '匹配模式(AND/OR)', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '用户视图' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_user_id ON sys_user_view (user_id ASC); +CREATE INDEX idx_name ON sys_user_view (name ASC); +CREATE INDEX idx_resource_type ON sys_user_view (resource_type ASC); +CREATE INDEX idx_organization_id ON sys_user_view (organization_id ASC); + + +CREATE TABLE sys_user_view_condition +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `sys_user_view_id` VARCHAR(32) NOT NULL COMMENT '视图id', + `name` VARCHAR(255) NOT NULL COMMENT '参数名称', + `value` TEXT(255) COMMENT '参数值', + `value_type` TEXT(255) COMMENT '参数值类型', + `type` VARCHAR(20) COMMENT '类型', + `multiple_value` BIT(1) NOT NULL DEFAULT 0 COMMENT '是否是多选值', + `operator` VARCHAR(20) COMMENT '操作符', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '用户视图详情' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_sys_user_view_id ON sys_user_view_condition (sys_user_view_id ASC); + +-- Dict config +CREATE TABLE sys_dict +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '字典值', + `module` VARCHAR(20) NOT NULL COMMENT '字典模块', + `type` VARCHAR(10) NOT NULL DEFAULT 'TEXT' COMMENT '字典值类型', + `pos` BIGINT NOT NULL COMMENT '自定义排序' , + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织ID', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '系统字典表' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_org_type ON sys_dict (organization_id ASC, type ASC); +CREATE INDEX idx_org_name ON sys_dict (organization_id ASC, name ASC); + +-- Add customer/lead pool reason +ALTER TABLE customer + ADD `reason_id` VARCHAR(32) COMMENT '公海原因ID'; +CREATE INDEX idx_reason_id ON customer (reason_id); +ALTER TABLE clue + ADD `reason_id` VARCHAR(32) COMMENT '线索池原因ID'; +CREATE INDEX idx_reason_id ON clue (reason_id); + +-- Add dict config +CREATE TABLE sys_dict_config +( + `module` VARCHAR(20) NOT NULL COMMENT '字典类型', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织ID', + `enabled` BIT(1) NOT NULL DEFAULT 0 COMMENT '是否启用', + PRIMARY KEY (module, organization_id) +) COMMENT = '系统字典配置表' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + + +CREATE TABLE customer_pool_hidden_field( + `pool_id` VARCHAR(32) NOT NULL COMMENT '公海池ID' , + `field_id` VARCHAR(32) NOT NULL COMMENT '字段ID' , + PRIMARY KEY (pool_id,field_id) +) COMMENT = '公海池隐藏字段' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE TABLE clue_pool_hidden_field( + `pool_id` VARCHAR(32) NOT NULL COMMENT '线索池ID' , + `field_id` VARCHAR(32) NOT NULL COMMENT '字段ID' , + PRIMARY KEY (pool_id,field_id) +) COMMENT = '线索池隐藏字段' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; + + + diff --git a/backend/crm/src/main/resources/migration/1.1.1/dml/V1.1.1_2_1__data.sql b/backend/crm/src/main/resources/migration/1.1.1/dml/V1.1.1_2_1__data.sql new file mode 100644 index 0000000..d217028 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.1/dml/V1.1.1_2_1__data.sql @@ -0,0 +1,22 @@ +-- set innodb lock wait timeout + +SET SESSION innodb_lock_wait_timeout = 7200; + +-- init dict config +INSERT INTO sys_dict values (UUID_SHORT(), '客户选择竞品', 'OPPORTUNITY_FAIL_RS', 'TEXT', 1, '100001', +UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 'admin', 'admin'); +INSERT INTO sys_dict values (UUID_SHORT(), '立项失败', 'OPPORTUNITY_FAIL_RS','TEXT', 2, '100001', +UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 'admin', 'admin'); +INSERT INTO sys_dict values (UUID_SHORT(), '决策链复杂', 'OPPORTUNITY_FAIL_RS','TEXT', 3,'100001', +UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 'admin', 'admin'); +INSERT INTO sys_dict values (UUID_SHORT(), '预算限制', 'OPPORTUNITY_FAIL_RS', 'TEXT', 4,'100001', +UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 'admin', 'admin'); +INSERT INTO sys_dict values (UUID_SHORT(), '需求变化', 'OPPORTUNITY_FAIL_RS', 'TEXT', 5, '100001', +UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 'admin', 'admin'); + +-- opportunity fail reason => sys_dict.fail.id +UPDATE opportunity set failure_reason = ( + SELECT sd.id FROM sys_dict sd WHERE sd.type = 'OPPORTUNITY_FAIL_RS' and sd.organization_id = '100001' AND sd.name = failure_reason +) WHERE failure_reason IS NOT NULL; + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.1/dml/V1.1.1_2_2__permission.sql b/backend/crm/src/main/resources/migration/1.1.1/dml/V1.1.1_2_2__permission.sql new file mode 100644 index 0000000..b2e930a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.1/dml/V1.1.1_2_2__permission.sql @@ -0,0 +1,5 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.2/ddl/V1.1.2_1__init.sql b/backend/crm/src/main/resources/migration/1.1.2/ddl/V1.1.2_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.2/ddl/V1.1.2_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.2/dml/V1.1.2_1_1__permission.sql b/backend/crm/src/main/resources/migration/1.1.2/dml/V1.1.2_1_1__permission.sql new file mode 100644 index 0000000..c5dfeb0 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.2/dml/V1.1.2_1_1__permission.sql @@ -0,0 +1,7 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CLUE_MANAGEMENT:IMPORT'); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.3/ddl/V1.1.3_1__init.sql b/backend/crm/src/main/resources/migration/1.1.3/ddl/V1.1.3_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.3/ddl/V1.1.3_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.3/ddl/V1.1.3_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.1.3/ddl/V1.1.3_2__ga_ddl.sql new file mode 100644 index 0000000..38be4c4 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.3/ddl/V1.1.3_2__ga_ddl.sql @@ -0,0 +1,11 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- Add pool pick rule +ALTER TABLE sys_user_view_condition ADD children_value text DEFAULT NULL COMMENT '包含新增子部门集合'; + +-- Del Opportunity Status +ALTER TABLE opportunity DROP COLUMN status; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.3/dml/V1.1.3_1_1__permission.sql b/backend/crm/src/main/resources/migration/1.1.3/dml/V1.1.3_1_1__permission.sql new file mode 100644 index 0000000..7c72b9c --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.3/dml/V1.1.3_1_1__permission.sql @@ -0,0 +1,42 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CUSTOMER_MANAGEMENT_POOL:EXPORT'); + +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CUSTOMER_MANAGEMENT_POOL:EXPORT'); + +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CUSTOMER_MANAGEMENT_POOL:EXPORT'); + + +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CLUE_MANAGEMENT_POOL:EXPORT'); + +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CLUE_MANAGEMENT_POOL:EXPORT'); + +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CLUE_MANAGEMENT_POOL:EXPORT'); + + +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CUSTOMER_MANAGEMENT_CONTACT:EXPORT'); + +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CUSTOMER_MANAGEMENT_CONTACT:EXPORT'); + +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CUSTOMER_MANAGEMENT_CONTACT:EXPORT'); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.4/ddl/V1.1.4_1__init.sql b/backend/crm/src/main/resources/migration/1.1.4/ddl/V1.1.4_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.4/ddl/V1.1.4_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.4/ddl/V1.1.4_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.1.4/ddl/V1.1.4_2__ga_ddl.sql new file mode 100644 index 0000000..60a4219 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.4/ddl/V1.1.4_2__ga_ddl.sql @@ -0,0 +1,10 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- Add follow_up_plan transit_record column +alter table follow_up_plan + add converted bit default b'0' not null comment '是否转为跟进记录' after status; + + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.4/dml/V1.1.4_1_1__permission.sql b/backend/crm/src/main/resources/migration/1.1.4/dml/V1.1.4_1_1__permission.sql new file mode 100644 index 0000000..b2e930a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.4/dml/V1.1.4_1_1__permission.sql @@ -0,0 +1,5 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.5/ddl/V1.1.5_1__init.sql b/backend/crm/src/main/resources/migration/1.1.5/ddl/V1.1.5_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.5/ddl/V1.1.5_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.5/ddl/V1.1.5_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.1.5/ddl/V1.1.5_2__ga_ddl.sql new file mode 100644 index 0000000..a408342 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.5/ddl/V1.1.5_2__ga_ddl.sql @@ -0,0 +1,66 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + + +CREATE TABLE sys_user_search_config +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `field_id` VARCHAR(32) NOT NULL COMMENT '搜索字段id', + `type` VARCHAR(20) NOT NULL COMMENT '字段类型', + `business_key` VARCHAR(50) COMMENT '业务字段key', + `data_source_type` VARCHAR(50) COMMENT '数据源对应类型', + `user_id` VARCHAR(32) NOT NULL COMMENT '用户id', + `module_type` VARCHAR(50) NOT NULL COMMENT '搜索模块(customer/lead/等)', + `sort_setting` VARCHAR(255) NOT NULL COMMENT '模块顺序设置["customer","clue"]', + `result_display` BIT(1) NOT NULL DEFAULT 0 COMMENT '是否展示有搜索结果的列表', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '用户全局搜索配置' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + + +CREATE INDEX idx_user_id ON sys_user_search_config (user_id ASC); +CREATE INDEX ids_module_type ON sys_user_search_config (module_type ASC); + + +CREATE TABLE sys_search_field_mask_config +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `field_id` VARCHAR(32) NOT NULL COMMENT '搜索字段id', + `type` VARCHAR(20) NOT NULL COMMENT '类型', + `business_key` VARCHAR(50) COMMENT '业务字段key', + `data_source_type` VARCHAR(50) COMMENT '数据源对应类型', + `module_type` VARCHAR(50) NOT NULL COMMENT '搜索模块(customer/lead/等)', + `organization_id` VARCHAR(32) COMMENT '组织id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '搜索字段脱敏配置' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + + +CREATE INDEX idx_module_type ON sys_search_field_mask_config (module_type ASC); +CREATE INDEX idx_organization_id ON sys_search_field_mask_config (organization_id ASC); +CREATE INDEX idx_field_id ON sys_search_field_mask_config (field_id ASC); + +ALTER TABLE opportunity MODIFY contact_id VARCHAR(32) COMMENT '联系人ID'; + +create index idx_organization_id on customer_contact (organization_id); +create index idx_phone on customer_contact (phone); + +create index idx_phone on clue (phone); + + + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.5/dml/V1.1.5_1_1__permission.sql b/backend/crm/src/main/resources/migration/1.1.5/dml/V1.1.5_1_1__permission.sql new file mode 100644 index 0000000..b2e930a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.5/dml/V1.1.5_1_1__permission.sql @@ -0,0 +1,5 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.5/dml/V1.1.5_2_1__data.sql b/backend/crm/src/main/resources/migration/1.1.5/dml/V1.1.5_2_1__data.sql new file mode 100644 index 0000000..825d76d --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.5/dml/V1.1.5_2_1__data.sql @@ -0,0 +1,18 @@ +-- set innodb lock wait timeout + +SET SESSION innodb_lock_wait_timeout = 7200; + +SET @customer_concat_addID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_concat_addID, 'CUSTOMER_CONCAT_ADD', 'CUSTOMER', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @customer_collaboration_addID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_collaboration_addID, 'CUSTOMER_COLLABORATION_ADD', 'CUSTOMER', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @clue_convert_businessID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@clue_convert_businessID, 'CLUE_CONVERT_BUSINESS', 'CLUE', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.8/ddl/V1.1.8_1__init.sql b/backend/crm/src/main/resources/migration/1.1.8/ddl/V1.1.8_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.8/ddl/V1.1.8_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.8/dml/V1.1.8_1_1__permission.sql b/backend/crm/src/main/resources/migration/1.1.8/dml/V1.1.8_1_1__permission.sql new file mode 100644 index 0000000..48de2a3 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.8/dml/V1.1.8_1_1__permission.sql @@ -0,0 +1,14 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- init import permissions for admin roles +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CUSTOMER_MANAGEMENT:IMPORT'); + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CUSTOMER_MANAGEMENT_CONTACT:IMPORT'); + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'OPPORTUNITY_MANAGEMENT:IMPORT'); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.9/ddl/V1.1.9_1__init.sql b/backend/crm/src/main/resources/migration/1.1.9/ddl/V1.1.9_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.9/ddl/V1.1.9_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.9/ddl/V1.1.9_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.1.9/ddl/V1.1.9_2__ga_ddl.sql new file mode 100644 index 0000000..ad9fbe8 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.9/ddl/V1.1.9_2__ga_ddl.sql @@ -0,0 +1,8 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- Allow opportunity customer_id null +ALTER TABLE opportunity MODIFY customer_id VARCHAR(32) NULL COMMENT '客户ID'; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.1.9/dml/V1.1.9_1_1__permission.sql b/backend/crm/src/main/resources/migration/1.1.9/dml/V1.1.9_1_1__permission.sql new file mode 100644 index 0000000..9aabc61 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.1.9/dml/V1.1.9_1_1__permission.sql @@ -0,0 +1,18 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- init import permissions for admin roles + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'PERSONAL_API_KEY:READ'); + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'PERSONAL_API_KEY:ADD'); + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'PERSONAL_API_KEY:UPDATE'); + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'PERSONAL_API_KEY:DELETE'); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.2.0/ddl/V1.2.0_1__init.sql b/backend/crm/src/main/resources/migration/1.2.0/ddl/V1.2.0_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.0/ddl/V1.2.0_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.2.0/ddl/V1.2.0_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.2.0/ddl/V1.2.0_2__ga_ddl.sql new file mode 100644 index 0000000..5478d40 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.0/ddl/V1.2.0_2__ga_ddl.sql @@ -0,0 +1,86 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +CREATE TABLE agent +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '名称', + `agent_module_id` VARCHAR(32) NOT NULL COMMENT '文件id', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `pos` BIGINT NOT NULL DEFAULT 0 COMMENT '同一节点下顺序', + `scope_id` TEXT NOT NULL COMMENT '应用范围', + `script` TEXT NOT NULL COMMENT '嵌入脚本', + `description` VARCHAR(1000) COMMENT '描述', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '智能体' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_name ON agent (name ASC); +CREATE INDEX idx_agent_module_id ON agent (agent_module_id ASC); + + +CREATE TABLE agent_module +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `name` VARCHAR(255) NOT NULL COMMENT '名称', + `parent_id` VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '父节点id', + `pos` BIGINT NOT NULL DEFAULT 0 COMMENT '同一节点下顺序', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '智能体模块' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON agent_module (organization_id ASC); +CREATE INDEX idx_name ON agent_module (name ASC); +CREATE INDEX idx_pos ON agent_module (pos ASC); +CREATE INDEX idx_parent_id ON agent_module (parent_id ASC); + + +CREATE TABLE agent_collection +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `user_id` VARCHAR(32) NOT NULL COMMENT '用户id', + `agent_id` VARCHAR(32) NOT NULL COMMENT '智能体id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '智能体收藏' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_user_id ON agent_collection (user_id ASC); + +CREATE TABLE sys_ui_settings +( + `param_key` VARCHAR(100) NOT NULL COMMENT 'key', + `param_value` VARCHAR(255) COMMENT 'value', + `type` VARCHAR(100) NOT NULL DEFAULT 'text' COMMENT 'type', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织ID', + PRIMARY KEY (param_key) +) COMMENT = '界面设置' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON sys_ui_settings (organization_id ASC); + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; + + + diff --git a/backend/crm/src/main/resources/migration/1.2.0/dml/V1.2.0_2_1__data.sql b/backend/crm/src/main/resources/migration/1.2.0/dml/V1.2.0_2_1__data.sql new file mode 100644 index 0000000..6576310 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.0/dml/V1.2.0_2_1__data.sql @@ -0,0 +1,14 @@ +-- set innodb lock wait timeout + +SET SESSION innodb_lock_wait_timeout = 7200; + +-- 初始智能体板菜单 +insert into sys_module value (UUID_SHORT(), '100001', 'agent', true, 8, + 'admin', UNIX_TIMESTAMP() * 1000, 'admin', UNIX_TIMESTAMP() * 1000); + +INSERT INTO `agent_module`(`id`, `organization_id`, `name`, `parent_id`, `pos`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES + (UUID_SHORT(), '100001', '默认文件夹', 'NONE', 0, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 'admin', 'admin'); + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.2.0/dml/V1.2.0_2_2__permission.sql b/backend/crm/src/main/resources/migration/1.2.0/dml/V1.2.0_2_2__permission.sql new file mode 100644 index 0000000..f22b6b8 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.0/dml/V1.2.0_2_2__permission.sql @@ -0,0 +1,16 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'AGENT:READ'), + (UUID_SHORT(), 'org_admin', 'AGENT:ADD'), + (UUID_SHORT(), 'org_admin', 'AGENT:UPDATE'), + (UUID_SHORT(), 'org_admin', 'AGENT:DELETE'); + +-- init product import permission +INSERT INTO sys_role_permission +(id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'PRODUCT_MANAGEMENT:IMPORT'); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.2.1/ddl/V1.2.1_1__init.sql b/backend/crm/src/main/resources/migration/1.2.1/ddl/V1.2.1_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.1/ddl/V1.2.1_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.2.1/ddl/V1.2.1_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.2.1/ddl/V1.2.1_2__ga_ddl.sql new file mode 100644 index 0000000..902d466 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.1/ddl/V1.2.1_2__ga_ddl.sql @@ -0,0 +1,33 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +CREATE TABLE sys_navigation +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `navigation_key` VARCHAR(20) NOT NULL COMMENT '导航栏key', + `enable` BIT(1) NOT NULL DEFAULT 1 COMMENT '启用/禁用', + `pos` BIGINT NOT NULL COMMENT '自定义排序', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + PRIMARY KEY (id) +) COMMENT = '系统导航设置' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON sys_navigation(organization_id ASC); + +alter table customer_owner + add reason_id varchar(32) null comment '公海原因ID'; + +alter table clue_owner + add reason_id VARCHAR(32) null comment '线索池原因ID'; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; + + + diff --git a/backend/crm/src/main/resources/migration/1.2.1/dml/V1.2.1_2_1__data.sql b/backend/crm/src/main/resources/migration/1.2.1/dml/V1.2.1_2_1__data.sql new file mode 100644 index 0000000..6948ff4 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.1/dml/V1.2.1_2_1__data.sql @@ -0,0 +1,25 @@ +-- set innodb lock wait timeout + +SET SESSION innodb_lock_wait_timeout = 7200; + +-- 初始导航栏 +insert into sys_navigation value (UUID_SHORT(), '100001', 'search', true, 1, + 'admin', UNIX_TIMESTAMP() * 1000, 'admin', UNIX_TIMESTAMP() * 1000); + +insert into sys_navigation value (UUID_SHORT(), '100001', 'agent', true, 2, + 'admin', UNIX_TIMESTAMP() * 1000, 'admin', UNIX_TIMESTAMP() * 1000); + +insert into sys_navigation value (UUID_SHORT(), '100001', 'notify', true, 3, + 'admin', UNIX_TIMESTAMP() * 1000, 'admin', UNIX_TIMESTAMP() * 1000); + +insert into sys_navigation value (UUID_SHORT(), '100001', 'about', true, 4, + 'admin', UNIX_TIMESTAMP() * 1000, 'admin', UNIX_TIMESTAMP() * 1000); + +insert into sys_navigation value (UUID_SHORT(), '100001', 'language', true, 5, + 'admin', UNIX_TIMESTAMP() * 1000, 'admin', UNIX_TIMESTAMP() * 1000); + +insert into sys_navigation value (UUID_SHORT(), '100001', 'help', true, 6, + 'admin', UNIX_TIMESTAMP() * 1000, 'admin', UNIX_TIMESTAMP() * 1000); + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.2.1/dml/V1.2.1_2_2__permission.sql b/backend/crm/src/main/resources/migration/1.2.1/dml/V1.2.1_2_2__permission.sql new file mode 100644 index 0000000..89a4bc0 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.1/dml/V1.2.1_2_2__permission.sql @@ -0,0 +1,12 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CUSTOMER_MANAGEMENT_POOL:UPDATE'); + +INSERT INTO sys_role_permission + (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CLUE_MANAGEMENT_POOL:UPDATE'); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.2.1/dml/V1.2.1_2_3__modify_tel.sql b/backend/crm/src/main/resources/migration/1.2.1/dml/V1.2.1_2_3__modify_tel.sql new file mode 100644 index 0000000..b5b7a94 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.1/dml/V1.2.1_2_3__modify_tel.sql @@ -0,0 +1,39 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- Remove phone prefix on system field +UPDATE clue SET phone = IF(phone LIKE '(+%)%', SUBSTRING(phone, LOCATE(')', phone) + 1), phone) +WHERE phone LIKE '(+%)%'; +UPDATE customer_contact SET phone = IF(phone LIKE '(+%)%', SUBSTRING(phone, LOCATE(')', phone) + 1), phone) +WHERE phone LIKE '(+%)%'; + +-- Remove phone prefix on custom field +UPDATE clue_field cf JOIN sys_module_field smf ON cf.field_id = smf.id AND smf.type = 'PHONE' +SET cf.field_value = IF(cf.field_value LIKE '(+%)%', SUBSTRING(cf.field_value, LOCATE(')', cf.field_value) + 1), cf.field_value) +WHERE cf.field_value LIKE '(+%)%'; + +UPDATE customer_field cf JOIN sys_module_field smf ON cf.field_id = smf.id AND smf.type = 'PHONE' +SET cf.field_value = IF(cf.field_value LIKE '(+%)%', SUBSTRING(cf.field_value, LOCATE(')', cf.field_value) + 1), cf.field_value) +WHERE cf.field_value LIKE '(+%)%'; + +UPDATE customer_contact_field ccf JOIN sys_module_field smf ON ccf.field_id = smf.id AND smf.type = 'PHONE' +SET ccf.field_value = IF(ccf.field_value LIKE '(+%)%', SUBSTRING(ccf.field_value, LOCATE(')', ccf.field_value) + 1), ccf.field_value) +WHERE ccf.field_value LIKE '(+%)%'; + +UPDATE opportunity_field opf JOIN sys_module_field smf ON opf.field_id = smf.id AND smf.type = 'PHONE' +SET opf.field_value = IF(opf.field_value LIKE '(+%)%', SUBSTRING(opf.field_value, LOCATE(')', opf.field_value) + 1), opf.field_value) +WHERE opf.field_value LIKE '(+%)%'; + +UPDATE product_field pf JOIN sys_module_field smf ON pf.field_id = smf.id AND smf.type = 'PHONE' +SET pf.field_value = IF(pf.field_value LIKE '(+%)%', SUBSTRING(pf.field_value, LOCATE(')', pf.field_value) + 1), pf.field_value) +WHERE pf.field_value LIKE '(+%)%'; + +UPDATE follow_up_plan_field fupf JOIN sys_module_field smf ON fupf.field_id = smf.id AND smf.type = 'PHONE' +SET fupf.field_value = IF(fupf.field_value LIKE '(+%)%', SUBSTRING(fupf.field_value, LOCATE(')', fupf.field_value) + 1), fupf.field_value) +WHERE fupf.field_value LIKE '(+%)%'; + +UPDATE follow_up_record_field furf JOIN sys_module_field smf ON furf.field_id = smf.id AND smf.type = 'PHONE' +SET furf.field_value = IF(furf.field_value LIKE '(+%)%', SUBSTRING(furf.field_value, LOCATE(')', furf.field_value) + 1), furf.field_value) +WHERE furf.field_value LIKE '(+%)%'; + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.2.2/ddl/V1.2.2_1__init.sql b/backend/crm/src/main/resources/migration/1.2.2/ddl/V1.2.2_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.2/ddl/V1.2.2_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.2.2/ddl/V1.2.2_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.2.2/ddl/V1.2.2_2__ga_ddl.sql new file mode 100644 index 0000000..a09bed3 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.2/ddl/V1.2.2_2__ga_ddl.sql @@ -0,0 +1,10 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +ALTER TABLE opportunity ADD COLUMN pos BIGINT DEFAULT NULL; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; + + + diff --git a/backend/crm/src/main/resources/migration/1.2.2/dml/V1.2.2_2_1__data.sql b/backend/crm/src/main/resources/migration/1.2.2/dml/V1.2.2_2_1__data.sql new file mode 100644 index 0000000..963a29e --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.2/dml/V1.2.2_2_1__data.sql @@ -0,0 +1,6 @@ +-- set innodb lock wait timeout + +SET SESSION innodb_lock_wait_timeout = 7200; + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.2.2/dml/V1.2.2_2_2__permission.sql b/backend/crm/src/main/resources/migration/1.2.2/dml/V1.2.2_2_2__permission.sql new file mode 100644 index 0000000..b2e930a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.2/dml/V1.2.2_2_2__permission.sql @@ -0,0 +1,5 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.2.2/dml/V1.2.2_2_3__modify_tel.sql b/backend/crm/src/main/resources/migration/1.2.2/dml/V1.2.2_2_3__modify_tel.sql new file mode 100644 index 0000000..c895db1 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.2/dml/V1.2.2_2_3__modify_tel.sql @@ -0,0 +1,11 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- Init opportunity pos +UPDATE opportunity +SET pos = (SELECT rn + FROM (SELECT id, ROW_NUMBER() OVER (ORDER BY id) as rn + FROM opportunity) t2 + WHERE t2.id = opportunity.id); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.2.3/ddl/V1.2.3_1__init.sql b/backend/crm/src/main/resources/migration/1.2.3/ddl/V1.2.3_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.3/ddl/V1.2.3_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.2.3/ddl/V1.2.3_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.2.3/ddl/V1.2.3_2__ga_ddl.sql new file mode 100644 index 0000000..e24443d --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.3/ddl/V1.2.3_2__ga_ddl.sql @@ -0,0 +1,44 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +alter table sys_message_task + add ding_talk_enable bit default b'0' null comment '钉钉启用' after we_com_enable; + +alter table sys_message_task + add lark_enable bit default b'0' null comment '飞书启用' after ding_talk_enable; + +-- customer_id 字段允许为空 +ALTER TABLE customer_contact MODIFY COLUMN customer_id varchar(32) NULL COMMENT '客户id'; + +CREATE TABLE opportunity_stage_config +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(16) NOT NULL COMMENT '值', + `type` VARCHAR(50) NOT NULL COMMENT '类型', + `rate` VARCHAR(10) NOT NULL COMMENT '赢率', + `afoot_roll_back` BIT(1) DEFAULT 0 COMMENT '进行中回退设置', + `end_roll_back` BIT(1) DEFAULT 0 COMMENT '完结回退设置', + `pos` BIGINT NOT NULL COMMENT '顺序', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '商机阶段配置' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + + +-- 添加跟进记录默认排序索引 +create index idx_follow_create + on follow_up_record (follow_time desc, create_time desc); +create index idx_estimated_create + on follow_up_plan (estimated_time desc, create_time desc); + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; + + + diff --git a/backend/crm/src/main/resources/migration/1.2.3/dml/V1.2.3_2_1__data.sql b/backend/crm/src/main/resources/migration/1.2.3/dml/V1.2.3_2_1__data.sql new file mode 100644 index 0000000..00fbf9f --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.3/dml/V1.2.3_2_1__data.sql @@ -0,0 +1,21 @@ +-- set innodb lock wait timeout + +SET SESSION innodb_lock_wait_timeout = 7200; + + +INSERT INTO `opportunity_stage_config`(`id`, `name`, `type`, `rate`, `afoot_roll_back`, `end_roll_back`, `pos`, `organization_id`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES + ('CREATE', '新建', 'AFOOT', '10', b'1', b'0', 1, '100001', 1760175899000, 1760175899000, 'admin', 'admin'), + ('CLEAR_REQUIREMENTS', '需求明确', 'AFOOT', '30', b'1', b'0', 2, '100001', 1760175899000, 1760175899000, 'admin', 'admin'), + ('SCHEME_VALIDATION', '方案验证', 'AFOOT', '50', b'1', b'0', 3, '100001', 1760175899000, 1760175899000, 'admin', 'admin'), + ('PROJECT_PROPOSAL_REPORT', '立项汇报', 'AFOOT', '70', b'1', b'0', 4, '100001', 1760175899000, 1760175899000, 'admin', 'admin'), + ('BUSINESS_PROCUREMENT', '商务采购', 'AFOOT', '90', b'1', b'0', 5, '100001', 1760175899000, 1760175899000, 'admin', 'admin'), + ('SUCCESS', '成功', 'END', '100', b'1', b'0', 6, '100001', 1760175899000, 1760175899000, 'admin', 'admin'), + ('FAIL', '失败', 'END', '0', b'1', b'0', 7, '100001', 1760175899000, 1760175899000, 'admin', 'admin'); + +-- 加入记录/计划顶部导航栏 +update sys_navigation set pos = pos + 1 where organization_id = '100001' and pos >= 2; +insert into sys_navigation value (UUID_SHORT(), '100001', 'event', true, 2, + 'admin', UNIX_TIMESTAMP() * 1000, 'admin', UNIX_TIMESTAMP() * 1000); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.2.3/dml/V1.2.3_2_2__permission.sql b/backend/crm/src/main/resources/migration/1.2.3/dml/V1.2.3_2_2__permission.sql new file mode 100644 index 0000000..b2e930a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.3/dml/V1.2.3_2_2__permission.sql @@ -0,0 +1,5 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.2.3/dml/V1.2.3_2_3__modify_tel.sql b/backend/crm/src/main/resources/migration/1.2.3/dml/V1.2.3_2_3__modify_tel.sql new file mode 100644 index 0000000..5f9a303 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.2.3/dml/V1.2.3_2_3__modify_tel.sql @@ -0,0 +1,11 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + + +DELETE FROM sys_organization_config WHERE type = 'AUTH'; + +DELETE FROM sys_organization_config_detail WHERE type in ('WE_COM_OAUTH2', 'WECOM_OAUTH2', 'DINGTALK_OAUTH2', 'WECOM_NOTICE', 'DINGTALK_NOTICE', 'WECOM_CODE', 'DINGTALK_CODE'); + + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.3.0/ddl/V1.3.0_1__init.sql b/backend/crm/src/main/resources/migration/1.3.0/ddl/V1.3.0_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.3.0/ddl/V1.3.0_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.3.0/ddl/V1.3.0_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.3.0/ddl/V1.3.0_2__ga_ddl.sql new file mode 100644 index 0000000..93903d8 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.3.0/ddl/V1.3.0_2__ga_ddl.sql @@ -0,0 +1,11 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +ALTER TABLE dashboard CHANGE COLUMN resource_id resource_url VARCHAR(500) NOT NULL COMMENT '仪表板url'; + + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; + + + diff --git a/backend/crm/src/main/resources/migration/1.3.0/dml/V1.3.0_2_1__data.sql b/backend/crm/src/main/resources/migration/1.3.0/dml/V1.3.0_2_1__data.sql new file mode 100644 index 0000000..6c7f6da --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.3.0/dml/V1.3.0_2_1__data.sql @@ -0,0 +1,13 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +ALTER TABLE agent + ADD COLUMN type varchar(50) NOT NULL COMMENT '类型'; + +ALTER TABLE agent + ADD COLUMN workspace_id varchar(50) COMMENT '工作空间id'; + +ALTER TABLE agent + ADD COLUMN application_id varchar(50) COMMENT '智能体应用id'; + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.3.0/dml/V1.3.0_2_2__permission.sql b/backend/crm/src/main/resources/migration/1.3.0/dml/V1.3.0_2_2__permission.sql new file mode 100644 index 0000000..1594a1a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.3.0/dml/V1.3.0_2_2__permission.sql @@ -0,0 +1,7 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- init customer merge permission +INSERT INTO sys_role_permission (id, role_id, permission_id) VALUES (UUID_SHORT(), 'org_admin', 'CUSTOMER_MANAGEMENT:MERGE'); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.3.0/dml/V1.3.0_2_3__modify_tel.sql b/backend/crm/src/main/resources/migration/1.3.0/dml/V1.3.0_2_3__modify_tel.sql new file mode 100644 index 0000000..4500e8f --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.3.0/dml/V1.3.0_2_3__modify_tel.sql @@ -0,0 +1,10 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + + +UPDATE agent set type = 'SCRIPT'; + +UPDATE sys_module set enable = b'1' where module_key = 'dashboard'; + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.3.2/ddl/V1.3.2_1__init.sql b/backend/crm/src/main/resources/migration/1.3.2/ddl/V1.3.2_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.3.2/ddl/V1.3.2_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.3.5/ddl/V1.3.5_1__init.sql b/backend/crm/src/main/resources/migration/1.3.5/ddl/V1.3.5_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.3.5/ddl/V1.3.5_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.3.5/dml/V1.3.5_2_1__data.sql b/backend/crm/src/main/resources/migration/1.3.5/dml/V1.3.5_2_1__data.sql new file mode 100644 index 0000000..ed0968e --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.3.5/dml/V1.3.5_2_1__data.sql @@ -0,0 +1,4 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.3.5/dml/V1.3.5_2_2__permission.sql b/backend/crm/src/main/resources/migration/1.3.5/dml/V1.3.5_2_2__permission.sql new file mode 100644 index 0000000..929a4e0 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.3.5/dml/V1.3.5_2_2__permission.sql @@ -0,0 +1,16 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +INSERT INTO sys_role_permission (id, role_id, permission_id) VALUES (UUID_SHORT(), 'org_admin', 'CUSTOMER_MANAGEMENT:TRANSFER'); +INSERT INTO sys_role_permission (id, role_id, permission_id) VALUES (UUID_SHORT(), 'org_admin', 'OPPORTUNITY_MANAGEMENT:TRANSFER'); +INSERT INTO sys_role_permission (id, role_id, permission_id) VALUES (UUID_SHORT(), 'org_admin', 'CLUE_MANAGEMENT:TRANSFER'); + +INSERT INTO sys_role_permission (id, role_id, permission_id) VALUES (UUID_SHORT(), 'sales_manager', 'CUSTOMER_MANAGEMENT:TRANSFER'); +INSERT INTO sys_role_permission (id, role_id, permission_id) VALUES (UUID_SHORT(), 'sales_manager', 'OPPORTUNITY_MANAGEMENT:TRANSFER'); +INSERT INTO sys_role_permission (id, role_id, permission_id) VALUES (UUID_SHORT(), 'sales_manager', 'CLUE_MANAGEMENT:TRANSFER'); + +INSERT INTO sys_role_permission (id, role_id, permission_id) VALUES (UUID_SHORT(), 'sales_staff', 'CUSTOMER_MANAGEMENT:TRANSFER'); +INSERT INTO sys_role_permission (id, role_id, permission_id) VALUES (UUID_SHORT(), 'sales_staff', 'OPPORTUNITY_MANAGEMENT:TRANSFER'); +INSERT INTO sys_role_permission (id, role_id, permission_id) VALUES (UUID_SHORT(), 'sales_staff', 'CLUE_MANAGEMENT:TRANSFER'); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.3.6/ddl/V1.3.6_1__init.sql b/backend/crm/src/main/resources/migration/1.3.6/ddl/V1.3.6_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.3.6/ddl/V1.3.6_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.3.6/ddl/V1.3.6_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.3.6/ddl/V1.3.6_2__ga_ddl.sql new file mode 100644 index 0000000..901886e --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.3.6/ddl/V1.3.6_2__ga_ddl.sql @@ -0,0 +1,14 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +alter table sys_organization_user + ADD COLUMN onboarding_date BIGINT COMMENT '入职时间'; + + + + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; + + + diff --git a/backend/crm/src/main/resources/migration/1.3.6/dml/V1.3.6_2_1__data.sql b/backend/crm/src/main/resources/migration/1.3.6/dml/V1.3.6_2_1__data.sql new file mode 100644 index 0000000..ed0968e --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.3.6/dml/V1.3.6_2_1__data.sql @@ -0,0 +1,4 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.4.0/ddl/V1.4.0_1__init.sql b/backend/crm/src/main/resources/migration/1.4.0/ddl/V1.4.0_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.4.0/ddl/V1.4.0_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.4.0/ddl/V1.4.0_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.4.0/ddl/V1.4.0_2__ga_ddl.sql new file mode 100644 index 0000000..c367806 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.4.0/ddl/V1.4.0_2__ga_ddl.sql @@ -0,0 +1,286 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +CREATE TABLE opportunity_quotation +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '名称', + `opportunity_id` VARCHAR(32) NOT NULL COMMENT '商机id', + `amount` DECIMAL(14, 2) NOT NULL COMMENT '累计金额', + `approval_status` VARCHAR(50) NOT NULL COMMENT '审核状态', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织ID', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '商机报价单' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_opportunity_id ON opportunity_quotation (opportunity_id ASC); +CREATE INDEX idx_organization_id ON opportunity_quotation (organization_id ASC); +CREATE INDEX idx_approval_status ON opportunity_quotation (approval_status); + +CREATE TABLE opportunity_quotation_field +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '报价单id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` VARCHAR(255) NOT NULL COMMENT '自定义属性值', + `ref_sub_id` VARCHAR(32) COMMENT '引用子表格ID;关联的子表格字段ID', + `row_id` VARCHAR(32) COMMENT '子表格行实例ID;行实例数据ID', + PRIMARY KEY (id) +) COMMENT = '商机报价单自定义属性' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON opportunity_quotation_field (resource_id ASC); + +CREATE TABLE opportunity_quotation_field_blob +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '报价单id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` TEXT NOT NULL COMMENT '自定义属性值', + `ref_sub_id` VARCHAR(32) COMMENT '父引用ID;关联的子表格字段ID', + `row_id` VARCHAR(32) COMMENT '行实例ID;行实例数据ID', + PRIMARY KEY (id) +) COMMENT = '商机报价单自定义属性大文本' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON opportunity_quotation_field_blob (resource_id ASC); + +CREATE TABLE opportunity_quotation_approval +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `quotation_id` VARCHAR(32) NOT NULL COMMENT '商机报价单id', + `approval_status` VARCHAR(50) NOT NULL COMMENT '审核状态', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '商机报价单审批' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_quotation_id ON opportunity_quotation_approval (quotation_id ASC); + +CREATE TABLE opportunity_quotation_snapshot +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `quotation_id` VARCHAR(32) NOT NULL COMMENT '报价单id', + `quotation_prop` TEXT COMMENT '表单属性快照', + `quotation_value` TEXT COMMENT '表单值快照', + PRIMARY KEY (id) +) COMMENT = '商机报价单快照' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_quotation_id ON opportunity_quotation_snapshot (quotation_id ASC); + + +CREATE TABLE contract +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '合同名称', + `customer_id` VARCHAR(32) NOT NULL COMMENT '客户id', + `owner` VARCHAR(32) NOT NULL COMMENT '合同负责人', + `amount` DECIMAL(20, 10) COMMENT '金额', + `number` VARCHAR(50) NOT NULL COMMENT '编号', + `approval_status` VARCHAR(50) COMMENT '审核状态', + `archived_status` VARCHAR(50) NOT NULL COMMENT '归档状态', + `status` VARCHAR(50) NOT NULL COMMENT '合同状态', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `void_reason` VARCHAR(255) COMMENT '作废原因', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '合同' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_name ON contract (name ASC); +CREATE INDEX idx_customer_id ON contract (customer_id ASC); +CREATE INDEX idx_owner ON contract (owner ASC); +CREATE INDEX idx_number ON contract (number ASC); +CREATE INDEX idx_organization_id ON contract (organization_id ASC); +CREATE INDEX idx_approval_status ON contract (approval_status); +CREATE INDEX idx_archived_status ON contract (archived_status); +CREATE INDEX idx_status ON contract (status); + + +CREATE TABLE contract_field +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '合同id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` VARCHAR(255) NOT NULL COMMENT '自定义属性值', + `ref_sub_id` VARCHAR(32) COMMENT '引用子表格ID;关联的子表格字段ID', + `row_id` VARCHAR(32) COMMENT '子表格行实例ID;行实例数据ID', + PRIMARY KEY (id) +) COMMENT = '合同自定义属性' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON contract_field (resource_id ASC); +CREATE INDEX idx_ref_sub_id ON contract_field (ref_sub_id ASC); + + +CREATE TABLE contract_field_blob +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '合同id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` TEXT NOT NULL COMMENT '自定义属性值', + `ref_sub_id` VARCHAR(32) COMMENT '引用子表格ID;关联的子表格字段ID', + `row_id` VARCHAR(32) COMMENT '子表格行实例ID;行实例数据ID', + PRIMARY KEY (id) +) COMMENT = '合同自定义属性大文本' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON contract_field_blob (resource_id ASC); +CREATE INDEX idx_ref_sub_id ON contract_field_blob (ref_sub_id ASC); + + +CREATE TABLE contract_snapshot +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `contract_id` VARCHAR(32) NOT NULL COMMENT '合同id', + `contract_prop` TEXT(255) COMMENT '表单属性快照', + `contract_value` TEXT(255) COMMENT '表单值快照', + PRIMARY KEY (id) +) COMMENT = '合同快照' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + + +CREATE INDEX idx_contract_id ON contract_snapshot (contract_id ASC); + +-- 回款计划 +CREATE TABLE contract_payment_plan +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `contract_id` VARCHAR(32) NOT NULL COMMENT '合同ID', + `owner` VARCHAR(32) NOT NULL COMMENT '负责人', + `plan_status` VARCHAR(32) NOT NULL COMMENT '计划状态', + `plan_amount` DECIMAL(20, 10) COMMENT '计划回款金额', + `plan_end_time` BIGINT COMMENT '计划回款时间', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '合同回款计划' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_contract_id ON contract_payment_plan (contract_id ASC); +CREATE INDEX idx_create_time ON contract_payment_plan (create_time ASC); +CREATE INDEX idx_owner ON contract_payment_plan (owner ASC); + +CREATE TABLE contract_payment_plan_field +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '回款计划id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` VARCHAR(255) NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '回款计划自定义属性' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON contract_payment_plan_field (resource_id ASC); + +CREATE TABLE contract_payment_plan_field_blob +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '回款计划id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` TEXT NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '回款计划自定义属性大文本' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON contract_payment_plan_field_blob (resource_id ASC); + +-- 产品价格表 +CREATE TABLE product_price +( + `id` VARCHAR(32) NOT NULL COMMENT 'ID', + `name` VARCHAR(255) NOT NULL COMMENT '价格表名称', + `status` VARCHAR(32) NOT NULL COMMENT '状态', + `pos` BIGINT NOT NULL COMMENT '自定义排序', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织ID', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '价格' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_org_id ON product_price (organization_id ASC); +CREATE INDEX idx_status ON product_price (status); + +CREATE TABLE product_price_field +( + `id` VARCHAR(32) NOT NULL COMMENT 'ID', + `resource_id` VARCHAR(32) NOT NULL COMMENT '价格表ID', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性ID', + `field_value` VARCHAR(255) NOT NULL COMMENT '自定义属性值', + `ref_sub_id` VARCHAR(32) COMMENT '引用子表格ID;关联的子表格字段ID', + `row_id` VARCHAR(32) COMMENT '子表格行实例ID;行实例数据ID', + PRIMARY KEY (id) +) COMMENT = '价格自定义属性' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON product_price_field (resource_id ASC); +CREATE INDEX idx_ref_sub_id ON product_price_field (ref_sub_id ASC); + +CREATE TABLE product_price_field_blob +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '价格id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` TEXT NOT NULL COMMENT '自定义属性值', + `ref_sub_id` VARCHAR(32) COMMENT '引用子表格ID;关联的子表格字段ID', + `row_id` VARCHAR(32) COMMENT '子表格行实例ID;行实例数据ID', + PRIMARY KEY (id) +) COMMENT = '价格表自定义属性大文本' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON product_price_field_blob (resource_id ASC); +CREATE INDEX idx_ref_sub_id ON product_price_field_blob (ref_sub_id ASC); + +-- modify record, plan content field length (3000) +ALTER TABLE follow_up_record MODIFY COLUMN content VARCHAR (3000) NOT NULL COMMENT '跟进内容'; +ALTER TABLE follow_up_plan MODIFY COLUMN content VARCHAR (3000) NOT NULL COMMENT '跟进内容'; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.4.0/dml/V1.4.0_2_1__data.sql b/backend/crm/src/main/resources/migration/1.4.0/dml/V1.4.0_2_1__data.sql new file mode 100644 index 0000000..8a2d262 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.4.0/dml/V1.4.0_2_1__data.sql @@ -0,0 +1,19 @@ +-- set innodb lock wait timeout + +SET SESSION innodb_lock_wait_timeout = 7200; + +insert into sys_module value (UUID_SHORT(), '100001', 'contract', true, 9, + 'admin', UNIX_TIMESTAMP() * 1000, 'admin', UNIX_TIMESTAMP() * 1000); + +insert into sys_module value (UUID_SHORT(), '100001', 'tender', true, 10, + 'admin', UNIX_TIMESTAMP() * 1000, 'admin', UNIX_TIMESTAMP() * 1000); + +SET @customer_addID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_addID, 'BUSINESS_QUOTATION_APPROVAL', 'OPPORTUNITY', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @customer_addID = UUID_SHORT();-- 初始化消息通知 +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_addID, 'BUSINESS_QUOTATION_DELETED', 'OPPORTUNITY', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.4.0/dml/V1.4.0_2_2__permission.sql b/backend/crm/src/main/resources/migration/1.4.0/dml/V1.4.0_2_2__permission.sql new file mode 100644 index 0000000..dfce8ce --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.4.0/dml/V1.4.0_2_2__permission.sql @@ -0,0 +1,134 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'OPPORTUNITY_QUOTATION:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'OPPORTUNITY_QUOTATION:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'OPPORTUNITY_QUOTATION:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'OPPORTUNITY_QUOTATION:DELETE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'OPPORTUNITY_QUOTATION:DOWNLOAD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'OPPORTUNITY_QUOTATION:APPROVAL'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'OPPORTUNITY_QUOTATION:VOIDED'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'TENDER:READ'); +-- init sales manager permissions +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'OPPORTUNITY_QUOTATION:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'OPPORTUNITY_QUOTATION:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'OPPORTUNITY_QUOTATION:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'OPPORTUNITY_QUOTATION:DOWNLOAD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'OPPORTUNITY_QUOTATION:APPROVAL'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'OPPORTUNITY_QUOTATION:VOIDED'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'TENDER:READ'); +-- init sales_staff permissions +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'OPPORTUNITY_QUOTATION:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'OPPORTUNITY_QUOTATION:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'OPPORTUNITY_QUOTATION:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'OPPORTUNITY_QUOTATION:DOWNLOAD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'OPPORTUNITY_QUOTATION:VOIDED'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'TENDER:READ'); + +-- init price permissions +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'PRICE:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'PRICE:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'PRICE:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'PRICE:DELETE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'PRICE:IMPORT'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'PRICE:EXPORT'); + + +-- contract +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT:EXPORT'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT:ARCHIVE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT:VOIDED'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT:APPROVAL'); + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT:EXPORT'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT:VOIDED'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT:APPROVAL'); + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT:EXPORT'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT:VOIDED'); + +-- contract_payment_plan +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_PAYMENT_PLAN:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_PAYMENT_PLAN:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_PAYMENT_PLAN:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_PAYMENT_PLAN:DELETE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_PAYMENT_PLAN:EXPORT'); + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_PAYMENT_PLAN:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_PAYMENT_PLAN:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_PAYMENT_PLAN:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_PAYMENT_PLAN:EXPORT'); + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT_PAYMENT_PLAN:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT_PAYMENT_PLAN:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT_PAYMENT_PLAN:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT_PAYMENT_PLAN:EXPORT'); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.4.0/dml/V1.4.0_2_3__modify_tel.sql b/backend/crm/src/main/resources/migration/1.4.0/dml/V1.4.0_2_3__modify_tel.sql new file mode 100644 index 0000000..9f59ddf --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.4.0/dml/V1.4.0_2_3__modify_tel.sql @@ -0,0 +1,7 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +UPDATE sys_operation_log SET module = 'OPPORTUNITY_INDEX' WHERE module = 'OPPORTUNITY'; + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.4.0/dml/V1.4.0_2_4__modify_log.sql b/backend/crm/src/main/resources/migration/1.4.0/dml/V1.4.0_2_4__modify_log.sql new file mode 100644 index 0000000..4bac75e --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.4.0/dml/V1.4.0_2_4__modify_log.sql @@ -0,0 +1,6 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +UPDATE sys_operation_log SET module = 'PRODUCT_MANAGEMENT_PRO' WHERE module = 'PRODUCT_MANAGEMENT'; + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.4.1/ddl/V1.4.1_1__init.sql b/backend/crm/src/main/resources/migration/1.4.1/ddl/V1.4.1_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.4.1/ddl/V1.4.1_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.4.1/ddl/V1.4.1_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.4.1/ddl/V1.4.1_2__ga_ddl.sql new file mode 100644 index 0000000..bc8eca8 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.4.1/ddl/V1.4.1_2__ga_ddl.sql @@ -0,0 +1,12 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- modify lead phone length (255) +ALTER TABLE clue MODIFY COLUMN phone VARCHAR(255) NULL COMMENT '联系人电话'; + +ALTER TABLE contract DROP COLUMN archived_status; + +ALTER TABLE contract RENAME COLUMN status TO stage; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.4.1/dml/V1.4.1_2_1__data.sql b/backend/crm/src/main/resources/migration/1.4.1/dml/V1.4.1_2_1__data.sql new file mode 100644 index 0000000..ed0968e --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.4.1/dml/V1.4.1_2_1__data.sql @@ -0,0 +1,4 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.4.1/dml/V1.4.1_2_2__modify_tel.sql b/backend/crm/src/main/resources/migration/1.4.1/dml/V1.4.1_2_2__modify_tel.sql new file mode 100644 index 0000000..1ea3ec0 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.4.1/dml/V1.4.1_2_2__modify_tel.sql @@ -0,0 +1,23 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + + +DELETE FROM sys_role_permission WHERE permission_id in ('CONTRACT:ARCHIVE', 'CONTRACT:VOIDED'); + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT:STAGE'); + +DELETE FROM sys_user_view_condition WHERE name = 'archivedStatus'; + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT:DELETE'); + + +UPDATE sys_user_view_condition suc + JOIN sys_user_view su +ON suc.sys_user_view_id = su.id + SET suc.NAME = 'stage' +WHERE suc.NAME = 'status' + AND su.resource_type = 'CONTRACT'; + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.4.2/ddl/V1.4.2_1__init.sql b/backend/crm/src/main/resources/migration/1.4.2/ddl/V1.4.2_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.4.2/ddl/V1.4.2_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.4.2/ddl/V1.4.2_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.4.2/ddl/V1.4.2_2__ga_ddl.sql new file mode 100644 index 0000000..1547fae --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.4.2/ddl/V1.4.2_2__ga_ddl.sql @@ -0,0 +1,21 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- modify field table, add unique id +ALTER TABLE product_price_field ADD COLUMN biz_id VARCHAR(32) NULL COMMENT '唯一业务行ID' AFTER row_id; +ALTER TABLE product_price_field_blob ADD COLUMN biz_id VARCHAR(32) NULL COMMENT '唯一业务行ID' AFTER row_id; +ALTER TABLE contract_field ADD COLUMN biz_id VARCHAR(32) NULL COMMENT '唯一业务行ID' AFTER row_id; +ALTER TABLE contract_field_blob ADD COLUMN biz_id VARCHAR(32) NULL COMMENT '唯一业务行ID' AFTER row_id; +ALTER TABLE opportunity_quotation_field ADD COLUMN biz_id VARCHAR(32) NULL COMMENT '唯一业务行ID' AFTER row_id; +ALTER TABLE opportunity_quotation_field_blob ADD COLUMN biz_id VARCHAR(32) NULL COMMENT '唯一业务行ID' AFTER row_id; + +-- add unique index +CREATE UNIQUE INDEX uk_price_field_cell ON product_price_field (resource_id, row_id, field_id); +CREATE UNIQUE INDEX uk_price_field_blob_cell ON product_price_field_blob (resource_id, row_id, field_id); +CREATE UNIQUE INDEX uk_contract_field_cell ON contract_field (resource_id, row_id, field_id); +CREATE UNIQUE INDEX uk_contract_field_blob_cell ON contract_field_blob (resource_id, row_id, field_id); +CREATE UNIQUE INDEX uk_opp_quotation_field_cell ON opportunity_quotation_field (resource_id, row_id, field_id); +CREATE UNIQUE INDEX uk_opp_quotation_field_blob_cell ON opportunity_quotation_field_blob (resource_id, row_id, field_id); + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.4.2/dml/V1.4.2_2_1__data.sql b/backend/crm/src/main/resources/migration/1.4.2/dml/V1.4.2_2_1__data.sql new file mode 100644 index 0000000..16fd256 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.4.2/dml/V1.4.2_2_1__data.sql @@ -0,0 +1,47 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- init biz_id by group (resource_id, row_id) +UPDATE product_price_field f JOIN ( + SELECT resource_id, row_id, UUID_SHORT() AS biz_id FROM product_price_field + WHERE row_id IS NOT NULL AND biz_id IS NULL GROUP BY resource_id, row_id +) t ON f.resource_id = t.resource_id AND f.row_id = t.row_id +SET f.biz_id = t.biz_id +WHERE f.row_id IS NOT NULL AND f.biz_id IS NULL; + +UPDATE product_price_field_blob f JOIN ( + SELECT resource_id, row_id, UUID_SHORT() AS biz_id FROM product_price_field_blob + WHERE row_id IS NOT NULL AND biz_id IS NULL GROUP BY resource_id, row_id +) t ON f.resource_id = t.resource_id AND f.row_id = t.row_id +SET f.biz_id = t.biz_id +WHERE f.row_id IS NOT NULL AND f.biz_id IS NULL; + +UPDATE contract_field f JOIN ( + SELECT resource_id, row_id, UUID_SHORT() AS biz_id FROM contract_field + WHERE row_id IS NOT NULL AND biz_id IS NULL GROUP BY resource_id, row_id +) t ON f.resource_id = t.resource_id AND f.row_id = t.row_id +SET f.biz_id = t.biz_id +WHERE f.row_id IS NOT NULL AND f.biz_id IS NULL; + +UPDATE contract_field_blob f JOIN ( + SELECT resource_id, row_id, UUID_SHORT() AS biz_id FROM contract_field_blob + WHERE row_id IS NOT NULL AND biz_id IS NULL GROUP BY resource_id, row_id +) t ON f.resource_id = t.resource_id AND f.row_id = t.row_id +SET f.biz_id = t.biz_id +WHERE f.row_id IS NOT NULL AND f.biz_id IS NULL; + +UPDATE opportunity_quotation_field f JOIN ( + SELECT resource_id, row_id, UUID_SHORT() AS biz_id FROM opportunity_quotation_field + WHERE row_id IS NOT NULL AND biz_id IS NULL GROUP BY resource_id, row_id +) t ON f.resource_id = t.resource_id AND f.row_id = t.row_id +SET f.biz_id = t.biz_id +WHERE f.row_id IS NOT NULL AND f.biz_id IS NULL; + +UPDATE opportunity_quotation_field_blob f JOIN ( + SELECT resource_id, row_id, UUID_SHORT() AS biz_id FROM opportunity_quotation_field_blob + WHERE row_id IS NOT NULL AND biz_id IS NULL GROUP BY resource_id, row_id +) t ON f.resource_id = t.resource_id AND f.row_id = t.row_id +SET f.biz_id = t.biz_id +WHERE f.row_id IS NOT NULL AND f.biz_id IS NULL; + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.5.0/ddl/V1.5.0_1__init.sql b/backend/crm/src/main/resources/migration/1.5.0/ddl/V1.5.0_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.5.0/ddl/V1.5.0_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.5.0/ddl/V1.5.0_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.5.0/ddl/V1.5.0_2__ga_ddl.sql new file mode 100644 index 0000000..09198db --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.5.0/ddl/V1.5.0_2__ga_ddl.sql @@ -0,0 +1,194 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +CREATE TABLE sys_message_task_config +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `organization_id` VARCHAR(50) NOT NULL COMMENT '组织id', + `event` VARCHAR(255) NOT NULL COMMENT '通知事件类型', + `task_type` VARCHAR(64) NOT NULL COMMENT '任务类型', + `value` TEXT COMMENT '通知配置值', + PRIMARY KEY (id) +) COMMENT = '消息通知配置' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_organization_id ON sys_message_task_config (organization_id ASC); +CREATE INDEX idx_task_type ON sys_message_task_config (task_type ASC); +CREATE INDEX idx_event ON sys_message_task_config (event ASC); + + +CREATE TABLE business_title +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '公司名称', + `type` VARCHAR(50) COMMENT '来源类型', + `identification_number` VARCHAR(255) COMMENT '纳税人识别号', + `opening_bank` VARCHAR(255) COMMENT '开户银行', + `bank_account` VARCHAR(255) COMMENT '银行账号', + `registration_address` VARCHAR(255) COMMENT '注册地址', + `phone_number` VARCHAR(255) COMMENT '注册电话', + `registered_capital` VARCHAR(255) COMMENT '注册资本', + `company_size` VARCHAR(255) COMMENT '公司规模', + `registration_number` VARCHAR(255) COMMENT '工商注册号', + `approval_status` VARCHAR(50) COMMENT '审核状态', + `unapproved_reason` VARCHAR(255) COMMENT '不通过原因', + `organization_id` VARCHAR(50) NOT NULL COMMENT '组织id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '工商抬头' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + + +CREATE INDEX idx_organization_id ON business_title (organization_id ASC); +CREATE INDEX idx_name ON business_title (name ASC); + + +CREATE TABLE business_title_config +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `field` VARCHAR(255) NOT NULL COMMENT '字段', + `required` BIT(1) NOT NULL DEFAULT 1 COMMENT '是否必填', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + PRIMARY KEY (id) +) COMMENT = '工商抬头配置表' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE TABLE contract_invoice +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '发票名称', + `contract_id` VARCHAR(32) NOT NULL COMMENT '合同id', + `owner` VARCHAR(32) NOT NULL COMMENT '负责人', + `amount` DECIMAL(20, 10) COMMENT '开票金额', + `invoice_type` VARCHAR(50) COMMENT '发票类型', + `tax_rate` DECIMAL(20, 10) COMMENT '税率', + `approval_status` VARCHAR(50) COMMENT '审核状态', + `business_title_id` VARCHAR(50) COMMENT '工商抬头ID', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '发票' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_contract_id ON contract_invoice (contract_id ASC); + +CREATE TABLE contract_invoice_field +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '合同id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` VARCHAR(255) NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '发票自定义属性' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON contract_invoice_field (resource_id ASC); + +CREATE TABLE contract_invoice_field_blob +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '合同id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` TEXT NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '发票自定义属性大文本' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON contract_invoice_field_blob (resource_id ASC); + +CREATE TABLE contract_invoice_snapshot +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `invoice_id` VARCHAR(32) NOT NULL COMMENT '合同id', + `invoice_prop` TEXT(255) COMMENT '表单属性快照', + `invoice_value` TEXT(255) COMMENT '表单值快照', + PRIMARY KEY (id) +) COMMENT = '发票快照' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_invoice_id ON contract_invoice_snapshot (invoice_id ASC); + +-- init contract_payment_record field +CREATE TABLE contract_payment_record +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '回款记录名称', + `no` VARCHAR(50) COMMENT '回款编号', + `owner` VARCHAR(32) NOT NULL COMMENT '负责人', + `contract_id` VARCHAR(32) NOT NULL COMMENT '合同ID', + `payment_plan_id` VARCHAR(32) COMMENT '回款计划ID', + `record_amount` DECIMAL(20, 10) COMMENT '回款金额', + `record_end_time` BIGINT COMMENT '回款时间', + `record_bank` VARCHAR(32) COMMENT '收款银行', + `record_bank_no` VARCHAR(32) COMMENT '收款账号', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '合同回款记录' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_contract_id ON contract_payment_record (contract_id ASC); +CREATE INDEX idx_plan_id ON contract_payment_record (payment_plan_id ASC); +CREATE INDEX idx_owner ON contract_payment_record (owner ASC); + +CREATE TABLE contract_payment_record_field +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '回款记录id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` VARCHAR(255) NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '合同回款记录自定义属性' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON contract_payment_record_field (resource_id ASC); + +CREATE TABLE contract_payment_record_field_blob +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '回款记录id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` TEXT NOT NULL COMMENT '自定义属性值', + PRIMARY KEY (id) +) COMMENT = '合同回款记录自定义属性大文本' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON contract_payment_record_field_blob (resource_id ASC); + +-- modify form_key length to 50 +ALTER TABLE sys_module_form MODIFY COLUMN form_key VARCHAR (50); +-- add payment plan name field +ALTER TABLE contract_payment_plan + ADD COLUMN name VARCHAR(255) NOT NULL COMMENT '回款计划名称' AFTER id; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.5.0/ddl/V1.5.0_3__modify_sub_key.sql b/backend/crm/src/main/resources/migration/1.5.0/ddl/V1.5.0_3__modify_sub_key.sql new file mode 100644 index 0000000..4406947 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.5.0/ddl/V1.5.0_3__modify_sub_key.sql @@ -0,0 +1,19 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- modify unique index of sub table +ALTER TABLE product_price_field DROP INDEX uk_price_field_cell, + ADD UNIQUE INDEX uk_price_field_cell (resource_id, ref_sub_id, row_id, field_id); +ALTER TABLE product_price_field_blob DROP INDEX uk_price_field_blob_cell, + ADD UNIQUE INDEX uk_price_field_blob_cell (resource_id, ref_sub_id, row_id, field_id); +ALTER TABLE contract_field DROP INDEX uk_contract_field_cell, + ADD UNIQUE INDEX uk_contract_field_cell (resource_id, ref_sub_id, row_id, field_id); +ALTER TABLE contract_field_blob DROP INDEX uk_contract_field_blob_cell, + ADD UNIQUE INDEX uk_contract_field_blob_cell (resource_id, ref_sub_id, row_id, field_id); +ALTER TABLE opportunity_quotation_field DROP INDEX uk_opp_quotation_field_cell, + ADD UNIQUE INDEX uk_opp_quotation_field_cell (resource_id, ref_sub_id, row_id, field_id); +ALTER TABLE opportunity_quotation_field_blob DROP INDEX uk_opp_quotation_field_blob_cell, + ADD UNIQUE INDEX uk_opp_quotation_field_blob_cell (resource_id, ref_sub_id, row_id, field_id); + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.5.0/dml/V1.5.0_2_1__data.sql b/backend/crm/src/main/resources/migration/1.5.0/dml/V1.5.0_2_1__data.sql new file mode 100644 index 0000000..5cf06af --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.5.0/dml/V1.5.0_2_1__data.sql @@ -0,0 +1,73 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- 初始化消息通知 +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_addID, 'BUSINESS_QUOTATION_EXPIRING', 'OPPORTUNITY', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task_config (id, organization_id, event, task_type, value) + VALUE (@customer_addID, '100001', 'BUSINESS_QUOTATION_EXPIRING', 'OPPORTUNITY','{"timeList":[{"timeValue":3,"timeUnit":"DAY"}],"userIds":["OWNER"],"roleIds":[],"ownerEnable":false,"ownerLevel":0,"roleEnable":false}'); + + +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_addID, 'BUSINESS_QUOTATION_EXPIRED', 'OPPORTUNITY', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + + +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task_config (id, organization_id, event, task_type, value) + VALUE (@customer_addID, '100001', 'BUSINESS_QUOTATION_EXPIRED', 'OPPORTUNITY','{"timeList":[],"userIds":["OWNER"],"roleIds":[],"ownerEnable":false,"ownerLevel":0,"roleEnable":false}'); + +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_addID, 'CONTRACT_ARCHIVED', 'CONTRACT', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task_config (id, organization_id, event, task_type, value) + VALUE (@customer_addID, '100001', 'CONTRACT_ARCHIVED', 'CONTRACT','{"timeList":[],"userIds":["OWNER"],"roleIds":[],"ownerEnable":false,"ownerLevel":0,"roleEnable":false}'); + +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_addID, 'CONTRACT_VOID', 'CONTRACT', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task_config (id, organization_id, event, task_type, value) + VALUE (@customer_addID, '100001', 'CONTRACT_VOID', 'CONTRACT','{"timeList":[],"userIds":["OWNER"],"roleIds":[],"ownerEnable":false,"ownerLevel":0,"roleEnable":false}'); + +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_addID, 'CONTRACT_EXPIRING', 'CONTRACT', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task_config (id, organization_id, event, task_type, value) + VALUE (@customer_addID, '100001', 'CONTRACT_EXPIRING', 'CONTRACT','{"timeList":[{"timeValue":3,"timeUnit":"DAY"}],"userIds":["OWNER"],"roleIds":[],"ownerEnable":false,"ownerLevel":0,"roleEnable":false}'); + +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_addID, 'CONTRACT_EXPIRED', 'CONTRACT', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task_config (id, organization_id, event, task_type, value) + VALUE (@customer_addID, '100001', 'CONTRACT_EXPIRED', 'CONTRACT','{"timeList":[],"userIds":["OWNER"],"roleIds":[],"ownerEnable":false,"ownerLevel":0,"roleEnable":false}'); + + +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_addID, 'CONTRACT_PAYMENT_EXPIRING', 'CONTRACT', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task_config (id, organization_id, event, task_type, value) + VALUE (@customer_addID, '100001', 'CONTRACT_PAYMENT_EXPIRING', 'CONTRACT','{"timeList":[{"timeValue":3,"timeUnit":"DAY"}],"userIds":["OWNER"],"roleIds":[],"ownerEnable":false,"ownerLevel":0,"roleEnable":false}'); + +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_addID, 'CONTRACT_PAYMENT_EXPIRED', 'CONTRACT', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task_config (id, organization_id, event, task_type, value) + VALUE (@customer_addID, '100001', 'CONTRACT_PAYMENT_EXPIRED', 'CONTRACT','{"timeList":[],"userIds":["OWNER"],"roleIds":[],"ownerEnable":false,"ownerLevel":0,"roleEnable":false}'); + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.5.0/dml/V1.5.0_2_2__modify_tel.sql b/backend/crm/src/main/resources/migration/1.5.0/dml/V1.5.0_2_2__modify_tel.sql new file mode 100644 index 0000000..9d84f9f --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.5.0/dml/V1.5.0_2_2__modify_tel.sql @@ -0,0 +1,35 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +alter table opportunity_quotation + add until_time bigint not null comment '有效期至'; + +-- modify form_key length to 50 +ALTER TABLE sys_module_form MODIFY COLUMN form_key VARCHAR (50); + +CREATE INDEX idx_until_time + ON opportunity_quotation (until_time); + + +CREATE INDEX idx_plan_end_time + ON contract_payment_plan (plan_end_time); + + +INSERT INTO business_title_config (id, field, required, organization_id) +VALUES (UUID_SHORT(), 'name', true, '100001'), + (UUID_SHORT(), 'identification_number', true, '100001'), + (UUID_SHORT(), 'opening_bank', false, '100001'), + (UUID_SHORT(), 'bank_account', false, '100001'), + (UUID_SHORT(), 'registration_address', false, '100001'), + (UUID_SHORT(), 'phone_number', false, '100001'), + (UUID_SHORT(), 'registered_capital', false, '100001'), + (UUID_SHORT(), 'company_size', false, '100001'), + (UUID_SHORT(), 'registration_number', false, '100001'); + +alter table contract + add start_time bigint not null comment '合同开始时间'; +alter table contract + add end_time bigint not null comment '合同结束时间'; + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.5.0/dml/V1.5.0_2_3__permission.sql b/backend/crm/src/main/resources/migration/1.5.0/dml/V1.5.0_2_3__permission.sql new file mode 100644 index 0000000..17961e3 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.5.0/dml/V1.5.0_2_3__permission.sql @@ -0,0 +1,109 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- Init contract payment record permissions +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_PAYMENT_RECORD:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_PAYMENT_RECORD:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_PAYMENT_RECORD:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_PAYMENT_RECORD:DELETE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_PAYMENT_RECORD:IMPORT'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_PAYMENT_RECORD:EXPORT'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_BUSINESS_TITLE:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_BUSINESS_TITLE:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_BUSINESS_TITLE:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_BUSINESS_TITLE:DELETE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_BUSINESS_TITLE:EXPORT'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_BUSINESS_TITLE:IMPORT'); + + + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_PAYMENT_RECORD:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_PAYMENT_RECORD:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_PAYMENT_RECORD:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_PAYMENT_RECORD:IMPORT'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_PAYMENT_RECORD:EXPORT'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_BUSINESS_TITLE:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_BUSINESS_TITLE:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_BUSINESS_TITLE:UPDATE'); + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT_PAYMENT_RECORD:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT_PAYMENT_RECORD:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT_PAYMENT_RECORD:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT_PAYMENT_RECORD:IMPORT'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT_PAYMENT_RECORD:EXPORT'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT_BUSINESS_TITLE:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT_BUSINESS_TITLE:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT_BUSINESS_TITLE:UPDATE'); + + +-- Init invoice permissions +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_INVOICE:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_INVOICE:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_INVOICE:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_INVOICE:DELETE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_INVOICE:EXPORT'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT_INVOICE:APPROVAL'); + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_INVOICE:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_INVOICE:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_INVOICE:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_INVOICE:DELETE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT_INVOICE:EXPORT'); + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT_INVOICE:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT_INVOICE:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT_INVOICE:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT_INVOICE:EXPORT'); + +-- init contract payment operate permission +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CONTRACT:PAYMENT'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CONTRACT:PAYMENT'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CONTRACT:PAYMENT'); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.5.1/ddl/V1.5.1_1__init.sql b/backend/crm/src/main/resources/migration/1.5.1/ddl/V1.5.1_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.5.1/ddl/V1.5.1_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.5.1/ddl/V1.5.1_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.5.1/ddl/V1.5.1_2__ga_ddl.sql new file mode 100644 index 0000000..82dc8eb --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.5.1/ddl/V1.5.1_2__ga_ddl.sql @@ -0,0 +1,6 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.5.1/ddl/V1.5.1_3__modify.sql b/backend/crm/src/main/resources/migration/1.5.1/ddl/V1.5.1_3__modify.sql new file mode 100644 index 0000000..386ef33 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.5.1/ddl/V1.5.1_3__modify.sql @@ -0,0 +1,13 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- Modify quotation_prop column from TEXT to LONGTEXT in opportunity_quotation_snapshot table +ALTER TABLE opportunity_quotation_snapshot + MODIFY COLUMN quotation_prop LONGTEXT NULL COMMENT '表单属性快照'; +ALTER TABLE contract_snapshot + MODIFY contract_prop LONGTEXT NULL COMMENT '表单属性快照'; +ALTER TABLE contract_invoice_snapshot + MODIFY invoice_prop LONGTEXT NULL COMMENT '表单属性快照'; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.5.1/ddl/V1.5.1_4__record_del.sql b/backend/crm/src/main/resources/migration/1.5.1/ddl/V1.5.1_4__record_del.sql new file mode 100644 index 0000000..8ba2ae4 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.5.1/ddl/V1.5.1_4__record_del.sql @@ -0,0 +1,9 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- Delete payment record bank column +ALTER TABLE contract_payment_record DROP COLUMN record_bank; +ALTER TABLE contract_payment_record DROP COLUMN record_bank_no; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.5.1/dml/V1.5.1_2_1__data.sql b/backend/crm/src/main/resources/migration/1.5.1/dml/V1.5.1_2_1__data.sql new file mode 100644 index 0000000..5a8094e --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.5.1/dml/V1.5.1_2_1__data.sql @@ -0,0 +1,12 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- 初始化消息通知 +SET @customer_addID = UUID_SHORT(); +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@customer_addID, 'SYNC_ORGANIZATION_STRUCTURE', 'SYSTEM', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +-- init advance search settings +INSERT INTO sys_parameter value ('advance.search.setting', 'false', 'text'); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.5.2/ddl/V1.5.2_1__init.sql b/backend/crm/src/main/resources/migration/1.5.2/ddl/V1.5.2_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.5.2/ddl/V1.5.2_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.5.2/ddl/V1.5.2_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.5.2/ddl/V1.5.2_2__ga_ddl.sql new file mode 100644 index 0000000..ce11f2d --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.5.2/ddl/V1.5.2_2__ga_ddl.sql @@ -0,0 +1,14 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +ALTER TABLE business_title + ADD `area` VARCHAR(255) COMMENT '所属地区'; + +ALTER TABLE business_title + ADD `scale` VARCHAR(255) COMMENT '企业规模'; + +ALTER TABLE business_title + ADD `industry` VARCHAR(255) COMMENT '国标行业'; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.5.2/dml/V1.5.2_2_1__data.sql b/backend/crm/src/main/resources/migration/1.5.2/dml/V1.5.2_2_1__data.sql new file mode 100644 index 0000000..e22d11a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.5.2/dml/V1.5.2_2_1__data.sql @@ -0,0 +1,14 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +INSERT INTO business_title_config (id, field, required, organization_id) +VALUES (UUID_SHORT(), 'area', false, '100001'), + (UUID_SHORT(), 'scale', false, '100001'), + (UUID_SHORT(), 'industry', false, '100001'); + +INSERT INTO sys_dict_config (module,organization_id,enabled) +VALUES ('QUOTATION_APPROVAL','100001',1), + ('CONTRACT_APPROVAL','100001',1), + ('INVOICE_APPROVAL','100001',1); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.6.0/ddl/V1.6.0_1__init.sql b/backend/crm/src/main/resources/migration/1.6.0/ddl/V1.6.0_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.6.0/ddl/V1.6.0_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.6.0/ddl/V1.6.0_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.6.0/ddl/V1.6.0_2__ga_ddl.sql new file mode 100644 index 0000000..c1d70e0 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.6.0/ddl/V1.6.0_2__ga_ddl.sql @@ -0,0 +1,101 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +CREATE TABLE sales_order +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `number` VARCHAR(50) NOT NULL COMMENT '编号', + `name` VARCHAR(255) NOT NULL COMMENT '订单名称', + `customer_id` VARCHAR(32) COMMENT '客户id', + `contract_id` VARCHAR(32) COMMENT '合同id', + `owner` VARCHAR(32) COMMENT '订单负责人', + `amount` DECIMAL(20, 10) COMMENT '金额', + `stage` VARCHAR(50) NOT NULL COMMENT '订单状态', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '订单' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_customer_id ON sales_order (customer_id ASC); +CREATE INDEX idx_contract_id ON sales_order (contract_id ASC); +CREATE INDEX idx_owner ON sales_order (owner ASC); +CREATE INDEX idx_name ON sales_order (name ASC); + + +CREATE TABLE sales_order_field +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '订单id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` VARCHAR(255) NOT NULL COMMENT '自定义属性值', + `ref_sub_id` VARCHAR(32) COMMENT '引用子表格ID;关联的子表格字段ID', + `row_id` VARCHAR(32) COMMENT '子表格行实例ID;行实例数据ID', + `biz_id` VARCHAR(32) COMMENT '唯一业务行ID', + PRIMARY KEY (id) +) COMMENT = '订单自定义属性' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON sales_order_field (resource_id ASC); + + +CREATE TABLE sales_order_field_blob +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `resource_id` VARCHAR(32) NOT NULL COMMENT '订单id', + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id', + `field_value` TEXT NOT NULL COMMENT '自定义属性值', + `ref_sub_id` VARCHAR(32) COMMENT '引用子表格ID;关联的子表格字段ID', + `row_id` VARCHAR(32) COMMENT '子表格行实例ID;行实例数据ID', + `biz_id` VARCHAR(32) COMMENT '唯一业务行ID', + PRIMARY KEY (id) +) COMMENT = '订单自定义属性大文本' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON sales_order_field_blob (resource_id ASC); + +CREATE TABLE sales_order_snapshot +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `order_id` VARCHAR(32) NOT NULL COMMENT '订单id', + `order_prop` LONGTEXT COMMENT '订单属性快照', + `order_value` TEXT COMMENT '订单值快照', + PRIMARY KEY (id) +) COMMENT = '订单快照' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_order_id ON sales_order_snapshot (order_id ASC); + + +CREATE TABLE sales_order_stage_config +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '订单状态', + `type` VARCHAR(50) NOT NULL COMMENT '状态类型', + `afoot_roll_back` BIT(1) DEFAULT 0 COMMENT '进行中回退设置', + `end_roll_back` BIT(1) DEFAULT 0 COMMENT '完结回退设置', + `pos` BIGINT NOT NULL COMMENT '顺序', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '订单状态流设置' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.6.0/dml/V1.6.0_2_1__data.sql b/backend/crm/src/main/resources/migration/1.6.0/dml/V1.6.0_2_1__data.sql new file mode 100644 index 0000000..dbaa684 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.6.0/dml/V1.6.0_2_1__data.sql @@ -0,0 +1,17 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +insert into sys_module value (UUID_SHORT(), '100001', 'order', true, 10, + 'admin', UNIX_TIMESTAMP() * 1000, 'admin', UNIX_TIMESTAMP() * 1000); + +INSERT INTO `sales_order_stage_config`(`id`, `name`, `type`, `afoot_roll_back`, `end_roll_back`, `pos`, `organization_id`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES + ('CREATE', '新建', 'AFOOT', b'1', b'0', 1, '100001', 1772521969413, 1772521969413, 'admin', 'admin'), + ('PENDING_SHIPMENT', '待发货', 'AFOOT', b'1', b'0', 2, '100001', 1772521969413, 1772521969413, 'admin', 'admin'), + ('PARTIALLY_SHIPPED', '部分发货', 'AFOOT', b'1', b'0', 3, '100001', 1772521969413, 1772521969413, 'admin', 'admin'), + ('SHIPPED', '已发货', 'AFOOT', b'1', b'0', 4, '100001', 1772521969413, 1772521969413, 'admin', 'admin'), + ('PENDING_ACCEPTANCE', '待验收', 'AFOOT', b'1', b'0', 5, '100001', 1772521969413, 1772521969413, 'admin', 'admin'), + ('COMPLETED', '已完成', 'END', b'1', b'0', 6, '100001', 1772521969413, 1772521969413, 'admin', 'admin'), + ('VOIDED', '已作废', 'END', b'1', b'0', 7, '100001', 1772521969413, 1772521969413, 'admin', 'admin'); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.6.0/dml/V1.6.0_2_2__permission.sql b/backend/crm/src/main/resources/migration/1.6.0/dml/V1.6.0_2_2__permission.sql new file mode 100644 index 0000000..641bffb --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.6.0/dml/V1.6.0_2_2__permission.sql @@ -0,0 +1,35 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'ORDER:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'ORDER:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'ORDER:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'ORDER:DELETE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'ORDER:DOWNLOAD'); + +-- init sales manager permissions +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'ORDER:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'ORDER:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'ORDER:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'ORDER:DOWNLOAD'); + +-- init sales_staff permissions +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'ORDER:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'ORDER:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'ORDER:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'ORDER:DOWNLOAD'); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.6.1/ddl/V1.6.1_1__init.sql b/backend/crm/src/main/resources/migration/1.6.1/ddl/V1.6.1_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.6.1/ddl/V1.6.1_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.6.1/ddl/V1.6.1_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.6.1/ddl/V1.6.1_2__ga_ddl.sql new file mode 100644 index 0000000..cbdad4a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.6.1/ddl/V1.6.1_2__ga_ddl.sql @@ -0,0 +1,15 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +ALTER TABLE business_title + ADD `city` VARCHAR(255) COMMENT '市'; + +ALTER TABLE business_title + CHANGE COLUMN area province VARCHAR (255) COMMENT '省'; + +ALTER TABLE business_title + ADD remark VARCHAR(255) COMMENT '备注'; + + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.6.1/dml/V1.6.1_2_1__data.sql b/backend/crm/src/main/resources/migration/1.6.1/dml/V1.6.1_2_1__data.sql new file mode 100644 index 0000000..7a3ad0c --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.6.1/dml/V1.6.1_2_1__data.sql @@ -0,0 +1,11 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +INSERT INTO business_title_config (id, field, required, organization_id) +VALUES (UUID_SHORT(), 'city', false, '100001'), + (UUID_SHORT(), 'remark', false, '100001'); + +update business_title_config set `field` = 'province' where field = 'area' and organization_id = '100001'; + + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.6.2/ddl/V1.6.2_1__init.sql b/backend/crm/src/main/resources/migration/1.6.2/ddl/V1.6.2_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.6.2/ddl/V1.6.2_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.6.2/ddl/V1.6.2_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.6.2/ddl/V1.6.2_2__ga_ddl.sql new file mode 100644 index 0000000..018685c --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.6.2/ddl/V1.6.2_2__ga_ddl.sql @@ -0,0 +1,8 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- modify sys_module_field_blob prop column to longtext +ALTER TABLE sys_module_field_blob modify column prop longtext; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.6.2/dml/V1.6.2_2_1__data.sql b/backend/crm/src/main/resources/migration/1.6.2/dml/V1.6.2_2_1__data.sql new file mode 100644 index 0000000..4acefaa --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.6.2/dml/V1.6.2_2_1__data.sql @@ -0,0 +1,12 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- 初始化消息通知 +SET @clue_add_id = UUID_SHORT(); +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@clue_add_id, 'CLUE_ADD', 'CLUE', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); +SET @business_add_id = UUID_SHORT(); +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@business_add_id, 'BUSINESS_ADD', 'OPPORTUNITY', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.7.0/ddl/V1.7.0_1__init.sql b/backend/crm/src/main/resources/migration/1.7.0/ddl/V1.7.0_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.7.0/ddl/V1.7.0_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.7.0/ddl/V1.7.0_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.7.0/ddl/V1.7.0_2__ga_ddl.sql new file mode 100644 index 0000000..cce911f --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.7.0/ddl/V1.7.0_2__ga_ddl.sql @@ -0,0 +1,261 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +CREATE TABLE approval_flow( + `id` VARCHAR(32) NOT NULL COMMENT 'id' , + `current_version_id` VARCHAR(32) NOT NULL COMMENT '当前版本ID' , + `number` VARCHAR(50) NOT NULL COMMENT '流程编码;流程编码,自增,格式:CTR-APV-001(合同),INV-APV-001(发票),ORD-APV-001(订单)' , + `name` VARCHAR(255) NOT NULL COMMENT '流程名称;流程名称' , + `form_type` VARCHAR(50) NOT NULL COMMENT '表单类型;表单类型:QUOTATION(报价)、CONTRACT(合同)、INVOICE(发票)、ORDER(订单)' , + `create_execute` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '新建时执行;新建时是否执行审批流' , + `update_execute` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '编辑时执行;编辑时是否执行审批流' , + `submitter_can_revoke` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '允许提交人撤销;允许提交人撤销审批中的申请' , + `allow_batch_process` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '允许批量处理;允许审批人批量处理此流程的多个任务' , + `allow_withdraw` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '允许撤回;允许审批人撤回审批' , + `allow_add_sign` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '允许加签' , + `duplicate_approver_rule` VARCHAR(20) NOT NULL DEFAULT 'FIRST_ONLY' COMMENT '重复审批人:FIRST_ONLY/SEQUENTIAL_ALL/EACH' , + `require_comment` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否必须填写审批意见' , + `enable` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启用状态:0-禁用,1-启用' , + `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否删除:0-未删除,1-已删除' , + `create_time` BIGINT NOT NULL COMMENT '创建时间' , + `update_time` BIGINT NOT NULL COMMENT '更新时间' , + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人' , + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人' , + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id' , + `status_permissions` VARCHAR(4000) NOT NULL COMMENT '状态权限配置(JSON格式)' , + `description` VARCHAR(3000) COMMENT '流程描述' , + PRIMARY KEY (id) +) COMMENT = '审批流主表' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE TABLE approval_flow_version( + `id` VARCHAR(32) NOT NULL COMMENT 'ID' , + `flow_id` VARCHAR(32) NOT NULL COMMENT '审批流ID' , + `create_time` BIGINT NOT NULL COMMENT '创建时间' , + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人' , + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id' , + PRIMARY KEY (id) +) COMMENT = '审批流版本表' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_flow_id ON approval_flow_version(flow_id ASC); + +CREATE TABLE approval_node( + `id` VARCHAR(32) NOT NULL COMMENT 'id' , + `flow_version_id` VARCHAR(32) NOT NULL COMMENT '审批流版本ID' , + `number` VARCHAR(50) NOT NULL COMMENT '节点编码;格式:PN001、PN002' , + `name` VARCHAR(255) NOT NULL COMMENT '节点名称' , + `node_type` VARCHAR(50) NOT NULL COMMENT '节点类型:START\CONDITION\DEFAULT\END' , + `sort` INT(11) NOT NULL DEFAULT 0 COMMENT '排序序号' , + PRIMARY KEY (id) +) COMMENT = '审批节点配置表' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_flow_version_id ON approval_node(flow_version_id ASC); + +CREATE TABLE approval_node_approver( + `id` VARCHAR(32) NOT NULL COMMENT 'id' , + `flow_version_id` VARCHAR(32) NOT NULL COMMENT '审批流版本ID;流程ID' , + `approval_type` VARCHAR(20) NOT NULL DEFAULT 'AUTO_PASS' COMMENT '审批类型;审批类型:MANUAL(人工审批)、AUTO_PASS(自动通过)、AUTO_REJECT(自动拒绝)' , + `multi_approver_mode` VARCHAR(20) NOT NULL COMMENT '多人审批方式;多人审批方式:ALL(会签)/ANY(或签)/SEQUENTIAL(依次审批)' , + `empty_approver_action` VARCHAR(20) NOT NULL DEFAULT 'AUTO_PASS' COMMENT '审批人为空时动作:AUTO_PASS(自动通过)/ASSIGN_SPECIFIC(指定人员审批)/ASSIGN_ADMIN(转交给审批管理员)' , + `fallback_approver` VARCHAR(32) COMMENT '审批人为空时,兜底审批人', + `same_submitter_action` VARCHAR(20) NOT NULL DEFAULT 'SKIP' COMMENT '审批人与提交人相同时动作:SKIP(自动跳过)/ALLOW(由提交人审批)/ASSIGN_SUPERIOR(转交给直属上级审批)' , + `approver_type` VARCHAR(50) COMMENT '审批人类型' , + `approver_direction` VARCHAR(20) NULL DEFAULT 'BOTTOM_UP' COMMENT '审批人方向:BOTTOM_UP(从下往上)/TOP_DOWN(从上往下),仅MULTIPLE_SUPERIOR/MULTIPLE_DEPT_HEAD生效' , + `cc_type` VARCHAR(50) COMMENT '抄送人类型' , + `cc_direction` VARCHAR(20) NULL DEFAULT 'BOTTOM_UP' COMMENT '抄送人方向:BOTTOM_UP(从下往上)/TOP_DOWN(从上往下),仅MULTIPLE_SUPERIOR/MULTIPLE_DEPT_HEAD生效' , + `cc_list` text COMMENT '抄送人列表(JSON数组)' , + `approver_list` text COMMENT '审批人列表(JSON数组)' , + `pass_post_config` VARCHAR(4000) COMMENT '审批通过后配置(JSON格式)' , + `reject_post_config` VARCHAR(4000) COMMENT '审批驳回后配置(JSON格式)' , + `field_permissions` VARCHAR(4000) COMMENT '字段权限配置(JSON格式)' , + PRIMARY KEY (id) +) COMMENT = '审批人节点配置表' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_flow_version_id ON approval_node_approver(flow_version_id ASC); + +CREATE TABLE approval_node_condition( + `id` VARCHAR(32) NOT NULL COMMENT 'id' , + `flow_version_id` VARCHAR(32) NOT NULL COMMENT '审批流版本ID' , + `condition_config` VARCHAR(4000) COMMENT '条件配置JSON' , + PRIMARY KEY (id) +) COMMENT = '条件节点配置表' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_flow_version_id ON approval_node_condition(flow_version_id ASC); + +CREATE TABLE approval_node_link( + `id` VARCHAR(32) NOT NULL COMMENT '主键' , + `flow_version_id` VARCHAR(32) NOT NULL COMMENT '审批流版本ID' , + `from_node_id` VARCHAR(32) NOT NULL COMMENT '源节点ID' , + `to_node_id` VARCHAR(32) NOT NULL COMMENT '目标节点ID' , + `sort` INT NOT NULL DEFAULT 0 COMMENT '分支评估顺序' , + PRIMARY KEY (id) +) COMMENT = '节点连接表' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_flow_version_id_from_id ON approval_node_link(flow_version_id ASC,from_node_id ASC); + +CREATE TABLE approval_instance( + `id` VARCHAR(32) NOT NULL COMMENT 'ID' , + `flow_version_id` VARCHAR(32) NOT NULL COMMENT '审批流版本ID' , + `type` VARCHAR(20) NOT NULL COMMENT '表单类型' , + `resource_id` VARCHAR(32) NOT NULL COMMENT '审批的业务数据ID' , + `submitter_id` VARCHAR(32) NOT NULL COMMENT '提交人ID' , + `current_node_id` VARCHAR(32) COMMENT '当前节点ID' , + `approval_status` VARCHAR(20) NOT NULL COMMENT '审批状态' , + `submit_time` BIGINT COMMENT '提审时间' , + `approval_time` BIGINT COMMENT '审批完成时间' , + `create_time` BIGINT NOT NULL COMMENT '创建时间' , + `update_time` BIGINT NOT NULL COMMENT '更新时间' , + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人' , + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人' , + PRIMARY KEY (id) +) COMMENT = '审批实例表' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_flow_version_id ON approval_instance(flow_version_id ASC); +CREATE INDEX idx_resource_id ON approval_instance(resource_id ASC); +CREATE INDEX idx_submitter_id ON approval_instance(submitter_id ASC); + +CREATE TABLE approval_task( + `id` VARCHAR(32) NOT NULL COMMENT 'ID' , + `node_id` VARCHAR(32) NOT NULL COMMENT '节点ID' , + `node_round` INT NOT NULL COMMENT '节点轮次' , + `instance_id` VARCHAR(32) NOT NULL COMMENT '审批实例ID' , + `approver_id` VARCHAR(20) NOT NULL COMMENT '审批人ID' , + `status` VARCHAR(20) NOT NULL COMMENT '任务状态; 待审批: PENDING, 审批中: APPROVING, 已通过: APPROVED, 已驳回: UNAPPROVED, 已撤销: REVOKED' , + `type` VARCHAR(20) COMMENT '任务类型; 抄送: CC; 加签: SN; 退回: BK; 普通: NL;' , + `action` VARCHAR(20) COMMENT '执行操作; 同意: APPROVE;驳回: REJECT;加签: SIGN;退回: BACK;' , + `create_time` BIGINT NOT NULL COMMENT '创建时间' , + `update_time` BIGINT NOT NULL COMMENT '更新时间' , + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人' , + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人' , + PRIMARY KEY (id) +) COMMENT = '审批任务表' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_instance_node_id ON approval_task(instance_id ASC,node_id ASC); +CREATE INDEX idx_approver_id ON approval_task(approver_id ASC); + +CREATE TABLE approval_add_sign_task( + `id` VARCHAR(32) NOT NULL COMMENT '主键ID' , + `type` VARCHAR(20) NOT NULL COMMENT '加签方式;{before: 在我之前、after: 在我之后' , + `task_id` VARCHAR(32) NOT NULL COMMENT '加签任务ID' , + `sign_task_id` VARCHAR(32) NOT NULL COMMENT '被加签的任务ID' , + `root_task_id` VARCHAR(32) COMMENT '根任务ID' , + `sort` BIGINT NOT NULL COMMENT '顺序;排序越小越往前' , + `comment` TEXT COMMENT '加签意见' , + PRIMARY KEY (id) +) COMMENT = '加签任务表' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_task_id ON approval_add_sign_task(task_id ASC); +CREATE INDEX idx_sign_task_id ON approval_add_sign_task(sign_task_id ASC); +CREATE INDEX idx_root_task_id ON approval_add_sign_task(root_task_id ASC); + +CREATE TABLE approval_return_back_record( + `id` VARCHAR(32) NOT NULL COMMENT '主键ID' , + `instance_id` VARCHAR(32) NOT NULL COMMENT '审批实例ID' , + `task_id` VARCHAR(32) NOT NULL COMMENT '当前任务ID' , + `return_to_node_id` VARCHAR(32) NOT NULL COMMENT '退回至任务ID' , + `return_reason` TEXT COMMENT '退回原因' , + `return_user_id` VARCHAR(32) NOT NULL COMMENT '退回操作人' , + PRIMARY KEY (id) +) COMMENT = '退回记录表' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_instance_id ON approval_return_back_record(instance_id ASC); + +CREATE TABLE approval_record( + `id` VARCHAR(32) NOT NULL COMMENT 'ID' , + `instance_id` VARCHAR(32) NOT NULL COMMENT '审批实例ID' , + `task_id` VARCHAR(32) COMMENT '任务ID' , + `node_id` VARCHAR(32) NOT NULL COMMENT '节点ID' , + `node_round` INT NOT NULL COMMENT '节点轮次' , + `result` VARCHAR(255) COMMENT '审批结果' , + `comment` TEXT COMMENT '审批意见' , + `create_time` BIGINT NOT NULL COMMENT '创建时间' , + `update_time` BIGINT NOT NULL COMMENT '更新时间' , + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人' , + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人' , + PRIMARY KEY (id) +) COMMENT = '审批记录表' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_instance_task_id ON approval_record(instance_id ASC,task_id ASC); + +CREATE TABLE approval_instance_attachment( + `id` VARCHAR(32) NOT NULL COMMENT 'ID' , + `instance_id` VARCHAR(32) NOT NULL COMMENT '审批实例ID' , + `element_id` VARCHAR(32) NOT NULL COMMENT '审批节点ID' , + `attachment_id` VARCHAR(32) NOT NULL COMMENT '附件ID' , + PRIMARY KEY (id) +) COMMENT = '审批实例附件表' + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_instance_id ON approval_instance_attachment(instance_id ASC); +CREATE INDEX idx_element_id ON approval_instance_attachment(element_id ASC); + + +CREATE TABLE contract_stage_config +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `name` VARCHAR(255) NOT NULL COMMENT '合同状态', + `type` VARCHAR(50) NOT NULL COMMENT '状态类型', + `afoot_roll_back` BIT(1) DEFAULT 0 COMMENT '进行中回退设置', + `end_roll_back` BIT(1) DEFAULT 0 COMMENT '完结回退设置', + `pos` BIGINT NOT NULL COMMENT '顺序', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '合同状态流设置' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + + +ALTER TABLE contract ADD COLUMN pos BIGINT DEFAULT NULL; + +ALTER TABLE sales_order ADD COLUMN pos BIGINT DEFAULT NULL; + +-- add order approval status +ALTER TABLE sales_order ADD COLUMN approval_status VARCHAR(50) NOT NULL COMMENT '审批状态' AFTER stage; + +-- add quotation invalid +ALTER TABLE opportunity_quotation ADD COLUMN invalid TINYINT(1) DEFAULT 0 COMMENT '是否作废: 0-正常, 1-作废' AFTER approval_status; + +-- drop quotation approval +DROP TABLE opportunity_quotation_approval; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.7.0/dml/V1.7.0_2_1__data.sql b/backend/crm/src/main/resources/migration/1.7.0/dml/V1.7.0_2_1__data.sql new file mode 100644 index 0000000..7b0a120 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.7.0/dml/V1.7.0_2_1__data.sql @@ -0,0 +1,29 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +INSERT INTO `contract_stage_config`(`id`, `name`, `type`, `afoot_roll_back`, `end_roll_back`, `pos`, `organization_id`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES + ('PENDING_SIGNING', '待签署', 'AFOOT', b'1', b'0', 1, '100001', 1776417799080, 1776417799080, 'admin', 'admin'), + ('SIGNED', '已签署', 'AFOOT', b'1', b'0', 2, '100001', 1776417799080, 1776417799080, 'admin', 'admin'), + ('CHANGE', '合同变更', 'AFOOT', b'1', b'0', 3, '100001', 1776417799080, 1776417799080, 'admin', 'admin'), + ('IN_PROGRESS', '履行中', 'AFOOT', b'1', b'0', 4, '100001', 1776417799080, 1776417799080, 'admin', 'admin'), + ('COMPLETED_PERFORMANCE', '履行完毕', 'AFOOT', b'1', b'0', 5, '100001', 1776417799080, 1776417799080, 'admin', 'admin'), + ('ARCHIVED', '合同完结', 'END', b'1', b'0', 6, '100001', 1776417799080, 1776417799080, 'admin', 'admin'), + ('VOID', '作废', 'END', b'1', b'0', 7, '100001', 1776417799080, 1776417799080, 'admin', 'admin'); + + +-- 初始化消息通知 +SET @contract_approval_id = UUID_SHORT(); +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@contract_approval_id, 'CONTRACT_APPROVAL', 'CONTRACT', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); +SET @order_approval_id = UUID_SHORT(); +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@order_approval_id, 'ORDER_APPROVAL', 'ORDER', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); +SET @invoice_approval_id = UUID_SHORT(); +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@invoice_approval_id, 'INVOICE_APPROVAL', 'CONTRACT', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); +SET @approval_todo_id = UUID_SHORT(); +INSERT INTO sys_message_task (id, event, task_type, email_enable, sys_enable, organization_id, template, create_user, create_time, update_user, update_time) + VALUE (@approval_todo_id, 'APPROVAL_TODO', 'APPROVAL', false, true,'100001', null, 'admin', UNIX_TIMESTAMP() * 1000 + 2, 'admin', UNIX_TIMESTAMP() * 1000 + 2 ); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.7.0/dml/V1.7.0_2_2__permission.sql b/backend/crm/src/main/resources/migration/1.7.0/dml/V1.7.0_2_2__permission.sql new file mode 100644 index 0000000..e3bb630 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.7.0/dml/V1.7.0_2_2__permission.sql @@ -0,0 +1,14 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- init org_admin permissions for approval flow +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'PROCESS_SETTING:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'PROCESS_SETTING:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'PROCESS_SETTING:UPDATE'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'PROCESS_SETTING:DELETE'); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.7.0/dml/V1.7.0_2_3__modify_tel.sql b/backend/crm/src/main/resources/migration/1.7.0/dml/V1.7.0_2_3__modify_tel.sql new file mode 100644 index 0000000..6ee2cc3 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.7.0/dml/V1.7.0_2_3__modify_tel.sql @@ -0,0 +1,18 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- Init contract pos +UPDATE contract +SET pos = (SELECT rn + FROM (SELECT id, ROW_NUMBER() OVER (ORDER BY id) as rn + FROM contract) t2 + WHERE t2.id = contract.id); + + +UPDATE sales_order +SET pos = (SELECT rn + FROM (SELECT id, ROW_NUMBER() OVER (ORDER BY id) as rn + FROM sales_order) t2 + WHERE t2.id = sales_order.id); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.7.1/ddl/V1.7.1_1__init.sql b/backend/crm/src/main/resources/migration/1.7.1/ddl/V1.7.1_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.7.1/ddl/V1.7.1_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.7.1/ddl/V1.7.1_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.7.1/ddl/V1.7.1_2__ga_ddl.sql new file mode 100644 index 0000000..c7acd92 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.7.1/ddl/V1.7.1_2__ga_ddl.sql @@ -0,0 +1,141 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- modify sys_module_field_blob prop column to longtext +ALTER TABLE contract MODIFY COLUMN start_time bigint NULL; +ALTER TABLE contract MODIFY COLUMN end_time bigint NULL; + +CREATE TABLE custom_form( + `id` VARCHAR(32) NOT NULL COMMENT 'id' , + `name` VARCHAR(255) NOT NULL COMMENT '名称' , + `enable` BIT(1) NOT NULL DEFAULT 0 COMMENT '是否启用' , + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id' , + `create_time` BIGINT NOT NULL COMMENT '创建时间' , + `update_time` BIGINT NOT NULL COMMENT '更新时间' , + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人' , + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人' , + PRIMARY KEY (id) +) COMMENT = '自定义表单' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE TABLE custom_form_admin( + `id` VARCHAR(32) NOT NULL COMMENT 'id' , + `custom_form_id` VARCHAR(32) NOT NULL COMMENT '自定义表单ID' , + `user_id` VARCHAR(32) NOT NULL COMMENT '用户ID' , + PRIMARY KEY (id) +) COMMENT = '自定义表单管理员' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_custom_form_id ON custom_form_admin(custom_form_id ASC); + + +CREATE TABLE custom_form_role( + `id` VARCHAR(32) NOT NULL COMMENT 'id' , + `name` VARCHAR(255) NOT NULL COMMENT '名称' , + `custom_form_id` VARCHAR(32) NOT NULL COMMENT '自定义表单ID' , + `internal_key` VARCHAR(50) NOT NULL COMMENT '内置角色key' , + `create_time` BIGINT NOT NULL COMMENT '创建时间' , + `update_time` BIGINT NOT NULL COMMENT '更新时间' , + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人' , + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人' , + PRIMARY KEY (id) +) COMMENT = '自定义表单角色' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_custom_form_id ON custom_form_role(custom_form_id ASC); + +CREATE TABLE custom_form_role_user( + `id` VARCHAR(32) NOT NULL COMMENT 'id' , + `role_id` VARCHAR(32) NOT NULL COMMENT '角色id' , + `user_id` VARCHAR(32) NOT NULL COMMENT '用户id' , + `create_time` BIGINT NOT NULL COMMENT '创建时间' , + `update_time` BIGINT NOT NULL COMMENT '更新时间' , + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人' , + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人' , + PRIMARY KEY (id) +) COMMENT = '自定义表单角色关联用户' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_role_id ON custom_form_role_user(role_id ASC); +CREATE INDEX idx_create_time ON custom_form_role_user(create_time DESC); + + +CREATE TABLE custom_form_data( + `id` VARCHAR(32) NOT NULL COMMENT 'id' , + `custom_form_id` VARCHAR(32) COMMENT '自定义表单ID' , + `name` VARCHAR(255) NOT NULL COMMENT '名称' , + `owner` VARCHAR(32) NOT NULL COMMENT '负责人' , + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id' , + `create_time` BIGINT NOT NULL COMMENT '创建时间' , + `update_time` BIGINT NOT NULL COMMENT '更新时间' , + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人' , + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人' , + PRIMARY KEY (id) +) COMMENT = '自定义表单数据' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_custom_form_id ON custom_form_data(custom_form_id ASC); + +CREATE TABLE custom_form_data_field( + `id` VARCHAR(32) NOT NULL COMMENT 'id' , + `resource_id` VARCHAR(32) NOT NULL COMMENT '自定义表单数据id' , + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id' , + `field_value` VARCHAR(255) NOT NULL COMMENT '自定义属性值' , + PRIMARY KEY (id) +) COMMENT = '自定义表单数据自定义属性' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON custom_form_data_field(resource_id ASC); + +CREATE TABLE custom_form_data_field_blob( + `id` VARCHAR(32) NOT NULL COMMENT 'id' , + `resource_id` VARCHAR(32) NOT NULL COMMENT '自定义表单数据id' , + `field_id` VARCHAR(32) NOT NULL COMMENT '自定义属性id' , + `field_value` TEXT NOT NULL COMMENT '自定义属性值' , + PRIMARY KEY (id) +) COMMENT = '自定义表单数据自定义属性大文本' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_resource_id ON custom_form_data_field_blob(resource_id ASC); + + +SET @sql = ( + SELECT IF( + EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'business_title' + AND COLUMN_NAME = 'company_number' + ), + 'SELECT 1', + 'ALTER TABLE business_title ADD COLUMN company_number BIGINT NOT NULL AUTO_INCREMENT UNIQUE COMMENT ''公司编号''' + ) +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + + +ALTER TABLE approval_node_approver MODIFY COLUMN field_permissions text COMMENT '字段权限配置(JSON格式)'; +ALTER TABLE approval_node_approver MODIFY COLUMN pass_post_config text COMMENT '审批通过后配置(JSON格式)'; +ALTER TABLE approval_node_approver MODIFY COLUMN reject_post_config text COMMENT '审批驳回后配置(JSON格式)'; + +ALTER TABLE sys_operation_log ADD COLUMN request_source VARCHAR(32) DEFAULT 'WEB' COMMENT '请求来源'; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; diff --git a/backend/crm/src/main/resources/migration/1.7.1/dml/V1.7.1_2_1__data.sql b/backend/crm/src/main/resources/migration/1.7.1/dml/V1.7.1_2_1__data.sql new file mode 100644 index 0000000..d618002 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.7.1/dml/V1.7.1_2_1__data.sql @@ -0,0 +1,11 @@ +-- 加入代办顶部导航栏 +update sys_navigation +set pos = pos + 1 +where organization_id = '100001' + and pos >= 2; +insert into sys_navigation value (UUID_SHORT(), '100001', 'task', true, 2, + 'admin', UNIX_TIMESTAMP() * 1000, 'admin', UNIX_TIMESTAMP() * 1000); + + +INSERT INTO sys_module (id, organization_id, module_key, enable, pos, create_user, create_time, update_user, update_time) +VALUES (UUID_SHORT(), '100001', 'customForm', 1, 9, 'admin', UNIX_TIMESTAMP() * 1000, 'admin', UNIX_TIMESTAMP() * 1000); diff --git a/backend/crm/src/main/resources/migration/1.7.1/dml/V1.7.1_2_2__permission.sql b/backend/crm/src/main/resources/migration/1.7.1/dml/V1.7.1_2_2__permission.sql new file mode 100644 index 0000000..7c3cc62 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.7.1/dml/V1.7.1_2_2__permission.sql @@ -0,0 +1,14 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- init org_admin permissions for custom form +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CUSTOM_FORM:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'org_admin', 'CUSTOM_FORM:ADD'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_manager', 'CUSTOM_FORM:READ'); +INSERT INTO sys_role_permission (id, role_id, permission_id) +VALUES (UUID_SHORT(), 'sales_staff', 'CUSTOM_FORM:READ'); + +SET SESSION innodb_lock_wait_timeout = DEFAULT; \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.7.2/ddl/V1.7.2_1__init.sql b/backend/crm/src/main/resources/migration/1.7.2/ddl/V1.7.2_1__init.sql new file mode 100644 index 0000000..d3fec9a --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.7.2/ddl/V1.7.2_1__init.sql @@ -0,0 +1 @@ +select database(); \ No newline at end of file diff --git a/backend/crm/src/main/resources/migration/1.7.2/ddl/V1.7.2_2__ga_ddl.sql b/backend/crm/src/main/resources/migration/1.7.2/ddl/V1.7.2_2__ga_ddl.sql new file mode 100644 index 0000000..b8c53e5 --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.7.2/ddl/V1.7.2_2__ga_ddl.sql @@ -0,0 +1,49 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- approval_flow 增加 delete_execute 字段 +ALTER TABLE approval_flow ADD COLUMN delete_execute tinyint(1) DEFAULT 0 COMMENT '删除时执行'; + +-- approval_node 增加 execute_time 字段 +ALTER TABLE approval_node ADD COLUMN execute_time varchar(30) DEFAULT NULL COMMENT '执行时机:CREATE/UPDATE/DELETE'; + +-- approval_instance 增加 execute_time 字段 +ALTER TABLE approval_instance ADD COLUMN execute_time varchar(30) DEFAULT NULL COMMENT '执行时机:CREATE/UPDATE/DELETE'; + +ALTER TABLE approval_instance ADD `comment` varchar(500) NULL COMMENT '变更说明'; + +ALTER TABLE contract ADD approved TINYINT(1) DEFAULT 0 NULL COMMENT '是否审批通过过'; +ALTER TABLE opportunity_quotation ADD approved TINYINT(1) DEFAULT 0 NULL COMMENT '是否审批通过过'; +ALTER TABLE contract_invoice ADD approved TINYINT(1) DEFAULT 0 NULL COMMENT '是否审批通过过'; +ALTER TABLE sales_order ADD approved TINYINT(1) DEFAULT 0 NULL COMMENT '是否审批通过过'; + +-- approval_instance 增加 update_fields 字段 +ALTER TABLE approval_instance ADD COLUMN update_fields VARCHAR(2000) DEFAULT NULL COMMENT '编辑时修改的字段列表'; + +CREATE TABLE stage_advanced_config +( + `id` VARCHAR(32) NOT NULL COMMENT 'id', + `origin_id` VARCHAR(32) NOT NULL COMMENT '源id', + `target_id` VARCHAR(32) NOT NULL COMMENT '目标id', + `enable` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否允许流转', + `field_config` TEXT(255) COMMENT '字段配置', + `module_type` VARCHAR(20) COMMENT '模块类型(order-订单/contract-合同)', + `organization_id` VARCHAR(32) NOT NULL COMMENT '组织id', + `create_time` BIGINT NOT NULL COMMENT '创建时间', + `update_time` BIGINT NOT NULL COMMENT '更新时间', + `create_user` VARCHAR(32) NOT NULL COMMENT '创建人', + `update_user` VARCHAR(32) NOT NULL COMMENT '更新人', + PRIMARY KEY (id) +) COMMENT = '状态流高级配置' +ENGINE = InnoDB +DEFAULT CHARSET = utf8mb4 +COLLATE = utf8mb4_general_ci; + +CREATE INDEX idx_type ON stage_advanced_config (module_type ASC); + + +ALTER TABLE sales_order_stage_config ADD COLUMN circulation_type VARCHAR(50) DEFAULT 'NORMAL' COMMENT '流转类型(NORMAL-普通,ADVANCED-高级)'; +ALTER TABLE contract_stage_config ADD COLUMN circulation_type VARCHAR(50) DEFAULT 'NORMAL' COMMENT '流转类型(NORMAL-普通,ADVANCED-高级)'; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; diff --git a/backend/crm/src/main/resources/migration/1.7.2/dml/V1.7.2_2_1__data.sql b/backend/crm/src/main/resources/migration/1.7.2/dml/V1.7.2_2_1__data.sql new file mode 100644 index 0000000..a2c448d --- /dev/null +++ b/backend/crm/src/main/resources/migration/1.7.2/dml/V1.7.2_2_1__data.sql @@ -0,0 +1,100 @@ +-- set innodb lock wait timeout +SET SESSION innodb_lock_wait_timeout = 7200; + +-- 历史数据处理:根据 approval_flow 的 create_execute 和 update_execute,将原来一份节点数据拆成多份 +-- 对 create_execute=true 的审批流,设置 execute_time = CREATE +UPDATE approval_node an +INNER JOIN approval_flow_version afv ON an.flow_version_id = afv.id +INNER JOIN approval_flow af ON afv.flow_id = af.id +SET an.execute_time = 'CREATE' +WHERE af.create_execute = 1; + +-- 对仅 update_execute=true 的审批流,设置 execute_time = UPDATE +UPDATE approval_node an +INNER JOIN approval_flow_version afv ON an.flow_version_id = afv.id +INNER JOIN approval_flow af ON afv.flow_id = af.id +SET an.execute_time = 'UPDATE' +WHERE af.update_execute = 1 AND (af.create_execute = 0 OR af.create_execute IS NULL); + +-- 对 create_execute=true AND update_execute=true 的审批流,拆分节点数据,复制一份作为 UPDATE(approval_node) +INSERT INTO approval_node (id, flow_version_id, number, name, node_type, sort, execute_time) +SELECT + CAST(an.id AS UNSIGNED) + 1000, + an.flow_version_id, + an.number, + an.name, + an.node_type, + an.sort, + 'UPDATE' AS execute_time +FROM approval_node an +INNER JOIN approval_flow_version afv ON an.flow_version_id = afv.id +INNER JOIN approval_flow af ON afv.flow_id = af.id +WHERE af.create_execute = 1 AND af.update_execute = 1 + AND an.execute_time = 'CREATE'; + +-- 复制 approval_node_approver(审批人节点配置) +INSERT INTO approval_node_approver (id, flow_version_id, approval_type, multi_approver_mode, + empty_approver_action, fallback_approver, same_submitter_action, approver_type, approver_direction, + approver_list, cc_type, cc_direction, cc_list, pass_post_config, reject_post_config, field_permissions) +SELECT + CAST(ana.id AS UNSIGNED) + 1000, + ana.flow_version_id, + ana.approval_type, + ana.multi_approver_mode, + ana.empty_approver_action, + ana.fallback_approver, + ana.same_submitter_action, + ana.approver_type, + ana.approver_direction, + ana.approver_list, + ana.cc_type, + ana.cc_direction, + ana.cc_list, + ana.pass_post_config, + ana.reject_post_config, + ana.field_permissions +FROM approval_node_approver ana +INNER JOIN approval_node an ON ana.id = an.id +INNER JOIN approval_flow_version afv ON an.flow_version_id = afv.id +INNER JOIN approval_flow af ON afv.flow_id = af.id +WHERE af.create_execute = 1 AND af.update_execute = 1 + AND an.execute_time = 'CREATE'; + +-- 复制 approval_node_condition(条件节点配置) +INSERT INTO approval_node_condition (id, flow_version_id, condition_config) +SELECT + CAST(anc.id AS UNSIGNED) + 1000, + anc.flow_version_id, + anc.condition_config +FROM approval_node_condition anc +INNER JOIN approval_node an ON anc.id = an.id +INNER JOIN approval_flow_version afv ON an.flow_version_id = afv.id +INNER JOIN approval_flow af ON afv.flow_id = af.id +WHERE af.create_execute = 1 AND af.update_execute = 1 + AND an.execute_time = 'CREATE'; + +-- 复制 approval_node_link(节点连接关系) +-- from_node_id 和 to_node_id 都需要映射到新ID +INSERT INTO approval_node_link (id, flow_version_id, from_node_id, to_node_id, sort) +SELECT + UUID_SHORT(), + anl.flow_version_id, + CAST(anl.from_node_id AS UNSIGNED) + 1000 AS from_node_id, + CAST(anl.to_node_id AS UNSIGNED) + 1000 AS to_node_id, + anl.sort +FROM approval_node_link anl +INNER JOIN approval_node from_node ON anl.from_node_id = from_node.id +INNER JOIN approval_flow_version afv ON from_node.flow_version_id = afv.id +INNER JOIN approval_flow af ON afv.flow_id = af.id +WHERE af.create_execute = 1 AND af.update_execute = 1 + AND af.deleted = 0 + AND from_node.execute_time = 'CREATE'; + +-- 处理已经审批通过的历史数据,设置通过标识位 +update contract set approved = 1 where approval_status = 'APPROVED'; +update opportunity_quotation set approved = 1 where approval_status = 'APPROVED'; +update contract_invoice set approved = 1 where approval_status = 'APPROVED'; +update sales_order set approved = 1 where approval_status = 'APPROVED'; + +-- set innodb lock wait timeout to default +SET SESSION innodb_lock_wait_timeout = DEFAULT; diff --git a/backend/crm/src/main/resources/permission.json b/backend/crm/src/main/resources/permission.json new file mode 100644 index 0000000..9f0b240 --- /dev/null +++ b/backend/crm/src/main/resources/permission.json @@ -0,0 +1,705 @@ +[ + { + "id": "SYSTEM", + "name": "permission.system.name", + "children": [ + { + "id": "SYSTEM_ORGANIZATION", + "name": "permission.system.organization.name", + "permissions": [ + { + "id": "SYS_ORGANIZATION:READ" + }, + { + "id": "SYS_ORGANIZATION:ADD" + }, + { + "id": "SYS_ORGANIZATION:UPDATE" + }, + { + "id": "SYS_ORGANIZATION:DELETE" + }, + { + "id": "SYS_ORGANIZATION:IMPORT" + }, + { + "id": "SYS_ORGANIZATION:SYNC", + "name": "permission.organization.sync" + }, + { + "id": "SYS_ORGANIZATION_USER:RESET_PASSWORD", + "name": "permission.organization.user.reset_password" + } + ] + }, + { + "id": "SYSTEM_ROLE", + "name": "permission.system.role.name", + "permissions": [ + { + "id": "SYSTEM_ROLE:READ" + }, + { + "id": "SYSTEM_ROLE:ADD" + }, + { + "id": "SYSTEM_ROLE:UPDATE" + }, + { + "id": "SYSTEM_ROLE:DELETE" + }, + { + "id": "SYSTEM_ROLE:ADD_USER", + "name": "permission.role.add_user" + }, + { + "id": "SYSTEM_ROLE:REMOVE_USER", + "name": "permission.role.remove_user" + } + ] + }, + { + "id": "SYSTEM_SETTING", + "name": "permission.system.module_setting.name", + "permissions": [ + { + "id": "MODULE_SETTING:READ" + }, + { + "id": "MODULE_SETTING:UPDATE" + } + ] + }, + { + "id": "SYSTEM_NOTICE", + "name": "permission.system.notice.name", + "permissions": [ + { + "id": "SYSTEM_NOTICE:READ" + }, + { + "id": "SYSTEM_NOTICE:ADD" + }, + { + "id": "SYSTEM_NOTICE:UPDATE" + }, + { + "id": "SYSTEM_NOTICE:DELETE" + } + ] + }, + { + "id": "SYSTEM_SETTING", + "name": "permission.system.setting.name", + "permissions": [ + { + "id": "SYSTEM_SETTING:READ" + }, + { + "id": "SYSTEM_SETTING:ADD" + }, + { + "id": "SYSTEM_SETTING:UPDATE" + }, + { + "id": "SYSTEM_SETTING:DELETE" + } + ] + }, + { + "id": "SYSTEM_SETTING", + "name": "permission.operation.log", + "permissions": [ + { + "id": "OPERATION_LOG:READ" + } + ] + }, + { + "id": "SYSTEM_LICENSE", + "name": "permission.system.license", + "license": true, + "permissions": [ + { + "id": "LICENSE:READ", + "license": true + }, + { + "id": "LICENSE:EDIT", + "license": true + } + ] + }, + { + "id": "PROCESS_SETTING", + "name": "permission.process_setting.name", + "permissions": [ + { + "id": "PROCESS_SETTING:READ" + }, + { + "id": "PROCESS_SETTING:ADD" + }, + { + "id": "PROCESS_SETTING:UPDATE" + }, + { + "id": "PROCESS_SETTING:DELETE" + } + ] + } + ] + }, + { + "id": "CUSTOMER_MANAGEMENT", + "name": "permission.customer_management.name", + "children": [ + { + "id": "CUSTOMER_MANAGEMENT_CUSTOMER", + "name": "permission.customer_management.customer.name", + "permissions": [ + { + "id": "CUSTOMER_MANAGEMENT:READ" + }, + { + "id": "CUSTOMER_MANAGEMENT:ADD" + }, + { + "id": "CUSTOMER_MANAGEMENT:UPDATE" + }, + { + "id": "CUSTOMER_MANAGEMENT:TRANSFER" + }, + { + "id": "CUSTOMER_MANAGEMENT:RECYCLE", + "name": "permission.customer_management.customer.recycle" + }, + { + "id": "CUSTOMER_MANAGEMENT:DELETE" + }, + { + "id": "CUSTOMER_MANAGEMENT:IMPORT" + }, + { + "id": "CUSTOMER_MANAGEMENT:EXPORT" + }, + { + "id": "CUSTOMER_MANAGEMENT:MERGE" + } + ] + }, + { + "id": "CUSTOMER_MANAGEMENT_POOL", + "name": "permission.customer_management.pool.name", + "permissions": [ + { + "id": "CUSTOMER_MANAGEMENT_POOL:READ" + }, + { + "id": "CUSTOMER_MANAGEMENT_POOL:UPDATE" + }, + { + "id": "CUSTOMER_MANAGEMENT_POOL:PICK", + "name": "permission.customer_management.pool.pick" + }, + { + "id": "CUSTOMER_MANAGEMENT_POOL:ASSIGN", + "name": "permission.customer_management.pool.assign" + }, + { + "id": "CUSTOMER_MANAGEMENT_POOL:DELETE" + }, + { + "id": "CUSTOMER_MANAGEMENT_POOL:EXPORT" + } + ] + }, + { + "id": "CUSTOMER_MANAGEMENT_CONTACT", + "name": "permission.customer.customer_contact.name", + "permissions": [ + { + "id": "CUSTOMER_MANAGEMENT_CONTACT:READ" + }, + { + "id": "CUSTOMER_MANAGEMENT_CONTACT:ADD" + }, + { + "id": "CUSTOMER_MANAGEMENT_CONTACT:UPDATE" + }, + { + "id": "CUSTOMER_MANAGEMENT_CONTACT:DELETE" + }, + { + "id": "CUSTOMER_MANAGEMENT_CONTACT:IMPORT" + }, + { + "id": "CUSTOMER_MANAGEMENT_CONTACT:EXPORT" + } + ] + } + ] + }, + { + "id": "CLUE_MANAGEMENT", + "name": "permission.clue_management.name", + "children": [ + { + "id": "CLUE_MANAGEMENT_CLUE", + "name": "permission.clue_management.clue.name", + "permissions": [ + { + "id": "CLUE_MANAGEMENT:READ" + }, + { + "id": "CLUE_MANAGEMENT:ADD" + }, + { + "id": "CLUE_MANAGEMENT:UPDATE" + }, + { + "id": "CLUE_MANAGEMENT:TRANSFER" + }, + { + "id": "CLUE_MANAGEMENT:RECYCLE", + "name": "permission.clue_management.clue.recycle" + }, + { + "id": "CLUE_MANAGEMENT:DELETE" + }, + { + "id": "CLUE_MANAGEMENT:IMPORT" + }, + { + "id": "CLUE_MANAGEMENT:EXPORT" + } + ] + }, + { + "id": "CLUE_MANAGEMENT_POOL", + "name": "permission.clue_management.pool.name", + "permissions": [ + { + "id": "CLUE_MANAGEMENT_POOL:READ" + }, + { + "id": "CLUE_MANAGEMENT_POOL:UPDATE" + }, + { + "id": "CLUE_MANAGEMENT_POOL:PICK", + "name": "permission.customer_management.pool.pick" + }, + { + "id": "CLUE_MANAGEMENT_POOL:ASSIGN", + "name": "permission.customer_management.pool.assign" + }, + { + "id": "CLUE_MANAGEMENT_POOL:DELETE" + }, + { + "id": "CLUE_MANAGEMENT_POOL:EXPORT" + } + ] + } + ] + }, + { + "id": "OPPORTUNITY_MANAGEMENT", + "name": "permission.opportunity_management.name", + "children": [ + { + "id": "OPPORTUNITY_MANAGEMENT_OPPORTUNITY", + "name": "permission.opportunity_management.opportunity.name", + "permissions": [ + { + "id": "OPPORTUNITY_MANAGEMENT:READ" + }, + { + "id": "OPPORTUNITY_MANAGEMENT:ADD" + }, + { + "id": "OPPORTUNITY_MANAGEMENT:UPDATE" + }, + { + "id": "OPPORTUNITY_MANAGEMENT:TRANSFER" + }, + { + "id": "OPPORTUNITY_MANAGEMENT:DELETE" + }, + { + "id": "OPPORTUNITY_MANAGEMENT:IMPORT" + }, + { + "id": "OPPORTUNITY_MANAGEMENT:EXPORT" + }, + { + "id": "OPPORTUNITY_MANAGEMENT:RESIGN", + "name": "permission.clue_management.clue.resign" + } + ] + }, + { + "id": "OPPORTUNITY_MANAGEMENT_QUOTATION", + "name": "permission.opportunity_management.opportunity_quotation.name", + "permissions": [ + { + "id": "OPPORTUNITY_QUOTATION:READ" + }, + { + "id": "OPPORTUNITY_QUOTATION:ADD" + }, + { + "id": "OPPORTUNITY_QUOTATION:UPDATE" + }, + { + "id": "OPPORTUNITY_QUOTATION:DELETE" + }, + { + "id": "OPPORTUNITY_QUOTATION:DOWNLOAD", + "name": "permission.opportunity_management.opportunity_quotation.download" + }, + { + "id": "OPPORTUNITY_QUOTATION:VOIDED" + } + ] + } + ] + }, + { + "id": "PRODUCT_MANAGEMENT", + "name": "permission.product_management.name", + "children": [ + { + "id": "PRODUCT_MANAGEMENT_PRODUCT", + "name": "permission.product_management.product.name", + "permissions": [ + { + "id": "PRODUCT_MANAGEMENT:READ" + }, + { + "id": "PRODUCT_MANAGEMENT:ADD" + }, + { + "id": "PRODUCT_MANAGEMENT:UPDATE" + }, + { + "id": "PRODUCT_MANAGEMENT:DELETE" + }, + { + "id": "PRODUCT_MANAGEMENT:IMPORT" + } + ] + }, + { + "id": "PRODUCT_MANAGEMENT_PRICE", + "name": "permission.product_management.price.name", + "permissions": [ + { + "id": "PRICE:READ" + }, + { + "id": "PRICE:ADD" + }, + { + "id": "PRICE:UPDATE" + }, + { + "id": "PRICE:DELETE" + }, + { + "id": "PRICE:IMPORT" + }, + { + "id": "PRICE:EXPORT" + } + ] + } + ] + }, + { + "id": "DASHBOARD_MANAGEMENT", + "name": "permission.dashboard_management.name", + "license": false, + "children": [ + { + "id": "DASHBOARD", + "name": "permission.dashboard.name", + "permissions": [ + { + "id": "DASHBOARD:READ", + "license": false + }, + { + "id": "DASHBOARD:ADD", + "license": false + }, + { + "id": "DASHBOARD:UPDATE", + "license": false + }, + { + "id": "DASHBOARD:DELETE", + "license": false + } + ] + } + ] + }, + { + "id": "PERSONAL_INFO", + "name": "permission.personal_info.name", + "license": false, + "children": [ + { + "id": "API_KEY", + "name": "permission.api_key.name", + "permissions": [ + { + "id": "PERSONAL_API_KEY:READ", + "license": false + }, + { + "id": "PERSONAL_API_KEY:ADD", + "license": false + }, + { + "id": "PERSONAL_API_KEY:UPDATE", + "license": false + }, + { + "id": "PERSONAL_API_KEY:DELETE", + "license": false + } + ] + } + ] + }, + { + "id": "AGENT_MANAGEMENT", + "name": "permission.agent_management.name", + "license": false, + "children": [ + { + "id": "AGENT", + "name": "permission.agent.name", + "permissions": [ + { + "id": "AGENT:READ", + "license": false + }, + { + "id": "AGENT:ADD", + "license": false + }, + { + "id": "AGENT:UPDATE", + "license": false + }, + { + "id": "AGENT:DELETE", + "license": false + } + ] + } + ] + }, + { + "id": "TENDER_MANAGEMENT", + "name": "permission.tender_management.name", + "license": false, + "children": [ + { + "id": "TENDER", + "name": "permission.tender.name", + "permissions": [ + { + "id": "TENDER:READ", + "license": false + } + ] + } + ] + }, + { + "id": "CONTRACT_MANAGEMENT", + "name": "permission.contract_management.name", + "license": false, + "children": [ + { + "id": "CONTRACT", + "name": "permission.contract.name", + "permissions": [ + { + "id": "CONTRACT:READ", + "license": false + }, + { + "id": "CONTRACT:ADD", + "license": false + }, + { + "id": "CONTRACT:UPDATE", + "license": false + }, + { + "id": "CONTRACT:DELETE", + "license": false + }, + { + "id": "CONTRACT:EXPORT", + "license": false + }, + { + "id": "CONTRACT:STAGE", + "license": false + }, + { + "id": "CONTRACT:PAYMENT" + } + ] + }, + { + "id": "CONTRACT_PAYMENT_PLAN", + "name": "permission.contract_payment_plan.name", + "permissions": [ + { + "id": "CONTRACT_PAYMENT_PLAN:READ" + }, + { + "id": "CONTRACT_PAYMENT_PLAN:ADD" + }, + { + "id": "CONTRACT_PAYMENT_PLAN:UPDATE" + }, + { + "id": "CONTRACT_PAYMENT_PLAN:DELETE" + }, + { + "id": "CONTRACT_PAYMENT_PLAN:EXPORT" + } + ] + }, + { + "id": "CONTRACT_PAYMENT_RECORD", + "name": "permission.contract_payment_record.name", + "permissions": [ + { + "id": "CONTRACT_PAYMENT_RECORD:READ" + }, + { + "id": "CONTRACT_PAYMENT_RECORD:ADD" + }, + { + "id": "CONTRACT_PAYMENT_RECORD:UPDATE" + }, + { + "id": "CONTRACT_PAYMENT_RECORD:DELETE" + }, + { + "id": "CONTRACT_PAYMENT_RECORD:IMPORT" + }, + { + "id": "CONTRACT_PAYMENT_RECORD:EXPORT" + } + ] + }, + { + "id": "BUSINESS_TITLE", + "name": "permission.business_title.name", + "permissions": [ + { + "id": "CONTRACT_BUSINESS_TITLE:READ", + "license": false + }, + { + "id": "CONTRACT_BUSINESS_TITLE:ADD", + "license": false + }, + { + "id": "CONTRACT_BUSINESS_TITLE:UPDATE", + "license": false + }, + { + "id": "CONTRACT_BUSINESS_TITLE:DELETE", + "license": false + }, + { + "id": "CONTRACT_BUSINESS_TITLE:EXPORT", + "license": false + }, + { + "id": "CONTRACT_BUSINESS_TITLE:IMPORT", + "license": false + } + ] + }, + { + "id": "CONTRACT_INVOICE", + "name": "permission.contract.invoice.name", + "permissions": [ + { + "id": "CONTRACT_INVOICE:READ" + }, + { + "id": "CONTRACT_INVOICE:ADD" + }, + { + "id": "CONTRACT_INVOICE:UPDATE" + }, + { + "id": "CONTRACT_INVOICE:DELETE" + }, + { + "id": "CONTRACT_INVOICE:EXPORT" + } + ] + } + ] + }, + { + "id": "ORDER_MANAGEMENT", + "name": "permission.order_management.name", + "license": false, + "children": [ + { + "id": "ORDER", + "name": "permission.order.name", + "permissions": [ + { + "id": "ORDER:READ" + }, + { + "id": "ORDER:ADD" + }, + { + "id": "ORDER:UPDATE" + }, + { + "id": "ORDER:DELETE" + }, + { + "id": "ORDER:DOWNLOAD" + } + ] + } + ] + }, + { + "id": "CUSTOM_FORM", + "name": "permission.custom_form.name", + "children": [ + { + "id": "CUSTOM_FORM", + "name": "permission.custom_form.name", + "permissions": [ + { + "id": "CUSTOM_FORM:READ" + }, + { + "id": "CUSTOM_FORM:ADD" + } + ] + } + ] + } +] diff --git a/backend/crm/src/main/resources/region/region.json b/backend/crm/src/main/resources/region/region.json new file mode 100644 index 0000000..2885f84 --- /dev/null +++ b/backend/crm/src/main/resources/region/region.json @@ -0,0 +1,23562 @@ +[ + { + "name": "中国", + "code": "CHN", + "children": [ + { + "code": "11", + "name": "北京市", + "children": [ + { + "code": "1101", + "name": "市辖区", + "children": [ + { + "code": "110101", + "name": "东城区" + }, + { + "code": "110102", + "name": "西城区" + }, + { + "code": "110105", + "name": "朝阳区" + }, + { + "code": "110106", + "name": "丰台区" + }, + { + "code": "110107", + "name": "石景山区" + }, + { + "code": "110108", + "name": "海淀区" + }, + { + "code": "110109", + "name": "门头沟区" + }, + { + "code": "110111", + "name": "房山区" + }, + { + "code": "110112", + "name": "通州区" + }, + { + "code": "110113", + "name": "顺义区" + }, + { + "code": "110114", + "name": "昌平区" + }, + { + "code": "110115", + "name": "大兴区" + }, + { + "code": "110116", + "name": "怀柔区" + }, + { + "code": "110117", + "name": "平谷区" + }, + { + "code": "110118", + "name": "密云区" + }, + { + "code": "110119", + "name": "延庆区" + } + ] + } + ] + }, + { + "code": "12", + "name": "天津市", + "children": [ + { + "code": "1201", + "name": "市辖区", + "children": [ + { + "code": "120101", + "name": "和平区" + }, + { + "code": "120102", + "name": "河东区" + }, + { + "code": "120103", + "name": "河西区" + }, + { + "code": "120104", + "name": "南开区" + }, + { + "code": "120105", + "name": "河北区" + }, + { + "code": "120106", + "name": "红桥区" + }, + { + "code": "120110", + "name": "东丽区" + }, + { + "code": "120111", + "name": "西青区" + }, + { + "code": "120112", + "name": "津南区" + }, + { + "code": "120113", + "name": "北辰区" + }, + { + "code": "120114", + "name": "武清区" + }, + { + "code": "120115", + "name": "宝坻区" + }, + { + "code": "120116", + "name": "滨海新区" + }, + { + "code": "120117", + "name": "宁河区" + }, + { + "code": "120118", + "name": "静海区" + }, + { + "code": "120119", + "name": "蓟州区" + } + ] + } + ] + }, + { + "code": "13", + "name": "河北省", + "children": [ + { + "code": "1301", + "name": "石家庄市", + "children": [ + { + "code": "130102", + "name": "长安区" + }, + { + "code": "130104", + "name": "桥西区" + }, + { + "code": "130105", + "name": "新华区" + }, + { + "code": "130107", + "name": "井陉矿区" + }, + { + "code": "130108", + "name": "裕华区" + }, + { + "code": "130109", + "name": "藁城区" + }, + { + "code": "130110", + "name": "鹿泉区" + }, + { + "code": "130111", + "name": "栾城区" + }, + { + "code": "130121", + "name": "井陉县" + }, + { + "code": "130123", + "name": "正定县" + }, + { + "code": "130125", + "name": "行唐县" + }, + { + "code": "130126", + "name": "灵寿县" + }, + { + "code": "130127", + "name": "高邑县" + }, + { + "code": "130128", + "name": "深泽县" + }, + { + "code": "130129", + "name": "赞皇县" + }, + { + "code": "130130", + "name": "无极县" + }, + { + "code": "130131", + "name": "平山县" + }, + { + "code": "130132", + "name": "元氏县" + }, + { + "code": "130133", + "name": "赵县" + }, + { + "code": "130171", + "name": "石家庄高新技术产业开发区" + }, + { + "code": "130172", + "name": "石家庄循环化工园区" + }, + { + "code": "130181", + "name": "辛集市" + }, + { + "code": "130183", + "name": "晋州市" + }, + { + "code": "130184", + "name": "新乐市" + } + ] + }, + { + "code": "1302", + "name": "唐山市", + "children": [ + { + "code": "130202", + "name": "路南区" + }, + { + "code": "130203", + "name": "路北区" + }, + { + "code": "130204", + "name": "古冶区" + }, + { + "code": "130205", + "name": "开平区" + }, + { + "code": "130207", + "name": "丰南区" + }, + { + "code": "130208", + "name": "丰润区" + }, + { + "code": "130209", + "name": "曹妃甸区" + }, + { + "code": "130224", + "name": "滦南县" + }, + { + "code": "130225", + "name": "乐亭县" + }, + { + "code": "130227", + "name": "迁西县" + }, + { + "code": "130229", + "name": "玉田县" + }, + { + "code": "130271", + "name": "河北唐山芦台经济开发区" + }, + { + "code": "130272", + "name": "唐山市汉沽管理区" + }, + { + "code": "130273", + "name": "唐山高新技术产业开发区" + }, + { + "code": "130274", + "name": "河北唐山海港经济开发区" + }, + { + "code": "130281", + "name": "遵化市" + }, + { + "code": "130283", + "name": "迁安市" + }, + { + "code": "130284", + "name": "滦州市" + } + ] + }, + { + "code": "1303", + "name": "秦皇岛市", + "children": [ + { + "code": "130302", + "name": "海港区" + }, + { + "code": "130303", + "name": "山海关区" + }, + { + "code": "130304", + "name": "北戴河区" + }, + { + "code": "130306", + "name": "抚宁区" + }, + { + "code": "130321", + "name": "青龙满族自治县" + }, + { + "code": "130322", + "name": "昌黎县" + }, + { + "code": "130324", + "name": "卢龙县" + }, + { + "code": "130371", + "name": "秦皇岛市经济技术开发区" + }, + { + "code": "130372", + "name": "北戴河新区" + } + ] + }, + { + "code": "1304", + "name": "邯郸市", + "children": [ + { + "code": "130402", + "name": "邯山区" + }, + { + "code": "130403", + "name": "丛台区" + }, + { + "code": "130404", + "name": "复兴区" + }, + { + "code": "130406", + "name": "峰峰矿区" + }, + { + "code": "130407", + "name": "肥乡区" + }, + { + "code": "130408", + "name": "永年区" + }, + { + "code": "130423", + "name": "临漳县" + }, + { + "code": "130424", + "name": "成安县" + }, + { + "code": "130425", + "name": "大名县" + }, + { + "code": "130426", + "name": "涉县" + }, + { + "code": "130427", + "name": "磁县" + }, + { + "code": "130430", + "name": "邱县" + }, + { + "code": "130431", + "name": "鸡泽县" + }, + { + "code": "130432", + "name": "广平县" + }, + { + "code": "130433", + "name": "馆陶县" + }, + { + "code": "130434", + "name": "魏县" + }, + { + "code": "130435", + "name": "曲周县" + }, + { + "code": "130471", + "name": "邯郸经济技术开发区" + }, + { + "code": "130473", + "name": "邯郸冀南新区" + }, + { + "code": "130481", + "name": "武安市" + } + ] + }, + { + "code": "1305", + "name": "邢台市", + "children": [ + { + "code": "130502", + "name": "襄都区" + }, + { + "code": "130503", + "name": "信都区" + }, + { + "code": "130505", + "name": "任泽区" + }, + { + "code": "130506", + "name": "南和区" + }, + { + "code": "130522", + "name": "临城县" + }, + { + "code": "130523", + "name": "内丘县" + }, + { + "code": "130524", + "name": "柏乡县" + }, + { + "code": "130525", + "name": "隆尧县" + }, + { + "code": "130528", + "name": "宁晋县" + }, + { + "code": "130529", + "name": "巨鹿县" + }, + { + "code": "130530", + "name": "新河县" + }, + { + "code": "130531", + "name": "广宗县" + }, + { + "code": "130532", + "name": "平乡县" + }, + { + "code": "130533", + "name": "威县" + }, + { + "code": "130534", + "name": "清河县" + }, + { + "code": "130535", + "name": "临西县" + }, + { + "code": "130571", + "name": "河北邢台经济开发区" + }, + { + "code": "130581", + "name": "南宫市" + }, + { + "code": "130582", + "name": "沙河市" + } + ] + }, + { + "code": "1306", + "name": "保定市", + "children": [ + { + "code": "130602", + "name": "竞秀区" + }, + { + "code": "130606", + "name": "莲池区" + }, + { + "code": "130607", + "name": "满城区" + }, + { + "code": "130608", + "name": "清苑区" + }, + { + "code": "130609", + "name": "徐水区" + }, + { + "code": "130623", + "name": "涞水县" + }, + { + "code": "130624", + "name": "阜平县" + }, + { + "code": "130626", + "name": "定兴县" + }, + { + "code": "130627", + "name": "唐县" + }, + { + "code": "130628", + "name": "高阳县" + }, + { + "code": "130629", + "name": "容城县" + }, + { + "code": "130630", + "name": "涞源县" + }, + { + "code": "130631", + "name": "望都县" + }, + { + "code": "130632", + "name": "安新县" + }, + { + "code": "130633", + "name": "易县" + }, + { + "code": "130634", + "name": "曲阳县" + }, + { + "code": "130635", + "name": "蠡县" + }, + { + "code": "130636", + "name": "顺平县" + }, + { + "code": "130637", + "name": "博野县" + }, + { + "code": "130638", + "name": "雄县" + }, + { + "code": "130671", + "name": "保定高新技术产业开发区" + }, + { + "code": "130672", + "name": "保定白沟新城" + }, + { + "code": "130681", + "name": "涿州市" + }, + { + "code": "130682", + "name": "定州市" + }, + { + "code": "130683", + "name": "安国市" + }, + { + "code": "130684", + "name": "高碑店市" + } + ] + }, + { + "code": "1307", + "name": "张家口市", + "children": [ + { + "code": "130702", + "name": "桥东区" + }, + { + "code": "130703", + "name": "桥西区" + }, + { + "code": "130705", + "name": "宣化区" + }, + { + "code": "130706", + "name": "下花园区" + }, + { + "code": "130708", + "name": "万全区" + }, + { + "code": "130709", + "name": "崇礼区" + }, + { + "code": "130722", + "name": "张北县" + }, + { + "code": "130723", + "name": "康保县" + }, + { + "code": "130724", + "name": "沽源县" + }, + { + "code": "130725", + "name": "尚义县" + }, + { + "code": "130726", + "name": "蔚县" + }, + { + "code": "130727", + "name": "阳原县" + }, + { + "code": "130728", + "name": "怀安县" + }, + { + "code": "130730", + "name": "怀来县" + }, + { + "code": "130731", + "name": "涿鹿县" + }, + { + "code": "130732", + "name": "赤城县" + }, + { + "code": "130771", + "name": "张家口经济开发区" + }, + { + "code": "130772", + "name": "张家口市察北管理区" + }, + { + "code": "130773", + "name": "张家口市塞北管理区" + } + ] + }, + { + "code": "1308", + "name": "承德市", + "children": [ + { + "code": "130802", + "name": "双桥区" + }, + { + "code": "130803", + "name": "双滦区" + }, + { + "code": "130804", + "name": "鹰手营子矿区" + }, + { + "code": "130821", + "name": "承德县" + }, + { + "code": "130822", + "name": "兴隆县" + }, + { + "code": "130824", + "name": "滦平县" + }, + { + "code": "130825", + "name": "隆化县" + }, + { + "code": "130826", + "name": "丰宁满族自治县" + }, + { + "code": "130827", + "name": "宽城满族自治县" + }, + { + "code": "130828", + "name": "围场满族蒙古族自治县" + }, + { + "code": "130871", + "name": "承德高新技术产业开发区" + }, + { + "code": "130881", + "name": "平泉市" + } + ] + }, + { + "code": "1309", + "name": "沧州市", + "children": [ + { + "code": "130902", + "name": "新华区" + }, + { + "code": "130903", + "name": "运河区" + }, + { + "code": "130921", + "name": "沧县" + }, + { + "code": "130922", + "name": "青县" + }, + { + "code": "130923", + "name": "东光县" + }, + { + "code": "130924", + "name": "海兴县" + }, + { + "code": "130925", + "name": "盐山县" + }, + { + "code": "130926", + "name": "肃宁县" + }, + { + "code": "130927", + "name": "南皮县" + }, + { + "code": "130928", + "name": "吴桥县" + }, + { + "code": "130929", + "name": "献县" + }, + { + "code": "130930", + "name": "孟村回族自治县" + }, + { + "code": "130971", + "name": "河北沧州经济开发区" + }, + { + "code": "130972", + "name": "沧州高新技术产业开发区" + }, + { + "code": "130973", + "name": "沧州渤海新区" + }, + { + "code": "130981", + "name": "泊头市" + }, + { + "code": "130982", + "name": "任丘市" + }, + { + "code": "130983", + "name": "黄骅市" + }, + { + "code": "130984", + "name": "河间市" + } + ] + }, + { + "code": "1310", + "name": "廊坊市", + "children": [ + { + "code": "131002", + "name": "安次区" + }, + { + "code": "131003", + "name": "广阳区" + }, + { + "code": "131022", + "name": "固安县" + }, + { + "code": "131023", + "name": "永清县" + }, + { + "code": "131024", + "name": "香河县" + }, + { + "code": "131025", + "name": "大城县" + }, + { + "code": "131026", + "name": "文安县" + }, + { + "code": "131028", + "name": "大厂回族自治县" + }, + { + "code": "131071", + "name": "廊坊经济技术开发区" + }, + { + "code": "131081", + "name": "霸州市" + }, + { + "code": "131082", + "name": "三河市" + } + ] + }, + { + "code": "1311", + "name": "衡水市", + "children": [ + { + "code": "131102", + "name": "桃城区" + }, + { + "code": "131103", + "name": "冀州区" + }, + { + "code": "131121", + "name": "枣强县" + }, + { + "code": "131122", + "name": "武邑县" + }, + { + "code": "131123", + "name": "武强县" + }, + { + "code": "131124", + "name": "饶阳县" + }, + { + "code": "131125", + "name": "安平县" + }, + { + "code": "131126", + "name": "故城县" + }, + { + "code": "131127", + "name": "景县" + }, + { + "code": "131128", + "name": "阜城县" + }, + { + "code": "131171", + "name": "河北衡水高新技术产业开发区" + }, + { + "code": "131172", + "name": "衡水滨湖新区" + }, + { + "code": "131182", + "name": "深州市" + } + ] + }, + { + "code": "133100", + "name": "雄安新区" + } + ] + }, + { + "code": "14", + "name": "山西省", + "children": [ + { + "code": "1401", + "name": "太原市", + "children": [ + { + "code": "140105", + "name": "小店区" + }, + { + "code": "140106", + "name": "迎泽区" + }, + { + "code": "140107", + "name": "杏花岭区" + }, + { + "code": "140108", + "name": "尖草坪区" + }, + { + "code": "140109", + "name": "万柏林区" + }, + { + "code": "140110", + "name": "晋源区" + }, + { + "code": "140121", + "name": "清徐县" + }, + { + "code": "140122", + "name": "阳曲县" + }, + { + "code": "140123", + "name": "娄烦县" + }, + { + "code": "140171", + "name": "山西转型综合改革示范区" + }, + { + "code": "140181", + "name": "古交市" + } + ] + }, + { + "code": "1402", + "name": "大同市", + "children": [ + { + "code": "140212", + "name": "新荣区" + }, + { + "code": "140213", + "name": "平城区" + }, + { + "code": "140214", + "name": "云冈区" + }, + { + "code": "140215", + "name": "云州区" + }, + { + "code": "140221", + "name": "阳高县" + }, + { + "code": "140222", + "name": "天镇县" + }, + { + "code": "140223", + "name": "广灵县" + }, + { + "code": "140224", + "name": "灵丘县" + }, + { + "code": "140225", + "name": "浑源县" + }, + { + "code": "140226", + "name": "左云县" + }, + { + "code": "140271", + "name": "山西大同经济开发区" + } + ] + }, + { + "code": "1403", + "name": "阳泉市", + "children": [ + { + "code": "140302", + "name": "城区" + }, + { + "code": "140303", + "name": "矿区" + }, + { + "code": "140311", + "name": "郊区" + }, + { + "code": "140321", + "name": "平定县" + }, + { + "code": "140322", + "name": "盂县" + } + ] + }, + { + "code": "1404", + "name": "长治市", + "children": [ + { + "code": "140403", + "name": "潞州区" + }, + { + "code": "140404", + "name": "上党区" + }, + { + "code": "140405", + "name": "屯留区" + }, + { + "code": "140406", + "name": "潞城区" + }, + { + "code": "140423", + "name": "襄垣县" + }, + { + "code": "140425", + "name": "平顺县" + }, + { + "code": "140426", + "name": "黎城县" + }, + { + "code": "140427", + "name": "壶关县" + }, + { + "code": "140428", + "name": "长子县" + }, + { + "code": "140429", + "name": "武乡县" + }, + { + "code": "140430", + "name": "沁县" + }, + { + "code": "140431", + "name": "沁源县" + } + ] + }, + { + "code": "1405", + "name": "晋城市", + "children": [ + { + "code": "140502", + "name": "城区" + }, + { + "code": "140521", + "name": "沁水县" + }, + { + "code": "140522", + "name": "阳城县" + }, + { + "code": "140524", + "name": "陵川县" + }, + { + "code": "140525", + "name": "泽州县" + }, + { + "code": "140581", + "name": "高平市" + } + ] + }, + { + "code": "1406", + "name": "朔州市", + "children": [ + { + "code": "140602", + "name": "朔城区" + }, + { + "code": "140603", + "name": "平鲁区" + }, + { + "code": "140621", + "name": "山阴县" + }, + { + "code": "140622", + "name": "应县" + }, + { + "code": "140623", + "name": "右玉县" + }, + { + "code": "140671", + "name": "山西朔州经济开发区" + }, + { + "code": "140681", + "name": "怀仁市" + } + ] + }, + { + "code": "1407", + "name": "晋中市", + "children": [ + { + "code": "140702", + "name": "榆次区" + }, + { + "code": "140703", + "name": "太谷区" + }, + { + "code": "140721", + "name": "榆社县" + }, + { + "code": "140722", + "name": "左权县" + }, + { + "code": "140723", + "name": "和顺县" + }, + { + "code": "140724", + "name": "昔阳县" + }, + { + "code": "140725", + "name": "寿阳县" + }, + { + "code": "140727", + "name": "祁县" + }, + { + "code": "140728", + "name": "平遥县" + }, + { + "code": "140729", + "name": "灵石县" + }, + { + "code": "140781", + "name": "介休市" + } + ] + }, + { + "code": "1408", + "name": "运城市", + "children": [ + { + "code": "140802", + "name": "盐湖区" + }, + { + "code": "140821", + "name": "临猗县" + }, + { + "code": "140822", + "name": "万荣县" + }, + { + "code": "140823", + "name": "闻喜县" + }, + { + "code": "140824", + "name": "稷山县" + }, + { + "code": "140825", + "name": "新绛县" + }, + { + "code": "140826", + "name": "绛县" + }, + { + "code": "140827", + "name": "垣曲县" + }, + { + "code": "140828", + "name": "夏县" + }, + { + "code": "140829", + "name": "平陆县" + }, + { + "code": "140830", + "name": "芮城县" + }, + { + "code": "140881", + "name": "永济市" + }, + { + "code": "140882", + "name": "河津市" + } + ] + }, + { + "code": "1409", + "name": "忻州市", + "children": [ + { + "code": "140902", + "name": "忻府区" + }, + { + "code": "140921", + "name": "定襄县" + }, + { + "code": "140922", + "name": "五台县" + }, + { + "code": "140923", + "name": "代县" + }, + { + "code": "140924", + "name": "繁峙县" + }, + { + "code": "140925", + "name": "宁武县" + }, + { + "code": "140926", + "name": "静乐县" + }, + { + "code": "140927", + "name": "神池县" + }, + { + "code": "140928", + "name": "五寨县" + }, + { + "code": "140929", + "name": "岢岚县" + }, + { + "code": "140930", + "name": "河曲县" + }, + { + "code": "140931", + "name": "保德县" + }, + { + "code": "140932", + "name": "偏关县" + }, + { + "code": "140971", + "name": "五台山风景名胜区" + }, + { + "code": "140981", + "name": "原平市" + } + ] + }, + { + "code": "1410", + "name": "临汾市", + "children": [ + { + "code": "141002", + "name": "尧都区" + }, + { + "code": "141021", + "name": "曲沃县" + }, + { + "code": "141022", + "name": "翼城县" + }, + { + "code": "141023", + "name": "襄汾县" + }, + { + "code": "141024", + "name": "洪洞县" + }, + { + "code": "141025", + "name": "古县" + }, + { + "code": "141026", + "name": "安泽县" + }, + { + "code": "141027", + "name": "浮山县" + }, + { + "code": "141028", + "name": "吉县" + }, + { + "code": "141029", + "name": "乡宁县" + }, + { + "code": "141030", + "name": "大宁县" + }, + { + "code": "141031", + "name": "隰县" + }, + { + "code": "141032", + "name": "永和县" + }, + { + "code": "141033", + "name": "蒲县" + }, + { + "code": "141034", + "name": "汾西县" + }, + { + "code": "141081", + "name": "侯马市" + }, + { + "code": "141082", + "name": "霍州市" + } + ] + }, + { + "code": "1411", + "name": "吕梁市", + "children": [ + { + "code": "141102", + "name": "离石区" + }, + { + "code": "141121", + "name": "文水县" + }, + { + "code": "141122", + "name": "交城县" + }, + { + "code": "141123", + "name": "兴县" + }, + { + "code": "141124", + "name": "临县" + }, + { + "code": "141125", + "name": "柳林县" + }, + { + "code": "141126", + "name": "石楼县" + }, + { + "code": "141127", + "name": "岚县" + }, + { + "code": "141128", + "name": "方山县" + }, + { + "code": "141129", + "name": "中阳县" + }, + { + "code": "141130", + "name": "交口县" + }, + { + "code": "141181", + "name": "孝义市" + }, + { + "code": "141182", + "name": "汾阳市" + } + ] + } + ] + }, + { + "code": "15", + "name": "内蒙古自治区", + "children": [ + { + "code": "1501", + "name": "呼和浩特市", + "children": [ + { + "code": "150102", + "name": "新城区" + }, + { + "code": "150103", + "name": "回民区" + }, + { + "code": "150104", + "name": "玉泉区" + }, + { + "code": "150105", + "name": "赛罕区" + }, + { + "code": "150121", + "name": "土默特左旗" + }, + { + "code": "150122", + "name": "托克托县" + }, + { + "code": "150123", + "name": "和林格尔县" + }, + { + "code": "150124", + "name": "清水河县" + }, + { + "code": "150125", + "name": "武川县" + }, + { + "code": "150172", + "name": "呼和浩特经济技术开发区" + } + ] + }, + { + "code": "1502", + "name": "包头市", + "children": [ + { + "code": "150202", + "name": "东河区" + }, + { + "code": "150203", + "name": "昆都仑区" + }, + { + "code": "150204", + "name": "青山区" + }, + { + "code": "150205", + "name": "石拐区" + }, + { + "code": "150206", + "name": "白云鄂博矿区" + }, + { + "code": "150207", + "name": "九原区" + }, + { + "code": "150221", + "name": "土默特右旗" + }, + { + "code": "150222", + "name": "固阳县" + }, + { + "code": "150223", + "name": "达尔罕茂明安联合旗" + }, + { + "code": "150271", + "name": "包头稀土高新技术产业开发区" + } + ] + }, + { + "code": "1503", + "name": "乌海市", + "children": [ + { + "code": "150302", + "name": "海勃湾区" + }, + { + "code": "150303", + "name": "海南区" + }, + { + "code": "150304", + "name": "乌达区" + } + ] + }, + { + "code": "1504", + "name": "赤峰市", + "children": [ + { + "code": "150402", + "name": "红山区" + }, + { + "code": "150403", + "name": "元宝山区" + }, + { + "code": "150404", + "name": "松山区" + }, + { + "code": "150421", + "name": "阿鲁科尔沁旗" + }, + { + "code": "150422", + "name": "巴林左旗" + }, + { + "code": "150423", + "name": "巴林右旗" + }, + { + "code": "150424", + "name": "林西县" + }, + { + "code": "150425", + "name": "克什克腾旗" + }, + { + "code": "150426", + "name": "翁牛特旗" + }, + { + "code": "150428", + "name": "喀喇沁旗" + }, + { + "code": "150429", + "name": "宁城县" + }, + { + "code": "150430", + "name": "敖汉旗" + } + ] + }, + { + "code": "1505", + "name": "通辽市", + "children": [ + { + "code": "150502", + "name": "科尔沁区" + }, + { + "code": "150521", + "name": "科尔沁左翼中旗" + }, + { + "code": "150522", + "name": "科尔沁左翼后旗" + }, + { + "code": "150523", + "name": "开鲁县" + }, + { + "code": "150524", + "name": "库伦旗" + }, + { + "code": "150525", + "name": "奈曼旗" + }, + { + "code": "150526", + "name": "扎鲁特旗" + }, + { + "code": "150571", + "name": "通辽经济技术开发区" + }, + { + "code": "150581", + "name": "霍林郭勒市" + } + ] + }, + { + "code": "1506", + "name": "鄂尔多斯市", + "children": [ + { + "code": "150602", + "name": "东胜区" + }, + { + "code": "150603", + "name": "康巴什区" + }, + { + "code": "150621", + "name": "达拉特旗" + }, + { + "code": "150622", + "name": "准格尔旗" + }, + { + "code": "150623", + "name": "鄂托克前旗" + }, + { + "code": "150624", + "name": "鄂托克旗" + }, + { + "code": "150625", + "name": "杭锦旗" + }, + { + "code": "150626", + "name": "乌审旗" + }, + { + "code": "150627", + "name": "伊金霍洛旗" + } + ] + }, + { + "code": "1507", + "name": "呼伦贝尔市", + "children": [ + { + "code": "150702", + "name": "海拉尔区" + }, + { + "code": "150703", + "name": "扎赉诺尔区" + }, + { + "code": "150721", + "name": "阿荣旗" + }, + { + "code": "150722", + "name": "莫力达瓦达斡尔族自治旗" + }, + { + "code": "150723", + "name": "鄂伦春自治旗" + }, + { + "code": "150724", + "name": "鄂温克族自治旗" + }, + { + "code": "150725", + "name": "陈巴尔虎旗" + }, + { + "code": "150726", + "name": "新巴尔虎左旗" + }, + { + "code": "150727", + "name": "新巴尔虎右旗" + }, + { + "code": "150781", + "name": "满洲里市" + }, + { + "code": "150782", + "name": "牙克石市" + }, + { + "code": "150783", + "name": "扎兰屯市" + }, + { + "code": "150784", + "name": "额尔古纳市" + }, + { + "code": "150785", + "name": "根河市" + } + ] + }, + { + "code": "1508", + "name": "巴彦淖尔市", + "children": [ + { + "code": "150802", + "name": "临河区" + }, + { + "code": "150821", + "name": "五原县" + }, + { + "code": "150822", + "name": "磴口县" + }, + { + "code": "150823", + "name": "乌拉特前旗" + }, + { + "code": "150824", + "name": "乌拉特中旗" + }, + { + "code": "150825", + "name": "乌拉特后旗" + }, + { + "code": "150826", + "name": "杭锦后旗" + } + ] + }, + { + "code": "1509", + "name": "乌兰察布市", + "children": [ + { + "code": "150902", + "name": "集宁区" + }, + { + "code": "150921", + "name": "卓资县" + }, + { + "code": "150922", + "name": "化德县" + }, + { + "code": "150923", + "name": "商都县" + }, + { + "code": "150924", + "name": "兴和县" + }, + { + "code": "150925", + "name": "凉城县" + }, + { + "code": "150926", + "name": "察哈尔右翼前旗" + }, + { + "code": "150927", + "name": "察哈尔右翼中旗" + }, + { + "code": "150928", + "name": "察哈尔右翼后旗" + }, + { + "code": "150929", + "name": "四子王旗" + }, + { + "code": "150981", + "name": "丰镇市" + } + ] + }, + { + "code": "1522", + "name": "兴安盟", + "children": [ + { + "code": "152201", + "name": "乌兰浩特市" + }, + { + "code": "152202", + "name": "阿尔山市" + }, + { + "code": "152221", + "name": "科尔沁右翼前旗" + }, + { + "code": "152222", + "name": "科尔沁右翼中旗" + }, + { + "code": "152223", + "name": "扎赉特旗" + }, + { + "code": "152224", + "name": "突泉县" + } + ] + }, + { + "code": "1525", + "name": "锡林郭勒盟", + "children": [ + { + "code": "152501", + "name": "二连浩特市" + }, + { + "code": "152502", + "name": "锡林浩特市" + }, + { + "code": "152522", + "name": "阿巴嘎旗" + }, + { + "code": "152523", + "name": "苏尼特左旗" + }, + { + "code": "152524", + "name": "苏尼特右旗" + }, + { + "code": "152525", + "name": "东乌珠穆沁旗" + }, + { + "code": "152526", + "name": "西乌珠穆沁旗" + }, + { + "code": "152527", + "name": "太仆寺旗" + }, + { + "code": "152528", + "name": "镶黄旗" + }, + { + "code": "152529", + "name": "正镶白旗" + }, + { + "code": "152530", + "name": "正蓝旗" + }, + { + "code": "152531", + "name": "多伦县" + }, + { + "code": "152571", + "name": "乌拉盖管理区管委会" + } + ] + }, + { + "code": "1529", + "name": "阿拉善盟", + "children": [ + { + "code": "152921", + "name": "阿拉善左旗" + }, + { + "code": "152922", + "name": "阿拉善右旗" + }, + { + "code": "152923", + "name": "额济纳旗" + }, + { + "code": "152971", + "name": "内蒙古阿拉善高新技术产业开发区" + } + ] + } + ] + }, + { + "code": "21", + "name": "辽宁省", + "children": [ + { + "code": "2101", + "name": "沈阳市", + "children": [ + { + "code": "210102", + "name": "和平区" + }, + { + "code": "210103", + "name": "沈河区" + }, + { + "code": "210104", + "name": "大东区" + }, + { + "code": "210105", + "name": "皇姑区" + }, + { + "code": "210106", + "name": "铁西区" + }, + { + "code": "210111", + "name": "苏家屯区" + }, + { + "code": "210112", + "name": "浑南区" + }, + { + "code": "210113", + "name": "沈北新区" + }, + { + "code": "210114", + "name": "于洪区" + }, + { + "code": "210115", + "name": "辽中区" + }, + { + "code": "210123", + "name": "康平县" + }, + { + "code": "210124", + "name": "法库县" + }, + { + "code": "210181", + "name": "新民市" + } + ] + }, + { + "code": "2102", + "name": "大连市", + "children": [ + { + "code": "210202", + "name": "中山区" + }, + { + "code": "210203", + "name": "西岗区" + }, + { + "code": "210204", + "name": "沙河口区" + }, + { + "code": "210211", + "name": "甘井子区" + }, + { + "code": "210212", + "name": "旅顺口区" + }, + { + "code": "210213", + "name": "金州区" + }, + { + "code": "210214", + "name": "普兰店区" + }, + { + "code": "210224", + "name": "长海县" + }, + { + "code": "210281", + "name": "瓦房店市" + }, + { + "code": "210283", + "name": "庄河市" + } + ] + }, + { + "code": "2103", + "name": "鞍山市", + "children": [ + { + "code": "210302", + "name": "铁东区" + }, + { + "code": "210303", + "name": "铁西区" + }, + { + "code": "210304", + "name": "立山区" + }, + { + "code": "210311", + "name": "千山区" + }, + { + "code": "210321", + "name": "台安县" + }, + { + "code": "210323", + "name": "岫岩满族自治县" + }, + { + "code": "210381", + "name": "海城市" + } + ] + }, + { + "code": "2104", + "name": "抚顺市", + "children": [ + { + "code": "210402", + "name": "新抚区" + }, + { + "code": "210403", + "name": "东洲区" + }, + { + "code": "210404", + "name": "望花区" + }, + { + "code": "210411", + "name": "顺城区" + }, + { + "code": "210421", + "name": "抚顺县" + }, + { + "code": "210422", + "name": "新宾满族自治县" + }, + { + "code": "210423", + "name": "清原满族自治县" + } + ] + }, + { + "code": "2105", + "name": "本溪市", + "children": [ + { + "code": "210502", + "name": "平山区" + }, + { + "code": "210503", + "name": "溪湖区" + }, + { + "code": "210504", + "name": "明山区" + }, + { + "code": "210505", + "name": "南芬区" + }, + { + "code": "210521", + "name": "本溪满族自治县" + }, + { + "code": "210522", + "name": "桓仁满族自治县" + } + ] + }, + { + "code": "2106", + "name": "丹东市", + "children": [ + { + "code": "210602", + "name": "元宝区" + }, + { + "code": "210603", + "name": "振兴区" + }, + { + "code": "210604", + "name": "振安区" + }, + { + "code": "210624", + "name": "宽甸满族自治县" + }, + { + "code": "210681", + "name": "东港市" + }, + { + "code": "210682", + "name": "凤城市" + } + ] + }, + { + "code": "2107", + "name": "锦州市", + "children": [ + { + "code": "210702", + "name": "古塔区" + }, + { + "code": "210703", + "name": "凌河区" + }, + { + "code": "210711", + "name": "太和区" + }, + { + "code": "210726", + "name": "黑山县" + }, + { + "code": "210727", + "name": "义县" + }, + { + "code": "210781", + "name": "凌海市" + }, + { + "code": "210782", + "name": "北镇市" + } + ] + }, + { + "code": "2108", + "name": "营口市", + "children": [ + { + "code": "210802", + "name": "站前区" + }, + { + "code": "210803", + "name": "西市区" + }, + { + "code": "210804", + "name": "鲅鱼圈区" + }, + { + "code": "210811", + "name": "老边区" + }, + { + "code": "210881", + "name": "盖州市" + }, + { + "code": "210882", + "name": "大石桥市" + } + ] + }, + { + "code": "2109", + "name": "阜新市", + "children": [ + { + "code": "210902", + "name": "海州区" + }, + { + "code": "210903", + "name": "新邱区" + }, + { + "code": "210904", + "name": "太平区" + }, + { + "code": "210905", + "name": "清河门区" + }, + { + "code": "210911", + "name": "细河区" + }, + { + "code": "210921", + "name": "阜新蒙古族自治县" + }, + { + "code": "210922", + "name": "彰武县" + } + ] + }, + { + "code": "2110", + "name": "辽阳市", + "children": [ + { + "code": "211002", + "name": "白塔区" + }, + { + "code": "211003", + "name": "文圣区" + }, + { + "code": "211004", + "name": "宏伟区" + }, + { + "code": "211005", + "name": "弓长岭区" + }, + { + "code": "211011", + "name": "太子河区" + }, + { + "code": "211021", + "name": "辽阳县" + }, + { + "code": "211081", + "name": "灯塔市" + } + ] + }, + { + "code": "2111", + "name": "盘锦市", + "children": [ + { + "code": "211102", + "name": "双台子区" + }, + { + "code": "211103", + "name": "兴隆台区" + }, + { + "code": "211104", + "name": "大洼区" + }, + { + "code": "211122", + "name": "盘山县" + } + ] + }, + { + "code": "2112", + "name": "铁岭市", + "children": [ + { + "code": "211202", + "name": "银州区" + }, + { + "code": "211204", + "name": "清河区" + }, + { + "code": "211221", + "name": "铁岭县" + }, + { + "code": "211223", + "name": "西丰县" + }, + { + "code": "211224", + "name": "昌图县" + }, + { + "code": "211281", + "name": "调兵山市" + }, + { + "code": "211282", + "name": "开原市" + } + ] + }, + { + "code": "2113", + "name": "朝阳市", + "children": [ + { + "code": "211302", + "name": "双塔区" + }, + { + "code": "211303", + "name": "龙城区" + }, + { + "code": "211321", + "name": "朝阳县" + }, + { + "code": "211322", + "name": "建平县" + }, + { + "code": "211324", + "name": "喀喇沁左翼蒙古族自治县" + }, + { + "code": "211381", + "name": "北票市" + }, + { + "code": "211382", + "name": "凌源市" + } + ] + }, + { + "code": "2114", + "name": "葫芦岛市", + "children": [ + { + "code": "211402", + "name": "连山区" + }, + { + "code": "211403", + "name": "龙港区" + }, + { + "code": "211404", + "name": "南票区" + }, + { + "code": "211421", + "name": "绥中县" + }, + { + "code": "211422", + "name": "建昌县" + }, + { + "code": "211481", + "name": "兴城市" + } + ] + } + ] + }, + { + "code": "22", + "name": "吉林省", + "children": [ + { + "code": "2201", + "name": "长春市", + "children": [ + { + "code": "220102", + "name": "南关区" + }, + { + "code": "220103", + "name": "宽城区" + }, + { + "code": "220104", + "name": "朝阳区" + }, + { + "code": "220105", + "name": "二道区" + }, + { + "code": "220106", + "name": "绿园区" + }, + { + "code": "220112", + "name": "双阳区" + }, + { + "code": "220113", + "name": "九台区" + }, + { + "code": "220122", + "name": "农安县" + }, + { + "code": "220171", + "name": "长春经济技术开发区" + }, + { + "code": "220172", + "name": "长春净月高新技术产业开发区" + }, + { + "code": "220173", + "name": "长春高新技术产业开发区" + }, + { + "code": "220174", + "name": "长春汽车经济技术开发区" + }, + { + "code": "220182", + "name": "榆树市" + }, + { + "code": "220183", + "name": "德惠市" + }, + { + "code": "220184", + "name": "公主岭市" + } + ] + }, + { + "code": "2202", + "name": "吉林市", + "children": [ + { + "code": "220202", + "name": "昌邑区" + }, + { + "code": "220203", + "name": "龙潭区" + }, + { + "code": "220204", + "name": "船营区" + }, + { + "code": "220211", + "name": "丰满区" + }, + { + "code": "220221", + "name": "永吉县" + }, + { + "code": "220271", + "name": "吉林经济开发区" + }, + { + "code": "220272", + "name": "吉林高新技术产业开发区" + }, + { + "code": "220273", + "name": "吉林中国新加坡食品区" + }, + { + "code": "220281", + "name": "蛟河市" + }, + { + "code": "220282", + "name": "桦甸市" + }, + { + "code": "220283", + "name": "舒兰市" + }, + { + "code": "220284", + "name": "磐石市" + } + ] + }, + { + "code": "2203", + "name": "四平市", + "children": [ + { + "code": "220302", + "name": "铁西区" + }, + { + "code": "220303", + "name": "铁东区" + }, + { + "code": "220322", + "name": "梨树县" + }, + { + "code": "220323", + "name": "伊通满族自治县" + }, + { + "code": "220382", + "name": "双辽市" + } + ] + }, + { + "code": "2204", + "name": "辽源市", + "children": [ + { + "code": "220402", + "name": "龙山区" + }, + { + "code": "220403", + "name": "西安区" + }, + { + "code": "220421", + "name": "东丰县" + }, + { + "code": "220422", + "name": "东辽县" + } + ] + }, + { + "code": "2205", + "name": "通化市", + "children": [ + { + "code": "220502", + "name": "东昌区" + }, + { + "code": "220503", + "name": "二道江区" + }, + { + "code": "220521", + "name": "通化县" + }, + { + "code": "220523", + "name": "辉南县" + }, + { + "code": "220524", + "name": "柳河县" + }, + { + "code": "220581", + "name": "梅河口市" + }, + { + "code": "220582", + "name": "集安市" + } + ] + }, + { + "code": "2206", + "name": "白山市", + "children": [ + { + "code": "220602", + "name": "浑江区" + }, + { + "code": "220605", + "name": "江源区" + }, + { + "code": "220621", + "name": "抚松县" + }, + { + "code": "220622", + "name": "靖宇县" + }, + { + "code": "220623", + "name": "长白朝鲜族自治县" + }, + { + "code": "220681", + "name": "临江市" + } + ] + }, + { + "code": "2207", + "name": "松原市", + "children": [ + { + "code": "220702", + "name": "宁江区" + }, + { + "code": "220721", + "name": "前郭尔罗斯蒙古族自治县" + }, + { + "code": "220722", + "name": "长岭县" + }, + { + "code": "220723", + "name": "乾安县" + }, + { + "code": "220771", + "name": "吉林松原经济开发区" + }, + { + "code": "220781", + "name": "扶余市" + } + ] + }, + { + "code": "2208", + "name": "白城市", + "children": [ + { + "code": "220802", + "name": "洮北区" + }, + { + "code": "220821", + "name": "镇赉县" + }, + { + "code": "220822", + "name": "通榆县" + }, + { + "code": "220871", + "name": "吉林白城经济开发区" + }, + { + "code": "220881", + "name": "洮南市" + }, + { + "code": "220882", + "name": "大安市" + } + ] + }, + { + "code": "2224", + "name": "延边朝鲜族自治州", + "children": [ + { + "code": "222401", + "name": "延吉市" + }, + { + "code": "222402", + "name": "图们市" + }, + { + "code": "222403", + "name": "敦化市" + }, + { + "code": "222404", + "name": "珲春市" + }, + { + "code": "222405", + "name": "龙井市" + }, + { + "code": "222406", + "name": "和龙市" + }, + { + "code": "222424", + "name": "汪清县" + }, + { + "code": "222426", + "name": "安图县" + } + ] + } + ] + }, + { + "code": "23", + "name": "黑龙江省", + "children": [ + { + "code": "2301", + "name": "哈尔滨市", + "children": [ + { + "code": "230102", + "name": "道里区" + }, + { + "code": "230103", + "name": "南岗区" + }, + { + "code": "230104", + "name": "道外区" + }, + { + "code": "230108", + "name": "平房区" + }, + { + "code": "230109", + "name": "松北区" + }, + { + "code": "230110", + "name": "香坊区" + }, + { + "code": "230111", + "name": "呼兰区" + }, + { + "code": "230112", + "name": "阿城区" + }, + { + "code": "230113", + "name": "双城区" + }, + { + "code": "230123", + "name": "依兰县" + }, + { + "code": "230124", + "name": "方正县" + }, + { + "code": "230125", + "name": "宾县" + }, + { + "code": "230126", + "name": "巴彦县" + }, + { + "code": "230127", + "name": "木兰县" + }, + { + "code": "230128", + "name": "通河县" + }, + { + "code": "230129", + "name": "延寿县" + }, + { + "code": "230183", + "name": "尚志市" + }, + { + "code": "230184", + "name": "五常市" + } + ] + }, + { + "code": "2302", + "name": "齐齐哈尔市", + "children": [ + { + "code": "230202", + "name": "龙沙区" + }, + { + "code": "230203", + "name": "建华区" + }, + { + "code": "230204", + "name": "铁锋区" + }, + { + "code": "230205", + "name": "昂昂溪区" + }, + { + "code": "230206", + "name": "富拉尔基区" + }, + { + "code": "230207", + "name": "碾子山区" + }, + { + "code": "230208", + "name": "梅里斯达斡尔族区" + }, + { + "code": "230221", + "name": "龙江县" + }, + { + "code": "230223", + "name": "依安县" + }, + { + "code": "230224", + "name": "泰来县" + }, + { + "code": "230225", + "name": "甘南县" + }, + { + "code": "230227", + "name": "富裕县" + }, + { + "code": "230229", + "name": "克山县" + }, + { + "code": "230230", + "name": "克东县" + }, + { + "code": "230231", + "name": "拜泉县" + }, + { + "code": "230281", + "name": "讷河市" + } + ] + }, + { + "code": "2303", + "name": "鸡西市", + "children": [ + { + "code": "230302", + "name": "鸡冠区" + }, + { + "code": "230303", + "name": "恒山区" + }, + { + "code": "230304", + "name": "滴道区" + }, + { + "code": "230305", + "name": "梨树区" + }, + { + "code": "230306", + "name": "城子河区" + }, + { + "code": "230307", + "name": "麻山区" + }, + { + "code": "230321", + "name": "鸡东县" + }, + { + "code": "230381", + "name": "虎林市" + }, + { + "code": "230382", + "name": "密山市" + } + ] + }, + { + "code": "2304", + "name": "鹤岗市", + "children": [ + { + "code": "230402", + "name": "向阳区" + }, + { + "code": "230403", + "name": "工农区" + }, + { + "code": "230404", + "name": "南山区" + }, + { + "code": "230405", + "name": "兴安区" + }, + { + "code": "230406", + "name": "东山区" + }, + { + "code": "230407", + "name": "兴山区" + }, + { + "code": "230421", + "name": "萝北县" + }, + { + "code": "230422", + "name": "绥滨县" + } + ] + }, + { + "code": "2305", + "name": "双鸭山市", + "children": [ + { + "code": "230502", + "name": "尖山区" + }, + { + "code": "230503", + "name": "岭东区" + }, + { + "code": "230505", + "name": "四方台区" + }, + { + "code": "230506", + "name": "宝山区" + }, + { + "code": "230521", + "name": "集贤县" + }, + { + "code": "230522", + "name": "友谊县" + }, + { + "code": "230523", + "name": "宝清县" + }, + { + "code": "230524", + "name": "饶河县" + } + ] + }, + { + "code": "2306", + "name": "大庆市", + "children": [ + { + "code": "230602", + "name": "萨尔图区" + }, + { + "code": "230603", + "name": "龙凤区" + }, + { + "code": "230604", + "name": "让胡路区" + }, + { + "code": "230605", + "name": "红岗区" + }, + { + "code": "230606", + "name": "大同区" + }, + { + "code": "230621", + "name": "肇州县" + }, + { + "code": "230622", + "name": "肇源县" + }, + { + "code": "230623", + "name": "林甸县" + }, + { + "code": "230624", + "name": "杜尔伯特蒙古族自治县" + }, + { + "code": "230671", + "name": "大庆高新技术产业开发区" + } + ] + }, + { + "code": "2307", + "name": "伊春市", + "children": [ + { + "code": "230717", + "name": "伊美区" + }, + { + "code": "230718", + "name": "乌翠区" + }, + { + "code": "230719", + "name": "友好区" + }, + { + "code": "230722", + "name": "嘉荫县" + }, + { + "code": "230723", + "name": "汤旺县" + }, + { + "code": "230724", + "name": "丰林县" + }, + { + "code": "230725", + "name": "大箐山县" + }, + { + "code": "230726", + "name": "南岔县" + }, + { + "code": "230751", + "name": "金林区" + }, + { + "code": "230781", + "name": "铁力市" + } + ] + }, + { + "code": "2308", + "name": "佳木斯市", + "children": [ + { + "code": "230803", + "name": "向阳区" + }, + { + "code": "230804", + "name": "前进区" + }, + { + "code": "230805", + "name": "东风区" + }, + { + "code": "230811", + "name": "郊区" + }, + { + "code": "230822", + "name": "桦南县" + }, + { + "code": "230826", + "name": "桦川县" + }, + { + "code": "230828", + "name": "汤原县" + }, + { + "code": "230881", + "name": "同江市" + }, + { + "code": "230882", + "name": "富锦市" + }, + { + "code": "230883", + "name": "抚远市" + } + ] + }, + { + "code": "2309", + "name": "七台河市", + "children": [ + { + "code": "230902", + "name": "新兴区" + }, + { + "code": "230903", + "name": "桃山区" + }, + { + "code": "230904", + "name": "茄子河区" + }, + { + "code": "230921", + "name": "勃利县" + } + ] + }, + { + "code": "2310", + "name": "牡丹江市", + "children": [ + { + "code": "231002", + "name": "东安区" + }, + { + "code": "231003", + "name": "阳明区" + }, + { + "code": "231004", + "name": "爱民区" + }, + { + "code": "231005", + "name": "西安区" + }, + { + "code": "231025", + "name": "林口县" + }, + { + "code": "231081", + "name": "绥芬河市" + }, + { + "code": "231083", + "name": "海林市" + }, + { + "code": "231084", + "name": "宁安市" + }, + { + "code": "231085", + "name": "穆棱市" + }, + { + "code": "231086", + "name": "东宁市" + } + ] + }, + { + "code": "2311", + "name": "黑河市", + "children": [ + { + "code": "231102", + "name": "爱辉区" + }, + { + "code": "231123", + "name": "逊克县" + }, + { + "code": "231124", + "name": "孙吴县" + }, + { + "code": "231181", + "name": "北安市" + }, + { + "code": "231182", + "name": "五大连池市" + }, + { + "code": "231183", + "name": "嫩江市" + } + ] + }, + { + "code": "2312", + "name": "绥化市", + "children": [ + { + "code": "231202", + "name": "北林区" + }, + { + "code": "231221", + "name": "望奎县" + }, + { + "code": "231222", + "name": "兰西县" + }, + { + "code": "231223", + "name": "青冈县" + }, + { + "code": "231224", + "name": "庆安县" + }, + { + "code": "231225", + "name": "明水县" + }, + { + "code": "231226", + "name": "绥棱县" + }, + { + "code": "231281", + "name": "安达市" + }, + { + "code": "231282", + "name": "肇东市" + }, + { + "code": "231283", + "name": "海伦市" + } + ] + }, + { + "code": "2327", + "name": "大兴安岭地区", + "children": [ + { + "code": "232701", + "name": "漠河市" + }, + { + "code": "232721", + "name": "呼玛县" + }, + { + "code": "232722", + "name": "塔河县" + }, + { + "code": "232761", + "name": "加格达奇区" + }, + { + "code": "232762", + "name": "松岭区" + }, + { + "code": "232763", + "name": "新林区" + }, + { + "code": "232764", + "name": "呼中区" + } + ] + } + ] + }, + { + "code": "31", + "name": "上海市", + "children": [ + { + "code": "3101", + "name": "市辖区", + "children": [ + { + "code": "310101", + "name": "黄浦区" + }, + { + "code": "310104", + "name": "徐汇区" + }, + { + "code": "310105", + "name": "长宁区" + }, + { + "code": "310106", + "name": "静安区" + }, + { + "code": "310107", + "name": "普陀区" + }, + { + "code": "310109", + "name": "虹口区" + }, + { + "code": "310110", + "name": "杨浦区" + }, + { + "code": "310112", + "name": "闵行区" + }, + { + "code": "310113", + "name": "宝山区" + }, + { + "code": "310114", + "name": "嘉定区" + }, + { + "code": "310115", + "name": "浦东新区" + }, + { + "code": "310116", + "name": "金山区" + }, + { + "code": "310117", + "name": "松江区" + }, + { + "code": "310118", + "name": "青浦区" + }, + { + "code": "310120", + "name": "奉贤区" + }, + { + "code": "310151", + "name": "崇明区" + } + ] + } + ] + }, + { + "code": "32", + "name": "江苏省", + "children": [ + { + "code": "3201", + "name": "南京市", + "children": [ + { + "code": "320102", + "name": "玄武区" + }, + { + "code": "320104", + "name": "秦淮区" + }, + { + "code": "320105", + "name": "建邺区" + }, + { + "code": "320106", + "name": "鼓楼区" + }, + { + "code": "320111", + "name": "浦口区" + }, + { + "code": "320113", + "name": "栖霞区" + }, + { + "code": "320114", + "name": "雨花台区" + }, + { + "code": "320115", + "name": "江宁区" + }, + { + "code": "320116", + "name": "六合区" + }, + { + "code": "320117", + "name": "溧水区" + }, + { + "code": "320118", + "name": "高淳区" + } + ] + }, + { + "code": "3202", + "name": "无锡市", + "children": [ + { + "code": "320205", + "name": "锡山区" + }, + { + "code": "320206", + "name": "惠山区" + }, + { + "code": "320211", + "name": "滨湖区" + }, + { + "code": "320213", + "name": "梁溪区" + }, + { + "code": "320214", + "name": "新吴区" + }, + { + "code": "320281", + "name": "江阴市" + }, + { + "code": "320282", + "name": "宜兴市" + } + ] + }, + { + "code": "3203", + "name": "徐州市", + "children": [ + { + "code": "320302", + "name": "鼓楼区" + }, + { + "code": "320303", + "name": "云龙区" + }, + { + "code": "320305", + "name": "贾汪区" + }, + { + "code": "320311", + "name": "泉山区" + }, + { + "code": "320312", + "name": "铜山区" + }, + { + "code": "320321", + "name": "丰县" + }, + { + "code": "320322", + "name": "沛县" + }, + { + "code": "320324", + "name": "睢宁县" + }, + { + "code": "320371", + "name": "徐州经济技术开发区" + }, + { + "code": "320381", + "name": "新沂市" + }, + { + "code": "320382", + "name": "邳州市" + } + ] + }, + { + "code": "3204", + "name": "常州市", + "children": [ + { + "code": "320402", + "name": "天宁区" + }, + { + "code": "320404", + "name": "钟楼区" + }, + { + "code": "320411", + "name": "新北区" + }, + { + "code": "320412", + "name": "武进区" + }, + { + "code": "320413", + "name": "金坛区" + }, + { + "code": "320481", + "name": "溧阳市" + } + ] + }, + { + "code": "3205", + "name": "苏州市", + "children": [ + { + "code": "320505", + "name": "虎丘区" + }, + { + "code": "320506", + "name": "吴中区" + }, + { + "code": "320507", + "name": "相城区" + }, + { + "code": "320508", + "name": "姑苏区" + }, + { + "code": "320509", + "name": "吴江区" + }, + { + "code": "320576", + "name": "苏州工业园区" + }, + { + "code": "320581", + "name": "常熟市" + }, + { + "code": "320582", + "name": "张家港市" + }, + { + "code": "320583", + "name": "昆山市" + }, + { + "code": "320585", + "name": "太仓市" + } + ] + }, + { + "code": "3206", + "name": "南通市", + "children": [ + { + "code": "320612", + "name": "通州区" + }, + { + "code": "320613", + "name": "崇川区" + }, + { + "code": "320614", + "name": "海门区" + }, + { + "code": "320623", + "name": "如东县" + }, + { + "code": "320671", + "name": "南通经济技术开发区" + }, + { + "code": "320681", + "name": "启东市" + }, + { + "code": "320682", + "name": "如皋市" + }, + { + "code": "320685", + "name": "海安市" + } + ] + }, + { + "code": "3207", + "name": "连云港市", + "children": [ + { + "code": "320703", + "name": "连云区" + }, + { + "code": "320706", + "name": "海州区" + }, + { + "code": "320707", + "name": "赣榆区" + }, + { + "code": "320722", + "name": "东海县" + }, + { + "code": "320723", + "name": "灌云县" + }, + { + "code": "320724", + "name": "灌南县" + }, + { + "code": "320771", + "name": "连云港经济技术开发区" + } + ] + }, + { + "code": "3208", + "name": "淮安市", + "children": [ + { + "code": "320803", + "name": "淮安区" + }, + { + "code": "320804", + "name": "淮阴区" + }, + { + "code": "320812", + "name": "清江浦区" + }, + { + "code": "320813", + "name": "洪泽区" + }, + { + "code": "320826", + "name": "涟水县" + }, + { + "code": "320830", + "name": "盱眙县" + }, + { + "code": "320831", + "name": "金湖县" + }, + { + "code": "320871", + "name": "淮安经济技术开发区" + } + ] + }, + { + "code": "3209", + "name": "盐城市", + "children": [ + { + "code": "320902", + "name": "亭湖区" + }, + { + "code": "320903", + "name": "盐都区" + }, + { + "code": "320904", + "name": "大丰区" + }, + { + "code": "320921", + "name": "响水县" + }, + { + "code": "320922", + "name": "滨海县" + }, + { + "code": "320923", + "name": "阜宁县" + }, + { + "code": "320924", + "name": "射阳县" + }, + { + "code": "320925", + "name": "建湖县" + }, + { + "code": "320971", + "name": "盐城经济技术开发区" + }, + { + "code": "320981", + "name": "东台市" + } + ] + }, + { + "code": "3210", + "name": "扬州市", + "children": [ + { + "code": "321002", + "name": "广陵区" + }, + { + "code": "321003", + "name": "邗江区" + }, + { + "code": "321012", + "name": "江都区" + }, + { + "code": "321023", + "name": "宝应县" + }, + { + "code": "321071", + "name": "扬州经济技术开发区" + }, + { + "code": "321081", + "name": "仪征市" + }, + { + "code": "321084", + "name": "高邮市" + } + ] + }, + { + "code": "3211", + "name": "镇江市", + "children": [ + { + "code": "321102", + "name": "京口区" + }, + { + "code": "321111", + "name": "润州区" + }, + { + "code": "321112", + "name": "丹徒区" + }, + { + "code": "321171", + "name": "镇江新区" + }, + { + "code": "321181", + "name": "丹阳市" + }, + { + "code": "321182", + "name": "扬中市" + }, + { + "code": "321183", + "name": "句容市" + } + ] + }, + { + "code": "3212", + "name": "泰州市", + "children": [ + { + "code": "321202", + "name": "海陵区" + }, + { + "code": "321203", + "name": "高港区" + }, + { + "code": "321204", + "name": "姜堰区" + }, + { + "code": "321281", + "name": "兴化市" + }, + { + "code": "321282", + "name": "靖江市" + }, + { + "code": "321283", + "name": "泰兴市" + } + ] + }, + { + "code": "3213", + "name": "宿迁市", + "children": [ + { + "code": "321302", + "name": "宿城区" + }, + { + "code": "321311", + "name": "宿豫区" + }, + { + "code": "321322", + "name": "沭阳县" + }, + { + "code": "321323", + "name": "泗阳县" + }, + { + "code": "321324", + "name": "泗洪县" + }, + { + "code": "321371", + "name": "宿迁经济技术开发区" + } + ] + } + ] + }, + { + "code": "33", + "name": "浙江省", + "children": [ + { + "code": "3301", + "name": "杭州市", + "children": [ + { + "code": "330102", + "name": "上城区" + }, + { + "code": "330105", + "name": "拱墅区" + }, + { + "code": "330106", + "name": "西湖区" + }, + { + "code": "330108", + "name": "滨江区" + }, + { + "code": "330109", + "name": "萧山区" + }, + { + "code": "330110", + "name": "余杭区" + }, + { + "code": "330111", + "name": "富阳区" + }, + { + "code": "330112", + "name": "临安区" + }, + { + "code": "330113", + "name": "临平区" + }, + { + "code": "330114", + "name": "钱塘区" + }, + { + "code": "330122", + "name": "桐庐县" + }, + { + "code": "330127", + "name": "淳安县" + }, + { + "code": "330182", + "name": "建德市" + } + ] + }, + { + "code": "3302", + "name": "宁波市", + "children": [ + { + "code": "330203", + "name": "海曙区" + }, + { + "code": "330205", + "name": "江北区" + }, + { + "code": "330206", + "name": "北仑区" + }, + { + "code": "330211", + "name": "镇海区" + }, + { + "code": "330212", + "name": "鄞州区" + }, + { + "code": "330213", + "name": "奉化区" + }, + { + "code": "330225", + "name": "象山县" + }, + { + "code": "330226", + "name": "宁海县" + }, + { + "code": "330281", + "name": "余姚市" + }, + { + "code": "330282", + "name": "慈溪市" + } + ] + }, + { + "code": "3303", + "name": "温州市", + "children": [ + { + "code": "330302", + "name": "鹿城区" + }, + { + "code": "330303", + "name": "龙湾区" + }, + { + "code": "330304", + "name": "瓯海区" + }, + { + "code": "330305", + "name": "洞头区" + }, + { + "code": "330324", + "name": "永嘉县" + }, + { + "code": "330326", + "name": "平阳县" + }, + { + "code": "330327", + "name": "苍南县" + }, + { + "code": "330328", + "name": "文成县" + }, + { + "code": "330329", + "name": "泰顺县" + }, + { + "code": "330381", + "name": "瑞安市" + }, + { + "code": "330382", + "name": "乐清市" + }, + { + "code": "330383", + "name": "龙港市" + } + ] + }, + { + "code": "3304", + "name": "嘉兴市", + "children": [ + { + "code": "330402", + "name": "南湖区" + }, + { + "code": "330411", + "name": "秀洲区" + }, + { + "code": "330421", + "name": "嘉善县" + }, + { + "code": "330424", + "name": "海盐县" + }, + { + "code": "330481", + "name": "海宁市" + }, + { + "code": "330482", + "name": "平湖市" + }, + { + "code": "330483", + "name": "桐乡市" + } + ] + }, + { + "code": "3305", + "name": "湖州市", + "children": [ + { + "code": "330502", + "name": "吴兴区" + }, + { + "code": "330503", + "name": "南浔区" + }, + { + "code": "330521", + "name": "德清县" + }, + { + "code": "330522", + "name": "长兴县" + }, + { + "code": "330523", + "name": "安吉县" + } + ] + }, + { + "code": "3306", + "name": "绍兴市", + "children": [ + { + "code": "330602", + "name": "越城区" + }, + { + "code": "330603", + "name": "柯桥区" + }, + { + "code": "330604", + "name": "上虞区" + }, + { + "code": "330624", + "name": "新昌县" + }, + { + "code": "330681", + "name": "诸暨市" + }, + { + "code": "330683", + "name": "嵊州市" + } + ] + }, + { + "code": "3307", + "name": "金华市", + "children": [ + { + "code": "330702", + "name": "婺城区" + }, + { + "code": "330703", + "name": "金东区" + }, + { + "code": "330723", + "name": "武义县" + }, + { + "code": "330726", + "name": "浦江县" + }, + { + "code": "330727", + "name": "磐安县" + }, + { + "code": "330781", + "name": "兰溪市" + }, + { + "code": "330782", + "name": "义乌市" + }, + { + "code": "330783", + "name": "东阳市" + }, + { + "code": "330784", + "name": "永康市" + } + ] + }, + { + "code": "3308", + "name": "衢州市", + "children": [ + { + "code": "330802", + "name": "柯城区" + }, + { + "code": "330803", + "name": "衢江区" + }, + { + "code": "330822", + "name": "常山县" + }, + { + "code": "330824", + "name": "开化县" + }, + { + "code": "330825", + "name": "龙游县" + }, + { + "code": "330881", + "name": "江山市" + } + ] + }, + { + "code": "3309", + "name": "舟山市", + "children": [ + { + "code": "330902", + "name": "定海区" + }, + { + "code": "330903", + "name": "普陀区" + }, + { + "code": "330921", + "name": "岱山县" + }, + { + "code": "330922", + "name": "嵊泗县" + } + ] + }, + { + "code": "3310", + "name": "台州市", + "children": [ + { + "code": "331002", + "name": "椒江区" + }, + { + "code": "331003", + "name": "黄岩区" + }, + { + "code": "331004", + "name": "路桥区" + }, + { + "code": "331022", + "name": "三门县" + }, + { + "code": "331023", + "name": "天台县" + }, + { + "code": "331024", + "name": "仙居县" + }, + { + "code": "331081", + "name": "温岭市" + }, + { + "code": "331082", + "name": "临海市" + }, + { + "code": "331083", + "name": "玉环市" + } + ] + }, + { + "code": "3311", + "name": "丽水市", + "children": [ + { + "code": "331102", + "name": "莲都区" + }, + { + "code": "331121", + "name": "青田县" + }, + { + "code": "331122", + "name": "缙云县" + }, + { + "code": "331123", + "name": "遂昌县" + }, + { + "code": "331124", + "name": "松阳县" + }, + { + "code": "331125", + "name": "云和县" + }, + { + "code": "331126", + "name": "庆元县" + }, + { + "code": "331127", + "name": "景宁畲族自治县" + }, + { + "code": "331181", + "name": "龙泉市" + } + ] + } + ] + }, + { + "code": "34", + "name": "安徽省", + "children": [ + { + "code": "3401", + "name": "合肥市", + "children": [ + { + "code": "340102", + "name": "瑶海区" + }, + { + "code": "340103", + "name": "庐阳区" + }, + { + "code": "340104", + "name": "蜀山区" + }, + { + "code": "340111", + "name": "包河区" + }, + { + "code": "340121", + "name": "长丰县" + }, + { + "code": "340122", + "name": "肥东县" + }, + { + "code": "340123", + "name": "肥西县" + }, + { + "code": "340124", + "name": "庐江县" + }, + { + "code": "340176", + "name": "合肥高新技术产业开发区" + }, + { + "code": "340177", + "name": "合肥经济技术开发区" + }, + { + "code": "340178", + "name": "合肥新站高新技术产业开发区" + }, + { + "code": "340181", + "name": "巢湖市" + } + ] + }, + { + "code": "3402", + "name": "芜湖市", + "children": [ + { + "code": "340202", + "name": "镜湖区" + }, + { + "code": "340207", + "name": "鸠江区" + }, + { + "code": "340209", + "name": "弋江区" + }, + { + "code": "340210", + "name": "湾沚区" + }, + { + "code": "340212", + "name": "繁昌区" + }, + { + "code": "340223", + "name": "南陵县" + }, + { + "code": "340271", + "name": "芜湖经济技术开发区" + }, + { + "code": "340272", + "name": "安徽芜湖三山经济开发区" + }, + { + "code": "340281", + "name": "无为市" + } + ] + }, + { + "code": "3403", + "name": "蚌埠市", + "children": [ + { + "code": "340302", + "name": "龙子湖区" + }, + { + "code": "340303", + "name": "蚌山区" + }, + { + "code": "340304", + "name": "禹会区" + }, + { + "code": "340311", + "name": "淮上区" + }, + { + "code": "340321", + "name": "怀远县" + }, + { + "code": "340322", + "name": "五河县" + }, + { + "code": "340323", + "name": "固镇县" + }, + { + "code": "340371", + "name": "蚌埠市高新技术开发区" + }, + { + "code": "340372", + "name": "蚌埠市经济开发区" + } + ] + }, + { + "code": "3404", + "name": "淮南市", + "children": [ + { + "code": "340402", + "name": "大通区" + }, + { + "code": "340403", + "name": "田家庵区" + }, + { + "code": "340404", + "name": "谢家集区" + }, + { + "code": "340405", + "name": "八公山区" + }, + { + "code": "340406", + "name": "潘集区" + }, + { + "code": "340421", + "name": "凤台县" + }, + { + "code": "340422", + "name": "寿县" + } + ] + }, + { + "code": "3405", + "name": "马鞍山市", + "children": [ + { + "code": "340503", + "name": "花山区" + }, + { + "code": "340504", + "name": "雨山区" + }, + { + "code": "340506", + "name": "博望区" + }, + { + "code": "340521", + "name": "当涂县" + }, + { + "code": "340522", + "name": "含山县" + }, + { + "code": "340523", + "name": "和县" + } + ] + }, + { + "code": "3406", + "name": "淮北市", + "children": [ + { + "code": "340602", + "name": "杜集区" + }, + { + "code": "340603", + "name": "相山区" + }, + { + "code": "340604", + "name": "烈山区" + }, + { + "code": "340621", + "name": "濉溪县" + } + ] + }, + { + "code": "3407", + "name": "铜陵市", + "children": [ + { + "code": "340705", + "name": "铜官区" + }, + { + "code": "340706", + "name": "义安区" + }, + { + "code": "340711", + "name": "郊区" + }, + { + "code": "340722", + "name": "枞阳县" + } + ] + }, + { + "code": "3408", + "name": "安庆市", + "children": [ + { + "code": "340802", + "name": "迎江区" + }, + { + "code": "340803", + "name": "大观区" + }, + { + "code": "340811", + "name": "宜秀区" + }, + { + "code": "340822", + "name": "怀宁县" + }, + { + "code": "340825", + "name": "太湖县" + }, + { + "code": "340826", + "name": "宿松县" + }, + { + "code": "340827", + "name": "望江县" + }, + { + "code": "340828", + "name": "岳西县" + }, + { + "code": "340871", + "name": "安徽安庆经济开发区" + }, + { + "code": "340881", + "name": "桐城市" + }, + { + "code": "340882", + "name": "潜山市" + } + ] + }, + { + "code": "3410", + "name": "黄山市", + "children": [ + { + "code": "341002", + "name": "屯溪区" + }, + { + "code": "341003", + "name": "黄山区" + }, + { + "code": "341004", + "name": "徽州区" + }, + { + "code": "341021", + "name": "歙县" + }, + { + "code": "341022", + "name": "休宁县" + }, + { + "code": "341023", + "name": "黟县" + }, + { + "code": "341024", + "name": "祁门县" + } + ] + }, + { + "code": "3411", + "name": "滁州市", + "children": [ + { + "code": "341102", + "name": "琅琊区" + }, + { + "code": "341103", + "name": "南谯区" + }, + { + "code": "341122", + "name": "来安县" + }, + { + "code": "341124", + "name": "全椒县" + }, + { + "code": "341125", + "name": "定远县" + }, + { + "code": "341126", + "name": "凤阳县" + }, + { + "code": "341171", + "name": "中新苏滁高新技术产业开发区" + }, + { + "code": "341172", + "name": "滁州经济技术开发区" + }, + { + "code": "341181", + "name": "天长市" + }, + { + "code": "341182", + "name": "明光市" + } + ] + }, + { + "code": "3412", + "name": "阜阳市", + "children": [ + { + "code": "341202", + "name": "颍州区" + }, + { + "code": "341203", + "name": "颍东区" + }, + { + "code": "341204", + "name": "颍泉区" + }, + { + "code": "341221", + "name": "临泉县" + }, + { + "code": "341222", + "name": "太和县" + }, + { + "code": "341225", + "name": "阜南县" + }, + { + "code": "341226", + "name": "颍上县" + }, + { + "code": "341271", + "name": "阜阳合肥现代产业园区" + }, + { + "code": "341272", + "name": "阜阳经济技术开发区" + }, + { + "code": "341282", + "name": "界首市" + } + ] + }, + { + "code": "3413", + "name": "宿州市", + "children": [ + { + "code": "341302", + "name": "埇桥区" + }, + { + "code": "341321", + "name": "砀山县" + }, + { + "code": "341322", + "name": "萧县" + }, + { + "code": "341323", + "name": "灵璧县" + }, + { + "code": "341324", + "name": "泗县" + }, + { + "code": "341371", + "name": "宿州马鞍山现代产业园区" + }, + { + "code": "341372", + "name": "宿州经济技术开发区" + } + ] + }, + { + "code": "3415", + "name": "六安市", + "children": [ + { + "code": "341502", + "name": "金安区" + }, + { + "code": "341503", + "name": "裕安区" + }, + { + "code": "341504", + "name": "叶集区" + }, + { + "code": "341522", + "name": "霍邱县" + }, + { + "code": "341523", + "name": "舒城县" + }, + { + "code": "341524", + "name": "金寨县" + }, + { + "code": "341525", + "name": "霍山县" + } + ] + }, + { + "code": "3416", + "name": "亳州市", + "children": [ + { + "code": "341602", + "name": "谯城区" + }, + { + "code": "341621", + "name": "涡阳县" + }, + { + "code": "341622", + "name": "蒙城县" + }, + { + "code": "341623", + "name": "利辛县" + } + ] + }, + { + "code": "3417", + "name": "池州市", + "children": [ + { + "code": "341702", + "name": "贵池区" + }, + { + "code": "341721", + "name": "东至县" + }, + { + "code": "341722", + "name": "石台县" + }, + { + "code": "341723", + "name": "青阳县" + } + ] + }, + { + "code": "3418", + "name": "宣城市", + "children": [ + { + "code": "341802", + "name": "宣州区" + }, + { + "code": "341821", + "name": "郎溪县" + }, + { + "code": "341823", + "name": "泾县" + }, + { + "code": "341824", + "name": "绩溪县" + }, + { + "code": "341825", + "name": "旌德县" + }, + { + "code": "341871", + "name": "宣城市经济开发区" + }, + { + "code": "341881", + "name": "宁国市" + }, + { + "code": "341882", + "name": "广德市" + } + ] + } + ] + }, + { + "code": "35", + "name": "福建省", + "children": [ + { + "code": "3501", + "name": "福州市", + "children": [ + { + "code": "350102", + "name": "鼓楼区" + }, + { + "code": "350103", + "name": "台江区" + }, + { + "code": "350104", + "name": "仓山区" + }, + { + "code": "350105", + "name": "马尾区" + }, + { + "code": "350111", + "name": "晋安区" + }, + { + "code": "350112", + "name": "长乐区" + }, + { + "code": "350121", + "name": "闽侯县" + }, + { + "code": "350122", + "name": "连江县" + }, + { + "code": "350123", + "name": "罗源县" + }, + { + "code": "350124", + "name": "闽清县" + }, + { + "code": "350125", + "name": "永泰县" + }, + { + "code": "350128", + "name": "平潭县" + }, + { + "code": "350181", + "name": "福清市" + } + ] + }, + { + "code": "3502", + "name": "厦门市", + "children": [ + { + "code": "350203", + "name": "思明区" + }, + { + "code": "350205", + "name": "海沧区" + }, + { + "code": "350206", + "name": "湖里区" + }, + { + "code": "350211", + "name": "集美区" + }, + { + "code": "350212", + "name": "同安区" + }, + { + "code": "350213", + "name": "翔安区" + } + ] + }, + { + "code": "3503", + "name": "莆田市", + "children": [ + { + "code": "350302", + "name": "城厢区" + }, + { + "code": "350303", + "name": "涵江区" + }, + { + "code": "350304", + "name": "荔城区" + }, + { + "code": "350305", + "name": "秀屿区" + }, + { + "code": "350322", + "name": "仙游县" + } + ] + }, + { + "code": "3504", + "name": "三明市", + "children": [ + { + "code": "350404", + "name": "三元区" + }, + { + "code": "350405", + "name": "沙县区" + }, + { + "code": "350421", + "name": "明溪县" + }, + { + "code": "350423", + "name": "清流县" + }, + { + "code": "350424", + "name": "宁化县" + }, + { + "code": "350425", + "name": "大田县" + }, + { + "code": "350426", + "name": "尤溪县" + }, + { + "code": "350428", + "name": "将乐县" + }, + { + "code": "350429", + "name": "泰宁县" + }, + { + "code": "350430", + "name": "建宁县" + }, + { + "code": "350481", + "name": "永安市" + } + ] + }, + { + "code": "3505", + "name": "泉州市", + "children": [ + { + "code": "350502", + "name": "鲤城区" + }, + { + "code": "350503", + "name": "丰泽区" + }, + { + "code": "350504", + "name": "洛江区" + }, + { + "code": "350505", + "name": "泉港区" + }, + { + "code": "350521", + "name": "惠安县" + }, + { + "code": "350524", + "name": "安溪县" + }, + { + "code": "350525", + "name": "永春县" + }, + { + "code": "350526", + "name": "德化县" + }, + { + "code": "350527", + "name": "金门县" + }, + { + "code": "350581", + "name": "石狮市" + }, + { + "code": "350582", + "name": "晋江市" + }, + { + "code": "350583", + "name": "南安市" + } + ] + }, + { + "code": "3506", + "name": "漳州市", + "children": [ + { + "code": "350602", + "name": "芗城区" + }, + { + "code": "350603", + "name": "龙文区" + }, + { + "code": "350604", + "name": "龙海区" + }, + { + "code": "350605", + "name": "长泰区" + }, + { + "code": "350622", + "name": "云霄县" + }, + { + "code": "350623", + "name": "漳浦县" + }, + { + "code": "350624", + "name": "诏安县" + }, + { + "code": "350626", + "name": "东山县" + }, + { + "code": "350627", + "name": "南靖县" + }, + { + "code": "350628", + "name": "平和县" + }, + { + "code": "350629", + "name": "华安县" + } + ] + }, + { + "code": "3507", + "name": "南平市", + "children": [ + { + "code": "350702", + "name": "延平区" + }, + { + "code": "350703", + "name": "建阳区" + }, + { + "code": "350721", + "name": "顺昌县" + }, + { + "code": "350722", + "name": "浦城县" + }, + { + "code": "350723", + "name": "光泽县" + }, + { + "code": "350724", + "name": "松溪县" + }, + { + "code": "350725", + "name": "政和县" + }, + { + "code": "350781", + "name": "邵武市" + }, + { + "code": "350782", + "name": "武夷山市" + }, + { + "code": "350783", + "name": "建瓯市" + } + ] + }, + { + "code": "3508", + "name": "龙岩市", + "children": [ + { + "code": "350802", + "name": "新罗区" + }, + { + "code": "350803", + "name": "永定区" + }, + { + "code": "350821", + "name": "长汀县" + }, + { + "code": "350823", + "name": "上杭县" + }, + { + "code": "350824", + "name": "武平县" + }, + { + "code": "350825", + "name": "连城县" + }, + { + "code": "350881", + "name": "漳平市" + } + ] + }, + { + "code": "3509", + "name": "宁德市", + "children": [ + { + "code": "350902", + "name": "蕉城区" + }, + { + "code": "350921", + "name": "霞浦县" + }, + { + "code": "350922", + "name": "古田县" + }, + { + "code": "350923", + "name": "屏南县" + }, + { + "code": "350924", + "name": "寿宁县" + }, + { + "code": "350925", + "name": "周宁县" + }, + { + "code": "350926", + "name": "柘荣县" + }, + { + "code": "350981", + "name": "福安市" + }, + { + "code": "350982", + "name": "福鼎市" + } + ] + } + ] + }, + { + "code": "36", + "name": "江西省", + "children": [ + { + "code": "3601", + "name": "南昌市", + "children": [ + { + "code": "360102", + "name": "东湖区" + }, + { + "code": "360103", + "name": "西湖区" + }, + { + "code": "360104", + "name": "青云谱区" + }, + { + "code": "360111", + "name": "青山湖区" + }, + { + "code": "360112", + "name": "新建区" + }, + { + "code": "360113", + "name": "红谷滩区" + }, + { + "code": "360121", + "name": "南昌县" + }, + { + "code": "360123", + "name": "安义县" + }, + { + "code": "360124", + "name": "进贤县" + } + ] + }, + { + "code": "3602", + "name": "景德镇市", + "children": [ + { + "code": "360202", + "name": "昌江区" + }, + { + "code": "360203", + "name": "珠山区" + }, + { + "code": "360222", + "name": "浮梁县" + }, + { + "code": "360281", + "name": "乐平市" + } + ] + }, + { + "code": "3603", + "name": "萍乡市", + "children": [ + { + "code": "360302", + "name": "安源区" + }, + { + "code": "360313", + "name": "湘东区" + }, + { + "code": "360321", + "name": "莲花县" + }, + { + "code": "360322", + "name": "上栗县" + }, + { + "code": "360323", + "name": "芦溪县" + } + ] + }, + { + "code": "3604", + "name": "九江市", + "children": [ + { + "code": "360402", + "name": "濂溪区" + }, + { + "code": "360403", + "name": "浔阳区" + }, + { + "code": "360404", + "name": "柴桑区" + }, + { + "code": "360423", + "name": "武宁县" + }, + { + "code": "360424", + "name": "修水县" + }, + { + "code": "360425", + "name": "永修县" + }, + { + "code": "360426", + "name": "德安县" + }, + { + "code": "360428", + "name": "都昌县" + }, + { + "code": "360429", + "name": "湖口县" + }, + { + "code": "360430", + "name": "彭泽县" + }, + { + "code": "360481", + "name": "瑞昌市" + }, + { + "code": "360482", + "name": "共青城市" + }, + { + "code": "360483", + "name": "庐山市" + } + ] + }, + { + "code": "3605", + "name": "新余市", + "children": [ + { + "code": "360502", + "name": "渝水区" + }, + { + "code": "360521", + "name": "分宜县" + } + ] + }, + { + "code": "3606", + "name": "鹰潭市", + "children": [ + { + "code": "360602", + "name": "月湖区" + }, + { + "code": "360603", + "name": "余江区" + }, + { + "code": "360681", + "name": "贵溪市" + } + ] + }, + { + "code": "3607", + "name": "赣州市", + "children": [ + { + "code": "360702", + "name": "章贡区" + }, + { + "code": "360703", + "name": "南康区" + }, + { + "code": "360704", + "name": "赣县区" + }, + { + "code": "360722", + "name": "信丰县" + }, + { + "code": "360723", + "name": "大余县" + }, + { + "code": "360724", + "name": "上犹县" + }, + { + "code": "360725", + "name": "崇义县" + }, + { + "code": "360726", + "name": "安远县" + }, + { + "code": "360728", + "name": "定南县" + }, + { + "code": "360729", + "name": "全南县" + }, + { + "code": "360730", + "name": "宁都县" + }, + { + "code": "360731", + "name": "于都县" + }, + { + "code": "360732", + "name": "兴国县" + }, + { + "code": "360733", + "name": "会昌县" + }, + { + "code": "360734", + "name": "寻乌县" + }, + { + "code": "360735", + "name": "石城县" + }, + { + "code": "360781", + "name": "瑞金市" + }, + { + "code": "360783", + "name": "龙南市" + } + ] + }, + { + "code": "3608", + "name": "吉安市", + "children": [ + { + "code": "360802", + "name": "吉州区" + }, + { + "code": "360803", + "name": "青原区" + }, + { + "code": "360821", + "name": "吉安县" + }, + { + "code": "360822", + "name": "吉水县" + }, + { + "code": "360823", + "name": "峡江县" + }, + { + "code": "360824", + "name": "新干县" + }, + { + "code": "360825", + "name": "永丰县" + }, + { + "code": "360826", + "name": "泰和县" + }, + { + "code": "360827", + "name": "遂川县" + }, + { + "code": "360828", + "name": "万安县" + }, + { + "code": "360829", + "name": "安福县" + }, + { + "code": "360830", + "name": "永新县" + }, + { + "code": "360881", + "name": "井冈山市" + } + ] + }, + { + "code": "3609", + "name": "宜春市", + "children": [ + { + "code": "360902", + "name": "袁州区" + }, + { + "code": "360921", + "name": "奉新县" + }, + { + "code": "360922", + "name": "万载县" + }, + { + "code": "360923", + "name": "上高县" + }, + { + "code": "360924", + "name": "宜丰县" + }, + { + "code": "360925", + "name": "靖安县" + }, + { + "code": "360926", + "name": "铜鼓县" + }, + { + "code": "360981", + "name": "丰城市" + }, + { + "code": "360982", + "name": "樟树市" + }, + { + "code": "360983", + "name": "高安市" + } + ] + }, + { + "code": "3610", + "name": "抚州市", + "children": [ + { + "code": "361002", + "name": "临川区" + }, + { + "code": "361003", + "name": "东乡区" + }, + { + "code": "361021", + "name": "南城县" + }, + { + "code": "361022", + "name": "黎川县" + }, + { + "code": "361023", + "name": "南丰县" + }, + { + "code": "361024", + "name": "崇仁县" + }, + { + "code": "361025", + "name": "乐安县" + }, + { + "code": "361026", + "name": "宜黄县" + }, + { + "code": "361027", + "name": "金溪县" + }, + { + "code": "361028", + "name": "资溪县" + }, + { + "code": "361030", + "name": "广昌县" + } + ] + }, + { + "code": "3611", + "name": "上饶市", + "children": [ + { + "code": "361102", + "name": "信州区" + }, + { + "code": "361103", + "name": "广丰区" + }, + { + "code": "361104", + "name": "广信区" + }, + { + "code": "361123", + "name": "玉山县" + }, + { + "code": "361124", + "name": "铅山县" + }, + { + "code": "361125", + "name": "横峰县" + }, + { + "code": "361126", + "name": "弋阳县" + }, + { + "code": "361127", + "name": "余干县" + }, + { + "code": "361128", + "name": "鄱阳县" + }, + { + "code": "361129", + "name": "万年县" + }, + { + "code": "361130", + "name": "婺源县" + }, + { + "code": "361181", + "name": "德兴市" + } + ] + } + ] + }, + { + "code": "37", + "name": "山东省", + "children": [ + { + "code": "3701", + "name": "济南市", + "children": [ + { + "code": "370102", + "name": "历下区" + }, + { + "code": "370103", + "name": "市中区" + }, + { + "code": "370104", + "name": "槐荫区" + }, + { + "code": "370105", + "name": "天桥区" + }, + { + "code": "370112", + "name": "历城区" + }, + { + "code": "370113", + "name": "长清区" + }, + { + "code": "370114", + "name": "章丘区" + }, + { + "code": "370115", + "name": "济阳区" + }, + { + "code": "370116", + "name": "莱芜区" + }, + { + "code": "370117", + "name": "钢城区" + }, + { + "code": "370124", + "name": "平阴县" + }, + { + "code": "370126", + "name": "商河县" + }, + { + "code": "370176", + "name": "济南高新技术产业开发区" + } + ] + }, + { + "code": "3702", + "name": "青岛市", + "children": [ + { + "code": "370202", + "name": "市南区" + }, + { + "code": "370203", + "name": "市北区" + }, + { + "code": "370211", + "name": "黄岛区" + }, + { + "code": "370212", + "name": "崂山区" + }, + { + "code": "370213", + "name": "李沧区" + }, + { + "code": "370214", + "name": "城阳区" + }, + { + "code": "370215", + "name": "即墨区" + }, + { + "code": "370281", + "name": "胶州市" + }, + { + "code": "370283", + "name": "平度市" + }, + { + "code": "370285", + "name": "莱西市" + } + ] + }, + { + "code": "3703", + "name": "淄博市", + "children": [ + { + "code": "370302", + "name": "淄川区" + }, + { + "code": "370303", + "name": "张店区" + }, + { + "code": "370304", + "name": "博山区" + }, + { + "code": "370305", + "name": "临淄区" + }, + { + "code": "370306", + "name": "周村区" + }, + { + "code": "370321", + "name": "桓台县" + }, + { + "code": "370322", + "name": "高青县" + }, + { + "code": "370323", + "name": "沂源县" + } + ] + }, + { + "code": "3704", + "name": "枣庄市", + "children": [ + { + "code": "370402", + "name": "市中区" + }, + { + "code": "370403", + "name": "薛城区" + }, + { + "code": "370404", + "name": "峄城区" + }, + { + "code": "370405", + "name": "台儿庄区" + }, + { + "code": "370406", + "name": "山亭区" + }, + { + "code": "370481", + "name": "滕州市" + } + ] + }, + { + "code": "3705", + "name": "东营市", + "children": [ + { + "code": "370502", + "name": "东营区" + }, + { + "code": "370503", + "name": "河口区" + }, + { + "code": "370505", + "name": "垦利区" + }, + { + "code": "370522", + "name": "利津县" + }, + { + "code": "370523", + "name": "广饶县" + }, + { + "code": "370571", + "name": "东营经济技术开发区" + }, + { + "code": "370572", + "name": "东营港经济开发区" + } + ] + }, + { + "code": "3706", + "name": "烟台市", + "children": [ + { + "code": "370602", + "name": "芝罘区" + }, + { + "code": "370611", + "name": "福山区" + }, + { + "code": "370612", + "name": "牟平区" + }, + { + "code": "370613", + "name": "莱山区" + }, + { + "code": "370614", + "name": "蓬莱区" + }, + { + "code": "370671", + "name": "烟台高新技术产业开发区" + }, + { + "code": "370676", + "name": "烟台经济技术开发区" + }, + { + "code": "370681", + "name": "龙口市" + }, + { + "code": "370682", + "name": "莱阳市" + }, + { + "code": "370683", + "name": "莱州市" + }, + { + "code": "370685", + "name": "招远市" + }, + { + "code": "370686", + "name": "栖霞市" + }, + { + "code": "370687", + "name": "海阳市" + } + ] + }, + { + "code": "3707", + "name": "潍坊市", + "children": [ + { + "code": "370702", + "name": "潍城区" + }, + { + "code": "370703", + "name": "寒亭区" + }, + { + "code": "370704", + "name": "坊子区" + }, + { + "code": "370705", + "name": "奎文区" + }, + { + "code": "370724", + "name": "临朐县" + }, + { + "code": "370725", + "name": "昌乐县" + }, + { + "code": "370772", + "name": "潍坊滨海经济技术开发区" + }, + { + "code": "370781", + "name": "青州市" + }, + { + "code": "370782", + "name": "诸城市" + }, + { + "code": "370783", + "name": "寿光市" + }, + { + "code": "370784", + "name": "安丘市" + }, + { + "code": "370785", + "name": "高密市" + }, + { + "code": "370786", + "name": "昌邑市" + } + ] + }, + { + "code": "3708", + "name": "济宁市", + "children": [ + { + "code": "370811", + "name": "任城区" + }, + { + "code": "370812", + "name": "兖州区" + }, + { + "code": "370826", + "name": "微山县" + }, + { + "code": "370827", + "name": "鱼台县" + }, + { + "code": "370828", + "name": "金乡县" + }, + { + "code": "370829", + "name": "嘉祥县" + }, + { + "code": "370830", + "name": "汶上县" + }, + { + "code": "370831", + "name": "泗水县" + }, + { + "code": "370832", + "name": "梁山县" + }, + { + "code": "370871", + "name": "济宁高新技术产业开发区" + }, + { + "code": "370881", + "name": "曲阜市" + }, + { + "code": "370883", + "name": "邹城市" + } + ] + }, + { + "code": "3709", + "name": "泰安市", + "children": [ + { + "code": "370902", + "name": "泰山区" + }, + { + "code": "370911", + "name": "岱岳区" + }, + { + "code": "370921", + "name": "宁阳县" + }, + { + "code": "370923", + "name": "东平县" + }, + { + "code": "370982", + "name": "新泰市" + }, + { + "code": "370983", + "name": "肥城市" + } + ] + }, + { + "code": "3710", + "name": "威海市", + "children": [ + { + "code": "371002", + "name": "环翠区" + }, + { + "code": "371003", + "name": "文登区" + }, + { + "code": "371071", + "name": "威海火炬高技术产业开发区" + }, + { + "code": "371072", + "name": "威海经济技术开发区" + }, + { + "code": "371073", + "name": "威海临港经济技术开发区" + }, + { + "code": "371082", + "name": "荣成市" + }, + { + "code": "371083", + "name": "乳山市" + } + ] + }, + { + "code": "3711", + "name": "日照市", + "children": [ + { + "code": "371102", + "name": "东港区" + }, + { + "code": "371103", + "name": "岚山区" + }, + { + "code": "371121", + "name": "五莲县" + }, + { + "code": "371122", + "name": "莒县" + }, + { + "code": "371171", + "name": "日照经济技术开发区" + } + ] + }, + { + "code": "3713", + "name": "临沂市", + "children": [ + { + "code": "371302", + "name": "兰山区" + }, + { + "code": "371311", + "name": "罗庄区" + }, + { + "code": "371312", + "name": "河东区" + }, + { + "code": "371321", + "name": "沂南县" + }, + { + "code": "371322", + "name": "郯城县" + }, + { + "code": "371323", + "name": "沂水县" + }, + { + "code": "371324", + "name": "兰陵县" + }, + { + "code": "371325", + "name": "费县" + }, + { + "code": "371326", + "name": "平邑县" + }, + { + "code": "371327", + "name": "莒南县" + }, + { + "code": "371328", + "name": "蒙阴县" + }, + { + "code": "371329", + "name": "临沭县" + }, + { + "code": "371371", + "name": "临沂高新技术产业开发区" + } + ] + }, + { + "code": "3714", + "name": "德州市", + "children": [ + { + "code": "371402", + "name": "德城区" + }, + { + "code": "371403", + "name": "陵城区" + }, + { + "code": "371422", + "name": "宁津县" + }, + { + "code": "371423", + "name": "庆云县" + }, + { + "code": "371424", + "name": "临邑县" + }, + { + "code": "371425", + "name": "齐河县" + }, + { + "code": "371426", + "name": "平原县" + }, + { + "code": "371427", + "name": "夏津县" + }, + { + "code": "371428", + "name": "武城县" + }, + { + "code": "371471", + "name": "德州天衢新区" + }, + { + "code": "371481", + "name": "乐陵市" + }, + { + "code": "371482", + "name": "禹城市" + } + ] + }, + { + "code": "3715", + "name": "聊城市", + "children": [ + { + "code": "371502", + "name": "东昌府区" + }, + { + "code": "371503", + "name": "茌平区" + }, + { + "code": "371521", + "name": "阳谷县" + }, + { + "code": "371522", + "name": "莘县" + }, + { + "code": "371524", + "name": "东阿县" + }, + { + "code": "371525", + "name": "冠县" + }, + { + "code": "371526", + "name": "高唐县" + }, + { + "code": "371581", + "name": "临清市" + } + ] + }, + { + "code": "3716", + "name": "滨州市", + "children": [ + { + "code": "371602", + "name": "滨城区" + }, + { + "code": "371603", + "name": "沾化区" + }, + { + "code": "371621", + "name": "惠民县" + }, + { + "code": "371622", + "name": "阳信县" + }, + { + "code": "371623", + "name": "无棣县" + }, + { + "code": "371625", + "name": "博兴县" + }, + { + "code": "371681", + "name": "邹平市" + } + ] + }, + { + "code": "3717", + "name": "菏泽市", + "children": [ + { + "code": "371702", + "name": "牡丹区" + }, + { + "code": "371703", + "name": "定陶区" + }, + { + "code": "371721", + "name": "曹县" + }, + { + "code": "371722", + "name": "单县" + }, + { + "code": "371723", + "name": "成武县" + }, + { + "code": "371724", + "name": "巨野县" + }, + { + "code": "371725", + "name": "郓城县" + }, + { + "code": "371726", + "name": "鄄城县" + }, + { + "code": "371728", + "name": "东明县" + }, + { + "code": "371771", + "name": "菏泽经济技术开发区" + }, + { + "code": "371772", + "name": "菏泽高新技术开发区" + } + ] + } + ] + }, + { + "code": "41", + "name": "河南省", + "children": [ + { + "code": "4101", + "name": "郑州市", + "children": [ + { + "code": "410102", + "name": "中原区" + }, + { + "code": "410103", + "name": "二七区" + }, + { + "code": "410104", + "name": "管城回族区" + }, + { + "code": "410105", + "name": "金水区" + }, + { + "code": "410106", + "name": "上街区" + }, + { + "code": "410108", + "name": "惠济区" + }, + { + "code": "410122", + "name": "中牟县" + }, + { + "code": "410171", + "name": "郑州经济技术开发区" + }, + { + "code": "410172", + "name": "郑州高新技术产业开发区" + }, + { + "code": "410173", + "name": "郑州航空港经济综合实验区" + }, + { + "code": "410181", + "name": "巩义市" + }, + { + "code": "410182", + "name": "荥阳市" + }, + { + "code": "410183", + "name": "新密市" + }, + { + "code": "410184", + "name": "新郑市" + }, + { + "code": "410185", + "name": "登封市" + } + ] + }, + { + "code": "4102", + "name": "开封市", + "children": [ + { + "code": "410202", + "name": "龙亭区" + }, + { + "code": "410203", + "name": "顺河回族区" + }, + { + "code": "410204", + "name": "鼓楼区" + }, + { + "code": "410205", + "name": "禹王台区" + }, + { + "code": "410212", + "name": "祥符区" + }, + { + "code": "410221", + "name": "杞县" + }, + { + "code": "410222", + "name": "通许县" + }, + { + "code": "410223", + "name": "尉氏县" + }, + { + "code": "410225", + "name": "兰考县" + } + ] + }, + { + "code": "4103", + "name": "洛阳市", + "children": [ + { + "code": "410302", + "name": "老城区" + }, + { + "code": "410303", + "name": "西工区" + }, + { + "code": "410304", + "name": "瀍河回族区" + }, + { + "code": "410305", + "name": "涧西区" + }, + { + "code": "410307", + "name": "偃师区" + }, + { + "code": "410308", + "name": "孟津区" + }, + { + "code": "410311", + "name": "洛龙区" + }, + { + "code": "410323", + "name": "新安县" + }, + { + "code": "410324", + "name": "栾川县" + }, + { + "code": "410325", + "name": "嵩县" + }, + { + "code": "410326", + "name": "汝阳县" + }, + { + "code": "410327", + "name": "宜阳县" + }, + { + "code": "410328", + "name": "洛宁县" + }, + { + "code": "410329", + "name": "伊川县" + }, + { + "code": "410371", + "name": "洛阳高新技术产业开发区" + } + ] + }, + { + "code": "4104", + "name": "平顶山市", + "children": [ + { + "code": "410402", + "name": "新华区" + }, + { + "code": "410403", + "name": "卫东区" + }, + { + "code": "410404", + "name": "石龙区" + }, + { + "code": "410411", + "name": "湛河区" + }, + { + "code": "410421", + "name": "宝丰县" + }, + { + "code": "410422", + "name": "叶县" + }, + { + "code": "410423", + "name": "鲁山县" + }, + { + "code": "410425", + "name": "郏县" + }, + { + "code": "410471", + "name": "平顶山高新技术产业开发区" + }, + { + "code": "410472", + "name": "平顶山市城乡一体化示范区" + }, + { + "code": "410481", + "name": "舞钢市" + }, + { + "code": "410482", + "name": "汝州市" + } + ] + }, + { + "code": "4105", + "name": "安阳市", + "children": [ + { + "code": "410502", + "name": "文峰区" + }, + { + "code": "410503", + "name": "北关区" + }, + { + "code": "410505", + "name": "殷都区" + }, + { + "code": "410506", + "name": "龙安区" + }, + { + "code": "410522", + "name": "安阳县" + }, + { + "code": "410523", + "name": "汤阴县" + }, + { + "code": "410526", + "name": "滑县" + }, + { + "code": "410527", + "name": "内黄县" + }, + { + "code": "410571", + "name": "安阳高新技术产业开发区" + }, + { + "code": "410581", + "name": "林州市" + } + ] + }, + { + "code": "4106", + "name": "鹤壁市", + "children": [ + { + "code": "410602", + "name": "鹤山区" + }, + { + "code": "410603", + "name": "山城区" + }, + { + "code": "410611", + "name": "淇滨区" + }, + { + "code": "410621", + "name": "浚县" + }, + { + "code": "410622", + "name": "淇县" + }, + { + "code": "410671", + "name": "鹤壁经济技术开发区" + } + ] + }, + { + "code": "4107", + "name": "新乡市", + "children": [ + { + "code": "410702", + "name": "红旗区" + }, + { + "code": "410703", + "name": "卫滨区" + }, + { + "code": "410704", + "name": "凤泉区" + }, + { + "code": "410711", + "name": "牧野区" + }, + { + "code": "410721", + "name": "新乡县" + }, + { + "code": "410724", + "name": "获嘉县" + }, + { + "code": "410725", + "name": "原阳县" + }, + { + "code": "410726", + "name": "延津县" + }, + { + "code": "410727", + "name": "封丘县" + }, + { + "code": "410771", + "name": "新乡高新技术产业开发区" + }, + { + "code": "410772", + "name": "新乡经济技术开发区" + }, + { + "code": "410773", + "name": "新乡市平原城乡一体化示范区" + }, + { + "code": "410781", + "name": "卫辉市" + }, + { + "code": "410782", + "name": "辉县市" + }, + { + "code": "410783", + "name": "长垣市" + } + ] + }, + { + "code": "4108", + "name": "焦作市", + "children": [ + { + "code": "410802", + "name": "解放区" + }, + { + "code": "410803", + "name": "中站区" + }, + { + "code": "410804", + "name": "马村区" + }, + { + "code": "410811", + "name": "山阳区" + }, + { + "code": "410821", + "name": "修武县" + }, + { + "code": "410822", + "name": "博爱县" + }, + { + "code": "410823", + "name": "武陟县" + }, + { + "code": "410825", + "name": "温县" + }, + { + "code": "410871", + "name": "焦作城乡一体化示范区" + }, + { + "code": "410882", + "name": "沁阳市" + }, + { + "code": "410883", + "name": "孟州市" + } + ] + }, + { + "code": "4109", + "name": "濮阳市", + "children": [ + { + "code": "410902", + "name": "华龙区" + }, + { + "code": "410922", + "name": "清丰县" + }, + { + "code": "410923", + "name": "南乐县" + }, + { + "code": "410926", + "name": "范县" + }, + { + "code": "410927", + "name": "台前县" + }, + { + "code": "410928", + "name": "濮阳县" + }, + { + "code": "410971", + "name": "河南濮阳工业园区" + }, + { + "code": "410972", + "name": "濮阳经济技术开发区" + } + ] + }, + { + "code": "4110", + "name": "许昌市", + "children": [ + { + "code": "411002", + "name": "魏都区" + }, + { + "code": "411003", + "name": "建安区" + }, + { + "code": "411024", + "name": "鄢陵县" + }, + { + "code": "411025", + "name": "襄城县" + }, + { + "code": "411071", + "name": "许昌经济技术开发区" + }, + { + "code": "411081", + "name": "禹州市" + }, + { + "code": "411082", + "name": "长葛市" + } + ] + }, + { + "code": "4111", + "name": "漯河市", + "children": [ + { + "code": "411102", + "name": "源汇区" + }, + { + "code": "411103", + "name": "郾城区" + }, + { + "code": "411104", + "name": "召陵区" + }, + { + "code": "411121", + "name": "舞阳县" + }, + { + "code": "411122", + "name": "临颍县" + }, + { + "code": "411171", + "name": "漯河经济技术开发区" + } + ] + }, + { + "code": "4112", + "name": "三门峡市", + "children": [ + { + "code": "411202", + "name": "湖滨区" + }, + { + "code": "411203", + "name": "陕州区" + }, + { + "code": "411221", + "name": "渑池县" + }, + { + "code": "411224", + "name": "卢氏县" + }, + { + "code": "411271", + "name": "河南三门峡经济开发区" + }, + { + "code": "411281", + "name": "义马市" + }, + { + "code": "411282", + "name": "灵宝市" + } + ] + }, + { + "code": "4113", + "name": "南阳市", + "children": [ + { + "code": "411302", + "name": "宛城区" + }, + { + "code": "411303", + "name": "卧龙区" + }, + { + "code": "411321", + "name": "南召县" + }, + { + "code": "411322", + "name": "方城县" + }, + { + "code": "411323", + "name": "西峡县" + }, + { + "code": "411324", + "name": "镇平县" + }, + { + "code": "411325", + "name": "内乡县" + }, + { + "code": "411326", + "name": "淅川县" + }, + { + "code": "411327", + "name": "社旗县" + }, + { + "code": "411328", + "name": "唐河县" + }, + { + "code": "411329", + "name": "新野县" + }, + { + "code": "411330", + "name": "桐柏县" + }, + { + "code": "411371", + "name": "南阳高新技术产业开发区" + }, + { + "code": "411372", + "name": "南阳市城乡一体化示范区" + }, + { + "code": "411381", + "name": "邓州市" + } + ] + }, + { + "code": "4114", + "name": "商丘市", + "children": [ + { + "code": "411402", + "name": "梁园区" + }, + { + "code": "411403", + "name": "睢阳区" + }, + { + "code": "411421", + "name": "民权县" + }, + { + "code": "411422", + "name": "睢县" + }, + { + "code": "411423", + "name": "宁陵县" + }, + { + "code": "411424", + "name": "柘城县" + }, + { + "code": "411425", + "name": "虞城县" + }, + { + "code": "411426", + "name": "夏邑县" + }, + { + "code": "411471", + "name": "豫东综合物流产业聚集区" + }, + { + "code": "411472", + "name": "河南商丘经济开发区" + }, + { + "code": "411481", + "name": "永城市" + } + ] + }, + { + "code": "4115", + "name": "信阳市", + "children": [ + { + "code": "411502", + "name": "浉河区" + }, + { + "code": "411503", + "name": "平桥区" + }, + { + "code": "411521", + "name": "罗山县" + }, + { + "code": "411522", + "name": "光山县" + }, + { + "code": "411523", + "name": "新县" + }, + { + "code": "411524", + "name": "商城县" + }, + { + "code": "411525", + "name": "固始县" + }, + { + "code": "411526", + "name": "潢川县" + }, + { + "code": "411527", + "name": "淮滨县" + }, + { + "code": "411528", + "name": "息县" + }, + { + "code": "411571", + "name": "信阳高新技术产业开发区" + } + ] + }, + { + "code": "4116", + "name": "周口市", + "children": [ + { + "code": "411602", + "name": "川汇区" + }, + { + "code": "411603", + "name": "淮阳区" + }, + { + "code": "411621", + "name": "扶沟县" + }, + { + "code": "411622", + "name": "西华县" + }, + { + "code": "411623", + "name": "商水县" + }, + { + "code": "411624", + "name": "沈丘县" + }, + { + "code": "411625", + "name": "郸城县" + }, + { + "code": "411627", + "name": "太康县" + }, + { + "code": "411628", + "name": "鹿邑县" + }, + { + "code": "411671", + "name": "周口临港开发区" + }, + { + "code": "411681", + "name": "项城市" + } + ] + }, + { + "code": "4117", + "name": "驻马店市", + "children": [ + { + "code": "411702", + "name": "驿城区" + }, + { + "code": "411721", + "name": "西平县" + }, + { + "code": "411722", + "name": "上蔡县" + }, + { + "code": "411723", + "name": "平舆县" + }, + { + "code": "411724", + "name": "正阳县" + }, + { + "code": "411725", + "name": "确山县" + }, + { + "code": "411726", + "name": "泌阳县" + }, + { + "code": "411727", + "name": "汝南县" + }, + { + "code": "411728", + "name": "遂平县" + }, + { + "code": "411729", + "name": "新蔡县" + }, + { + "code": "411771", + "name": "河南驻马店经济开发区" + } + ] + }, + { + "code": "4190", + "name": "省直辖县级行政区划", + "children": [ + { + "code": "419001", + "name": "济源市" + } + ] + } + ] + }, + { + "code": "42", + "name": "湖北省", + "children": [ + { + "code": "4201", + "name": "武汉市", + "children": [ + { + "code": "420102", + "name": "江岸区" + }, + { + "code": "420103", + "name": "江汉区" + }, + { + "code": "420104", + "name": "硚口区" + }, + { + "code": "420105", + "name": "汉阳区" + }, + { + "code": "420106", + "name": "武昌区" + }, + { + "code": "420107", + "name": "青山区" + }, + { + "code": "420111", + "name": "洪山区" + }, + { + "code": "420112", + "name": "东西湖区" + }, + { + "code": "420113", + "name": "汉南区" + }, + { + "code": "420114", + "name": "蔡甸区" + }, + { + "code": "420115", + "name": "江夏区" + }, + { + "code": "420116", + "name": "黄陂区" + }, + { + "code": "420117", + "name": "新洲区" + } + ] + }, + { + "code": "4202", + "name": "黄石市", + "children": [ + { + "code": "420202", + "name": "黄石港区" + }, + { + "code": "420203", + "name": "西塞山区" + }, + { + "code": "420204", + "name": "下陆区" + }, + { + "code": "420205", + "name": "铁山区" + }, + { + "code": "420222", + "name": "阳新县" + }, + { + "code": "420281", + "name": "大冶市" + } + ] + }, + { + "code": "4203", + "name": "十堰市", + "children": [ + { + "code": "420302", + "name": "茅箭区" + }, + { + "code": "420303", + "name": "张湾区" + }, + { + "code": "420304", + "name": "郧阳区" + }, + { + "code": "420322", + "name": "郧西县" + }, + { + "code": "420323", + "name": "竹山县" + }, + { + "code": "420324", + "name": "竹溪县" + }, + { + "code": "420325", + "name": "房县" + }, + { + "code": "420381", + "name": "丹江口市" + } + ] + }, + { + "code": "4205", + "name": "宜昌市", + "children": [ + { + "code": "420502", + "name": "西陵区" + }, + { + "code": "420503", + "name": "伍家岗区" + }, + { + "code": "420504", + "name": "点军区" + }, + { + "code": "420505", + "name": "猇亭区" + }, + { + "code": "420506", + "name": "夷陵区" + }, + { + "code": "420525", + "name": "远安县" + }, + { + "code": "420526", + "name": "兴山县" + }, + { + "code": "420527", + "name": "秭归县" + }, + { + "code": "420528", + "name": "长阳土家族自治县" + }, + { + "code": "420529", + "name": "五峰土家族自治县" + }, + { + "code": "420581", + "name": "宜都市" + }, + { + "code": "420582", + "name": "当阳市" + }, + { + "code": "420583", + "name": "枝江市" + } + ] + }, + { + "code": "4206", + "name": "襄阳市", + "children": [ + { + "code": "420602", + "name": "襄城区" + }, + { + "code": "420606", + "name": "樊城区" + }, + { + "code": "420607", + "name": "襄州区" + }, + { + "code": "420624", + "name": "南漳县" + }, + { + "code": "420625", + "name": "谷城县" + }, + { + "code": "420626", + "name": "保康县" + }, + { + "code": "420682", + "name": "老河口市" + }, + { + "code": "420683", + "name": "枣阳市" + }, + { + "code": "420684", + "name": "宜城市" + } + ] + }, + { + "code": "4207", + "name": "鄂州市", + "children": [ + { + "code": "420702", + "name": "梁子湖区" + }, + { + "code": "420703", + "name": "华容区" + }, + { + "code": "420704", + "name": "鄂城区" + } + ] + }, + { + "code": "4208", + "name": "荆门市", + "children": [ + { + "code": "420802", + "name": "东宝区" + }, + { + "code": "420804", + "name": "掇刀区" + }, + { + "code": "420822", + "name": "沙洋县" + }, + { + "code": "420881", + "name": "钟祥市" + }, + { + "code": "420882", + "name": "京山市" + } + ] + }, + { + "code": "4209", + "name": "孝感市", + "children": [ + { + "code": "420902", + "name": "孝南区" + }, + { + "code": "420921", + "name": "孝昌县" + }, + { + "code": "420922", + "name": "大悟县" + }, + { + "code": "420923", + "name": "云梦县" + }, + { + "code": "420981", + "name": "应城市" + }, + { + "code": "420982", + "name": "安陆市" + }, + { + "code": "420984", + "name": "汉川市" + } + ] + }, + { + "code": "4210", + "name": "荆州市", + "children": [ + { + "code": "421002", + "name": "沙市区" + }, + { + "code": "421003", + "name": "荆州区" + }, + { + "code": "421022", + "name": "公安县" + }, + { + "code": "421024", + "name": "江陵县" + }, + { + "code": "421071", + "name": "荆州经济技术开发区" + }, + { + "code": "421081", + "name": "石首市" + }, + { + "code": "421083", + "name": "洪湖市" + }, + { + "code": "421087", + "name": "松滋市" + }, + { + "code": "421088", + "name": "监利市" + } + ] + }, + { + "code": "4211", + "name": "黄冈市", + "children": [ + { + "code": "421102", + "name": "黄州区" + }, + { + "code": "421121", + "name": "团风县" + }, + { + "code": "421122", + "name": "红安县" + }, + { + "code": "421123", + "name": "罗田县" + }, + { + "code": "421124", + "name": "英山县" + }, + { + "code": "421125", + "name": "浠水县" + }, + { + "code": "421126", + "name": "蕲春县" + }, + { + "code": "421127", + "name": "黄梅县" + }, + { + "code": "421171", + "name": "龙感湖管理区" + }, + { + "code": "421181", + "name": "麻城市" + }, + { + "code": "421182", + "name": "武穴市" + } + ] + }, + { + "code": "4212", + "name": "咸宁市", + "children": [ + { + "code": "421202", + "name": "咸安区" + }, + { + "code": "421221", + "name": "嘉鱼县" + }, + { + "code": "421222", + "name": "通城县" + }, + { + "code": "421223", + "name": "崇阳县" + }, + { + "code": "421224", + "name": "通山县" + }, + { + "code": "421281", + "name": "赤壁市" + } + ] + }, + { + "code": "4213", + "name": "随州市", + "children": [ + { + "code": "421303", + "name": "曾都区" + }, + { + "code": "421321", + "name": "随县" + }, + { + "code": "421381", + "name": "广水市" + } + ] + }, + { + "code": "4228", + "name": "恩施土家族苗族自治州", + "children": [ + { + "code": "422801", + "name": "恩施市" + }, + { + "code": "422802", + "name": "利川市" + }, + { + "code": "422822", + "name": "建始县" + }, + { + "code": "422823", + "name": "巴东县" + }, + { + "code": "422825", + "name": "宣恩县" + }, + { + "code": "422826", + "name": "咸丰县" + }, + { + "code": "422827", + "name": "来凤县" + }, + { + "code": "422828", + "name": "鹤峰县" + } + ] + }, + { + "code": "4290", + "name": "省直辖县级行政区划", + "children": [ + { + "code": "429004", + "name": "仙桃市" + }, + { + "code": "429005", + "name": "潜江市" + }, + { + "code": "429006", + "name": "天门市" + }, + { + "code": "429021", + "name": "神农架林区" + } + ] + } + ] + }, + { + "code": "43", + "name": "湖南省", + "children": [ + { + "code": "4301", + "name": "长沙市", + "children": [ + { + "code": "430102", + "name": "芙蓉区" + }, + { + "code": "430103", + "name": "天心区" + }, + { + "code": "430104", + "name": "岳麓区" + }, + { + "code": "430105", + "name": "开福区" + }, + { + "code": "430111", + "name": "雨花区" + }, + { + "code": "430112", + "name": "望城区" + }, + { + "code": "430121", + "name": "长沙县" + }, + { + "code": "430181", + "name": "浏阳市" + }, + { + "code": "430182", + "name": "宁乡市" + } + ] + }, + { + "code": "4302", + "name": "株洲市", + "children": [ + { + "code": "430202", + "name": "荷塘区" + }, + { + "code": "430203", + "name": "芦淞区" + }, + { + "code": "430204", + "name": "石峰区" + }, + { + "code": "430211", + "name": "天元区" + }, + { + "code": "430212", + "name": "渌口区" + }, + { + "code": "430223", + "name": "攸县" + }, + { + "code": "430224", + "name": "茶陵县" + }, + { + "code": "430225", + "name": "炎陵县" + }, + { + "code": "430281", + "name": "醴陵市" + } + ] + }, + { + "code": "4303", + "name": "湘潭市", + "children": [ + { + "code": "430302", + "name": "雨湖区" + }, + { + "code": "430304", + "name": "岳塘区" + }, + { + "code": "430321", + "name": "湘潭县" + }, + { + "code": "430371", + "name": "湖南湘潭高新技术产业园区" + }, + { + "code": "430372", + "name": "湘潭昭山示范区" + }, + { + "code": "430373", + "name": "湘潭九华示范区" + }, + { + "code": "430381", + "name": "湘乡市" + }, + { + "code": "430382", + "name": "韶山市" + } + ] + }, + { + "code": "4304", + "name": "衡阳市", + "children": [ + { + "code": "430405", + "name": "珠晖区" + }, + { + "code": "430406", + "name": "雁峰区" + }, + { + "code": "430407", + "name": "石鼓区" + }, + { + "code": "430408", + "name": "蒸湘区" + }, + { + "code": "430412", + "name": "南岳区" + }, + { + "code": "430421", + "name": "衡阳县" + }, + { + "code": "430422", + "name": "衡南县" + }, + { + "code": "430423", + "name": "衡山县" + }, + { + "code": "430424", + "name": "衡东县" + }, + { + "code": "430426", + "name": "祁东县" + }, + { + "code": "430473", + "name": "湖南衡阳松木经济开发区" + }, + { + "code": "430476", + "name": "湖南衡阳高新技术产业园区" + }, + { + "code": "430481", + "name": "耒阳市" + }, + { + "code": "430482", + "name": "常宁市" + } + ] + }, + { + "code": "4305", + "name": "邵阳市", + "children": [ + { + "code": "430502", + "name": "双清区" + }, + { + "code": "430503", + "name": "大祥区" + }, + { + "code": "430511", + "name": "北塔区" + }, + { + "code": "430522", + "name": "新邵县" + }, + { + "code": "430523", + "name": "邵阳县" + }, + { + "code": "430524", + "name": "隆回县" + }, + { + "code": "430525", + "name": "洞口县" + }, + { + "code": "430527", + "name": "绥宁县" + }, + { + "code": "430528", + "name": "新宁县" + }, + { + "code": "430529", + "name": "城步苗族自治县" + }, + { + "code": "430581", + "name": "武冈市" + }, + { + "code": "430582", + "name": "邵东市" + } + ] + }, + { + "code": "4306", + "name": "岳阳市", + "children": [ + { + "code": "430602", + "name": "岳阳楼区" + }, + { + "code": "430603", + "name": "云溪区" + }, + { + "code": "430611", + "name": "君山区" + }, + { + "code": "430621", + "name": "岳阳县" + }, + { + "code": "430623", + "name": "华容县" + }, + { + "code": "430624", + "name": "湘阴县" + }, + { + "code": "430626", + "name": "平江县" + }, + { + "code": "430671", + "name": "岳阳市屈原管理区" + }, + { + "code": "430681", + "name": "汨罗市" + }, + { + "code": "430682", + "name": "临湘市" + } + ] + }, + { + "code": "4307", + "name": "常德市", + "children": [ + { + "code": "430702", + "name": "武陵区" + }, + { + "code": "430703", + "name": "鼎城区" + }, + { + "code": "430721", + "name": "安乡县" + }, + { + "code": "430722", + "name": "汉寿县" + }, + { + "code": "430723", + "name": "澧县" + }, + { + "code": "430724", + "name": "临澧县" + }, + { + "code": "430725", + "name": "桃源县" + }, + { + "code": "430726", + "name": "石门县" + }, + { + "code": "430771", + "name": "常德市西洞庭管理区" + }, + { + "code": "430781", + "name": "津市市" + } + ] + }, + { + "code": "4308", + "name": "张家界市", + "children": [ + { + "code": "430802", + "name": "永定区" + }, + { + "code": "430811", + "name": "武陵源区" + }, + { + "code": "430821", + "name": "慈利县" + }, + { + "code": "430822", + "name": "桑植县" + } + ] + }, + { + "code": "4309", + "name": "益阳市", + "children": [ + { + "code": "430902", + "name": "资阳区" + }, + { + "code": "430903", + "name": "赫山区" + }, + { + "code": "430921", + "name": "南县" + }, + { + "code": "430922", + "name": "桃江县" + }, + { + "code": "430923", + "name": "安化县" + }, + { + "code": "430971", + "name": "益阳市大通湖管理区" + }, + { + "code": "430972", + "name": "湖南益阳高新技术产业园区" + }, + { + "code": "430981", + "name": "沅江市" + } + ] + }, + { + "code": "4310", + "name": "郴州市", + "children": [ + { + "code": "431002", + "name": "北湖区" + }, + { + "code": "431003", + "name": "苏仙区" + }, + { + "code": "431021", + "name": "桂阳县" + }, + { + "code": "431022", + "name": "宜章县" + }, + { + "code": "431023", + "name": "永兴县" + }, + { + "code": "431024", + "name": "嘉禾县" + }, + { + "code": "431025", + "name": "临武县" + }, + { + "code": "431026", + "name": "汝城县" + }, + { + "code": "431027", + "name": "桂东县" + }, + { + "code": "431028", + "name": "安仁县" + }, + { + "code": "431081", + "name": "资兴市" + } + ] + }, + { + "code": "4311", + "name": "永州市", + "children": [ + { + "code": "431102", + "name": "零陵区" + }, + { + "code": "431103", + "name": "冷水滩区" + }, + { + "code": "431122", + "name": "东安县" + }, + { + "code": "431123", + "name": "双牌县" + }, + { + "code": "431124", + "name": "道县" + }, + { + "code": "431125", + "name": "江永县" + }, + { + "code": "431126", + "name": "宁远县" + }, + { + "code": "431127", + "name": "蓝山县" + }, + { + "code": "431128", + "name": "新田县" + }, + { + "code": "431129", + "name": "江华瑶族自治县" + }, + { + "code": "431171", + "name": "永州经济技术开发区" + }, + { + "code": "431173", + "name": "永州市回龙圩管理区" + }, + { + "code": "431181", + "name": "祁阳市" + } + ] + }, + { + "code": "4312", + "name": "怀化市", + "children": [ + { + "code": "431202", + "name": "鹤城区" + }, + { + "code": "431221", + "name": "中方县" + }, + { + "code": "431222", + "name": "沅陵县" + }, + { + "code": "431223", + "name": "辰溪县" + }, + { + "code": "431224", + "name": "溆浦县" + }, + { + "code": "431225", + "name": "会同县" + }, + { + "code": "431226", + "name": "麻阳苗族自治县" + }, + { + "code": "431227", + "name": "新晃侗族自治县" + }, + { + "code": "431228", + "name": "芷江侗族自治县" + }, + { + "code": "431229", + "name": "靖州苗族侗族自治县" + }, + { + "code": "431230", + "name": "通道侗族自治县" + }, + { + "code": "431271", + "name": "怀化市洪江管理区" + }, + { + "code": "431281", + "name": "洪江市" + } + ] + }, + { + "code": "4313", + "name": "娄底市", + "children": [ + { + "code": "431302", + "name": "娄星区" + }, + { + "code": "431321", + "name": "双峰县" + }, + { + "code": "431322", + "name": "新化县" + }, + { + "code": "431381", + "name": "冷水江市" + }, + { + "code": "431382", + "name": "涟源市" + } + ] + }, + { + "code": "4331", + "name": "湘西土家族苗族自治州", + "children": [ + { + "code": "433101", + "name": "吉首市" + }, + { + "code": "433122", + "name": "泸溪县" + }, + { + "code": "433123", + "name": "凤凰县" + }, + { + "code": "433124", + "name": "花垣县" + }, + { + "code": "433125", + "name": "保靖县" + }, + { + "code": "433126", + "name": "古丈县" + }, + { + "code": "433127", + "name": "永顺县" + }, + { + "code": "433130", + "name": "龙山县" + } + ] + } + ] + }, + { + "code": "44", + "name": "广东省", + "children": [ + { + "code": "4401", + "name": "广州市", + "children": [ + { + "code": "440103", + "name": "荔湾区" + }, + { + "code": "440104", + "name": "越秀区" + }, + { + "code": "440105", + "name": "海珠区" + }, + { + "code": "440106", + "name": "天河区" + }, + { + "code": "440111", + "name": "白云区" + }, + { + "code": "440112", + "name": "黄埔区" + }, + { + "code": "440113", + "name": "番禺区" + }, + { + "code": "440114", + "name": "花都区" + }, + { + "code": "440115", + "name": "南沙区" + }, + { + "code": "440117", + "name": "从化区" + }, + { + "code": "440118", + "name": "增城区" + } + ] + }, + { + "code": "4402", + "name": "韶关市", + "children": [ + { + "code": "440203", + "name": "武江区" + }, + { + "code": "440204", + "name": "浈江区" + }, + { + "code": "440205", + "name": "曲江区" + }, + { + "code": "440222", + "name": "始兴县" + }, + { + "code": "440224", + "name": "仁化县" + }, + { + "code": "440229", + "name": "翁源县" + }, + { + "code": "440232", + "name": "乳源瑶族自治县" + }, + { + "code": "440233", + "name": "新丰县" + }, + { + "code": "440281", + "name": "乐昌市" + }, + { + "code": "440282", + "name": "南雄市" + } + ] + }, + { + "code": "4403", + "name": "深圳市", + "children": [ + { + "code": "440303", + "name": "罗湖区" + }, + { + "code": "440304", + "name": "福田区" + }, + { + "code": "440305", + "name": "南山区" + }, + { + "code": "440306", + "name": "宝安区" + }, + { + "code": "440307", + "name": "龙岗区" + }, + { + "code": "440308", + "name": "盐田区" + }, + { + "code": "440309", + "name": "龙华区" + }, + { + "code": "440310", + "name": "坪山区" + }, + { + "code": "440311", + "name": "光明区" + } + ] + }, + { + "code": "4404", + "name": "珠海市", + "children": [ + { + "code": "440402", + "name": "香洲区" + }, + { + "code": "440403", + "name": "斗门区" + }, + { + "code": "440404", + "name": "金湾区" + } + ] + }, + { + "code": "4405", + "name": "汕头市", + "children": [ + { + "code": "440507", + "name": "龙湖区" + }, + { + "code": "440511", + "name": "金平区" + }, + { + "code": "440512", + "name": "濠江区" + }, + { + "code": "440513", + "name": "潮阳区" + }, + { + "code": "440514", + "name": "潮南区" + }, + { + "code": "440515", + "name": "澄海区" + }, + { + "code": "440523", + "name": "南澳县" + } + ] + }, + { + "code": "4406", + "name": "佛山市", + "children": [ + { + "code": "440604", + "name": "禅城区" + }, + { + "code": "440605", + "name": "南海区" + }, + { + "code": "440606", + "name": "顺德区" + }, + { + "code": "440607", + "name": "三水区" + }, + { + "code": "440608", + "name": "高明区" + } + ] + }, + { + "code": "4407", + "name": "江门市", + "children": [ + { + "code": "440703", + "name": "蓬江区" + }, + { + "code": "440704", + "name": "江海区" + }, + { + "code": "440705", + "name": "新会区" + }, + { + "code": "440781", + "name": "台山市" + }, + { + "code": "440783", + "name": "开平市" + }, + { + "code": "440784", + "name": "鹤山市" + }, + { + "code": "440785", + "name": "恩平市" + } + ] + }, + { + "code": "4408", + "name": "湛江市", + "children": [ + { + "code": "440802", + "name": "赤坎区" + }, + { + "code": "440803", + "name": "霞山区" + }, + { + "code": "440804", + "name": "坡头区" + }, + { + "code": "440811", + "name": "麻章区" + }, + { + "code": "440823", + "name": "遂溪县" + }, + { + "code": "440825", + "name": "徐闻县" + }, + { + "code": "440881", + "name": "廉江市" + }, + { + "code": "440882", + "name": "雷州市" + }, + { + "code": "440883", + "name": "吴川市" + } + ] + }, + { + "code": "4409", + "name": "茂名市", + "children": [ + { + "code": "440902", + "name": "茂南区" + }, + { + "code": "440904", + "name": "电白区" + }, + { + "code": "440981", + "name": "高州市" + }, + { + "code": "440982", + "name": "化州市" + }, + { + "code": "440983", + "name": "信宜市" + } + ] + }, + { + "code": "4412", + "name": "肇庆市", + "children": [ + { + "code": "441202", + "name": "端州区" + }, + { + "code": "441203", + "name": "鼎湖区" + }, + { + "code": "441204", + "name": "高要区" + }, + { + "code": "441223", + "name": "广宁县" + }, + { + "code": "441224", + "name": "怀集县" + }, + { + "code": "441225", + "name": "封开县" + }, + { + "code": "441226", + "name": "德庆县" + }, + { + "code": "441284", + "name": "四会市" + } + ] + }, + { + "code": "4413", + "name": "惠州市", + "children": [ + { + "code": "441302", + "name": "惠城区" + }, + { + "code": "441303", + "name": "惠阳区" + }, + { + "code": "441322", + "name": "博罗县" + }, + { + "code": "441323", + "name": "惠东县" + }, + { + "code": "441324", + "name": "龙门县" + } + ] + }, + { + "code": "4414", + "name": "梅州市", + "children": [ + { + "code": "441402", + "name": "梅江区" + }, + { + "code": "441403", + "name": "梅县区" + }, + { + "code": "441422", + "name": "大埔县" + }, + { + "code": "441423", + "name": "丰顺县" + }, + { + "code": "441424", + "name": "五华县" + }, + { + "code": "441426", + "name": "平远县" + }, + { + "code": "441427", + "name": "蕉岭县" + }, + { + "code": "441481", + "name": "兴宁市" + } + ] + }, + { + "code": "4415", + "name": "汕尾市", + "children": [ + { + "code": "441502", + "name": "城区" + }, + { + "code": "441521", + "name": "海丰县" + }, + { + "code": "441523", + "name": "陆河县" + }, + { + "code": "441581", + "name": "陆丰市" + } + ] + }, + { + "code": "4416", + "name": "河源市", + "children": [ + { + "code": "441602", + "name": "源城区" + }, + { + "code": "441621", + "name": "紫金县" + }, + { + "code": "441622", + "name": "龙川县" + }, + { + "code": "441623", + "name": "连平县" + }, + { + "code": "441624", + "name": "和平县" + }, + { + "code": "441625", + "name": "东源县" + } + ] + }, + { + "code": "4417", + "name": "阳江市", + "children": [ + { + "code": "441702", + "name": "江城区" + }, + { + "code": "441704", + "name": "阳东区" + }, + { + "code": "441721", + "name": "阳西县" + }, + { + "code": "441781", + "name": "阳春市" + } + ] + }, + { + "code": "4418", + "name": "清远市", + "children": [ + { + "code": "441802", + "name": "清城区" + }, + { + "code": "441803", + "name": "清新区" + }, + { + "code": "441821", + "name": "佛冈县" + }, + { + "code": "441823", + "name": "阳山县" + }, + { + "code": "441825", + "name": "连山壮族瑶族自治县" + }, + { + "code": "441826", + "name": "连南瑶族自治县" + }, + { + "code": "441881", + "name": "英德市" + }, + { + "code": "441882", + "name": "连州市" + } + ] + }, + { + "code": "4419", + "name": "东莞市", + "children": [ + { + "code": "441900003", + "name": "东城街道" + }, + { + "code": "441900004", + "name": "南城街道" + }, + { + "code": "441900005", + "name": "万江街道" + }, + { + "code": "441900006", + "name": "莞城街道" + }, + { + "code": "441900101", + "name": "石碣镇" + }, + { + "code": "441900102", + "name": "石龙镇" + }, + { + "code": "441900103", + "name": "茶山镇" + }, + { + "code": "441900104", + "name": "石排镇" + }, + { + "code": "441900105", + "name": "企石镇" + }, + { + "code": "441900106", + "name": "横沥镇" + }, + { + "code": "441900107", + "name": "桥头镇" + }, + { + "code": "441900108", + "name": "谢岗镇" + }, + { + "code": "441900109", + "name": "东坑镇" + }, + { + "code": "441900110", + "name": "常平镇" + }, + { + "code": "441900111", + "name": "寮步镇" + }, + { + "code": "441900112", + "name": "樟木头镇" + }, + { + "code": "441900113", + "name": "大朗镇" + }, + { + "code": "441900114", + "name": "黄江镇" + }, + { + "code": "441900115", + "name": "清溪镇" + }, + { + "code": "441900116", + "name": "塘厦镇" + }, + { + "code": "441900117", + "name": "凤岗镇" + }, + { + "code": "441900118", + "name": "大岭山镇" + }, + { + "code": "441900119", + "name": "长安镇" + }, + { + "code": "441900121", + "name": "虎门镇" + }, + { + "code": "441900122", + "name": "厚街镇" + }, + { + "code": "441900123", + "name": "沙田镇" + }, + { + "code": "441900124", + "name": "道滘镇" + }, + { + "code": "441900125", + "name": "洪梅镇" + }, + { + "code": "441900126", + "name": "麻涌镇" + }, + { + "code": "441900127", + "name": "望牛墩镇" + }, + { + "code": "441900128", + "name": "中堂镇" + }, + { + "code": "441900129", + "name": "高埗镇" + }, + { + "code": "441900401", + "name": "松山湖" + }, + { + "code": "441900402", + "name": "东莞港" + }, + { + "code": "441900403", + "name": "东莞生态园" + }, + { + "code": "441900404", + "name": "东莞滨海湾新区" + } + ] + }, + { + "code": "4420", + "name": "中山市", + "children": [ + { + "code": "442000001", + "name": "石岐街道" + }, + { + "code": "442000002", + "name": "东区街道" + }, + { + "code": "442000003", + "name": "中山港街道" + }, + { + "code": "442000004", + "name": "西区街道" + }, + { + "code": "442000005", + "name": "南区街道" + }, + { + "code": "442000006", + "name": "五桂山街道" + }, + { + "code": "442000007", + "name": "民众街道" + }, + { + "code": "442000008", + "name": "南朗街道" + }, + { + "code": "442000101", + "name": "黄圃镇" + }, + { + "code": "442000103", + "name": "东凤镇" + }, + { + "code": "442000105", + "name": "古镇镇" + }, + { + "code": "442000106", + "name": "沙溪镇" + }, + { + "code": "442000107", + "name": "坦洲镇" + }, + { + "code": "442000108", + "name": "港口镇" + }, + { + "code": "442000109", + "name": "三角镇" + }, + { + "code": "442000110", + "name": "横栏镇" + }, + { + "code": "442000111", + "name": "南头镇" + }, + { + "code": "442000112", + "name": "阜沙镇" + }, + { + "code": "442000114", + "name": "三乡镇" + }, + { + "code": "442000115", + "name": "板芙镇" + }, + { + "code": "442000116", + "name": "大涌镇" + }, + { + "code": "442000117", + "name": "神湾镇" + }, + { + "code": "442000118", + "name": "小榄镇" + } + ] + }, + { + "code": "4451", + "name": "潮州市", + "children": [ + { + "code": "445102", + "name": "湘桥区" + }, + { + "code": "445103", + "name": "潮安区" + }, + { + "code": "445122", + "name": "饶平县" + } + ] + }, + { + "code": "4452", + "name": "揭阳市", + "children": [ + { + "code": "445202", + "name": "榕城区" + }, + { + "code": "445203", + "name": "揭东区" + }, + { + "code": "445222", + "name": "揭西县" + }, + { + "code": "445224", + "name": "惠来县" + }, + { + "code": "445281", + "name": "普宁市" + } + ] + }, + { + "code": "4453", + "name": "云浮市", + "children": [ + { + "code": "445302", + "name": "云城区" + }, + { + "code": "445303", + "name": "云安区" + }, + { + "code": "445321", + "name": "新兴县" + }, + { + "code": "445322", + "name": "郁南县" + }, + { + "code": "445381", + "name": "罗定市" + } + ] + } + ] + }, + { + "code": "45", + "name": "广西壮族自治区", + "children": [ + { + "code": "4501", + "name": "南宁市", + "children": [ + { + "code": "450102", + "name": "兴宁区" + }, + { + "code": "450103", + "name": "青秀区" + }, + { + "code": "450105", + "name": "江南区" + }, + { + "code": "450107", + "name": "西乡塘区" + }, + { + "code": "450108", + "name": "良庆区" + }, + { + "code": "450109", + "name": "邕宁区" + }, + { + "code": "450110", + "name": "武鸣区" + }, + { + "code": "450123", + "name": "隆安县" + }, + { + "code": "450124", + "name": "马山县" + }, + { + "code": "450125", + "name": "上林县" + }, + { + "code": "450126", + "name": "宾阳县" + }, + { + "code": "450181", + "name": "横州市" + } + ] + }, + { + "code": "4502", + "name": "柳州市", + "children": [ + { + "code": "450202", + "name": "城中区" + }, + { + "code": "450203", + "name": "鱼峰区" + }, + { + "code": "450204", + "name": "柳南区" + }, + { + "code": "450205", + "name": "柳北区" + }, + { + "code": "450206", + "name": "柳江区" + }, + { + "code": "450222", + "name": "柳城县" + }, + { + "code": "450223", + "name": "鹿寨县" + }, + { + "code": "450224", + "name": "融安县" + }, + { + "code": "450225", + "name": "融水苗族自治县" + }, + { + "code": "450226", + "name": "三江侗族自治县" + } + ] + }, + { + "code": "4503", + "name": "桂林市", + "children": [ + { + "code": "450302", + "name": "秀峰区" + }, + { + "code": "450303", + "name": "叠彩区" + }, + { + "code": "450304", + "name": "象山区" + }, + { + "code": "450305", + "name": "七星区" + }, + { + "code": "450311", + "name": "雁山区" + }, + { + "code": "450312", + "name": "临桂区" + }, + { + "code": "450321", + "name": "阳朔县" + }, + { + "code": "450323", + "name": "灵川县" + }, + { + "code": "450324", + "name": "全州县" + }, + { + "code": "450325", + "name": "兴安县" + }, + { + "code": "450326", + "name": "永福县" + }, + { + "code": "450327", + "name": "灌阳县" + }, + { + "code": "450328", + "name": "龙胜各族自治县" + }, + { + "code": "450329", + "name": "资源县" + }, + { + "code": "450330", + "name": "平乐县" + }, + { + "code": "450332", + "name": "恭城瑶族自治县" + }, + { + "code": "450381", + "name": "荔浦市" + } + ] + }, + { + "code": "4504", + "name": "梧州市", + "children": [ + { + "code": "450403", + "name": "万秀区" + }, + { + "code": "450405", + "name": "长洲区" + }, + { + "code": "450406", + "name": "龙圩区" + }, + { + "code": "450421", + "name": "苍梧县" + }, + { + "code": "450422", + "name": "藤县" + }, + { + "code": "450423", + "name": "蒙山县" + }, + { + "code": "450481", + "name": "岑溪市" + } + ] + }, + { + "code": "4505", + "name": "北海市", + "children": [ + { + "code": "450502", + "name": "海城区" + }, + { + "code": "450503", + "name": "银海区" + }, + { + "code": "450512", + "name": "铁山港区" + }, + { + "code": "450521", + "name": "合浦县" + } + ] + }, + { + "code": "4506", + "name": "防城港市", + "children": [ + { + "code": "450602", + "name": "港口区" + }, + { + "code": "450603", + "name": "防城区" + }, + { + "code": "450621", + "name": "上思县" + }, + { + "code": "450681", + "name": "东兴市" + } + ] + }, + { + "code": "4507", + "name": "钦州市", + "children": [ + { + "code": "450702", + "name": "钦南区" + }, + { + "code": "450703", + "name": "钦北区" + }, + { + "code": "450721", + "name": "灵山县" + }, + { + "code": "450722", + "name": "浦北县" + } + ] + }, + { + "code": "4508", + "name": "贵港市", + "children": [ + { + "code": "450802", + "name": "港北区" + }, + { + "code": "450803", + "name": "港南区" + }, + { + "code": "450804", + "name": "覃塘区" + }, + { + "code": "450821", + "name": "平南县" + }, + { + "code": "450881", + "name": "桂平市" + } + ] + }, + { + "code": "4509", + "name": "玉林市", + "children": [ + { + "code": "450902", + "name": "玉州区" + }, + { + "code": "450903", + "name": "福绵区" + }, + { + "code": "450921", + "name": "容县" + }, + { + "code": "450922", + "name": "陆川县" + }, + { + "code": "450923", + "name": "博白县" + }, + { + "code": "450924", + "name": "兴业县" + }, + { + "code": "450981", + "name": "北流市" + } + ] + }, + { + "code": "4510", + "name": "百色市", + "children": [ + { + "code": "451002", + "name": "右江区" + }, + { + "code": "451003", + "name": "田阳区" + }, + { + "code": "451022", + "name": "田东县" + }, + { + "code": "451024", + "name": "德保县" + }, + { + "code": "451026", + "name": "那坡县" + }, + { + "code": "451027", + "name": "凌云县" + }, + { + "code": "451028", + "name": "乐业县" + }, + { + "code": "451029", + "name": "田林县" + }, + { + "code": "451030", + "name": "西林县" + }, + { + "code": "451031", + "name": "隆林各族自治县" + }, + { + "code": "451081", + "name": "靖西市" + }, + { + "code": "451082", + "name": "平果市" + } + ] + }, + { + "code": "4511", + "name": "贺州市", + "children": [ + { + "code": "451102", + "name": "八步区" + }, + { + "code": "451103", + "name": "平桂区" + }, + { + "code": "451121", + "name": "昭平县" + }, + { + "code": "451122", + "name": "钟山县" + }, + { + "code": "451123", + "name": "富川瑶族自治县" + } + ] + }, + { + "code": "4512", + "name": "河池市", + "children": [ + { + "code": "451202", + "name": "金城江区" + }, + { + "code": "451203", + "name": "宜州区" + }, + { + "code": "451221", + "name": "南丹县" + }, + { + "code": "451222", + "name": "天峨县" + }, + { + "code": "451223", + "name": "凤山县" + }, + { + "code": "451224", + "name": "东兰县" + }, + { + "code": "451225", + "name": "罗城仫佬族自治县" + }, + { + "code": "451226", + "name": "环江毛南族自治县" + }, + { + "code": "451227", + "name": "巴马瑶族自治县" + }, + { + "code": "451228", + "name": "都安瑶族自治县" + }, + { + "code": "451229", + "name": "大化瑶族自治县" + } + ] + }, + { + "code": "4513", + "name": "来宾市", + "children": [ + { + "code": "451302", + "name": "兴宾区" + }, + { + "code": "451321", + "name": "忻城县" + }, + { + "code": "451322", + "name": "象州县" + }, + { + "code": "451323", + "name": "武宣县" + }, + { + "code": "451324", + "name": "金秀瑶族自治县" + }, + { + "code": "451381", + "name": "合山市" + } + ] + }, + { + "code": "4514", + "name": "崇左市", + "children": [ + { + "code": "451402", + "name": "江州区" + }, + { + "code": "451421", + "name": "扶绥县" + }, + { + "code": "451422", + "name": "宁明县" + }, + { + "code": "451423", + "name": "龙州县" + }, + { + "code": "451424", + "name": "大新县" + }, + { + "code": "451425", + "name": "天等县" + }, + { + "code": "451481", + "name": "凭祥市" + } + ] + } + ] + }, + { + "code": "46", + "name": "海南省", + "children": [ + { + "code": "4601", + "name": "海口市", + "children": [ + { + "code": "460105", + "name": "秀英区" + }, + { + "code": "460106", + "name": "龙华区" + }, + { + "code": "460107", + "name": "琼山区" + }, + { + "code": "460108", + "name": "美兰区" + } + ] + }, + { + "code": "4602", + "name": "三亚市", + "children": [ + { + "code": "460202", + "name": "海棠区" + }, + { + "code": "460203", + "name": "吉阳区" + }, + { + "code": "460204", + "name": "天涯区" + }, + { + "code": "460205", + "name": "崖州区" + } + ] + }, + { + "code": "4603", + "name": "三沙市", + "children": [ + { + "code": "460321", + "name": "西沙群岛" + }, + { + "code": "460322", + "name": "南沙群岛" + }, + { + "code": "460323", + "name": "中沙群岛的岛礁及其海域" + } + ] + }, + { + "code": "4604", + "name": "儋州市", + "children": [ + { + "code": "460400100", + "name": "那大镇" + }, + { + "code": "460400101", + "name": "和庆镇" + }, + { + "code": "460400102", + "name": "南丰镇" + }, + { + "code": "460400103", + "name": "大成镇" + }, + { + "code": "460400104", + "name": "雅星镇" + }, + { + "code": "460400105", + "name": "兰洋镇" + }, + { + "code": "460400106", + "name": "光村镇" + }, + { + "code": "460400107", + "name": "木棠镇" + }, + { + "code": "460400108", + "name": "海头镇" + }, + { + "code": "460400109", + "name": "峨蔓镇" + }, + { + "code": "460400111", + "name": "王五镇" + }, + { + "code": "460400112", + "name": "白马井镇" + }, + { + "code": "460400113", + "name": "中和镇" + }, + { + "code": "460400114", + "name": "排浦镇" + }, + { + "code": "460400115", + "name": "东成镇" + }, + { + "code": "460400116", + "name": "新州镇" + }, + { + "code": "460400499", + "name": "洋浦经济开发区" + }, + { + "code": "460400500", + "name": "华南热作学院" + } + ] + }, + { + "code": "4690", + "name": "省直辖县级行政区划", + "children": [ + { + "code": "469001", + "name": "五指山市" + }, + { + "code": "469002", + "name": "琼海市" + }, + { + "code": "469005", + "name": "文昌市" + }, + { + "code": "469006", + "name": "万宁市" + }, + { + "code": "469007", + "name": "东方市" + }, + { + "code": "469021", + "name": "定安县" + }, + { + "code": "469022", + "name": "屯昌县" + }, + { + "code": "469023", + "name": "澄迈县" + }, + { + "code": "469024", + "name": "临高县" + }, + { + "code": "469025", + "name": "白沙黎族自治县" + }, + { + "code": "469026", + "name": "昌江黎族自治县" + }, + { + "code": "469027", + "name": "乐东黎族自治县" + }, + { + "code": "469028", + "name": "陵水黎族自治县" + }, + { + "code": "469029", + "name": "保亭黎族苗族自治县" + }, + { + "code": "469030", + "name": "琼中黎族苗族自治县" + } + ] + } + ] + }, + { + "code": "50", + "name": "重庆市", + "children": [ + { + "code": "5001", + "name": "市辖区", + "children": [ + { + "code": "500101", + "name": "万州区" + }, + { + "code": "500102", + "name": "涪陵区" + }, + { + "code": "500103", + "name": "渝中区" + }, + { + "code": "500104", + "name": "大渡口区" + }, + { + "code": "500105", + "name": "江北区" + }, + { + "code": "500106", + "name": "沙坪坝区" + }, + { + "code": "500107", + "name": "九龙坡区" + }, + { + "code": "500108", + "name": "南岸区" + }, + { + "code": "500109", + "name": "北碚区" + }, + { + "code": "500110", + "name": "綦江区" + }, + { + "code": "500111", + "name": "大足区" + }, + { + "code": "500112", + "name": "渝北区" + }, + { + "code": "500113", + "name": "巴南区" + }, + { + "code": "500114", + "name": "黔江区" + }, + { + "code": "500115", + "name": "长寿区" + }, + { + "code": "500116", + "name": "江津区" + }, + { + "code": "500117", + "name": "合川区" + }, + { + "code": "500118", + "name": "永川区" + }, + { + "code": "500119", + "name": "南川区" + }, + { + "code": "500120", + "name": "璧山区" + }, + { + "code": "500151", + "name": "铜梁区" + }, + { + "code": "500152", + "name": "潼南区" + }, + { + "code": "500153", + "name": "荣昌区" + }, + { + "code": "500154", + "name": "开州区" + }, + { + "code": "500155", + "name": "梁平区" + }, + { + "code": "500156", + "name": "武隆区" + }, + { + "code": "500157", + "name": "两江新区" + } + ] + }, + { + "code": "5002", + "name": "县", + "children": [ + { + "code": "500229", + "name": "城口县" + }, + { + "code": "500230", + "name": "丰都县" + }, + { + "code": "500231", + "name": "垫江县" + }, + { + "code": "500233", + "name": "忠县" + }, + { + "code": "500235", + "name": "云阳县" + }, + { + "code": "500236", + "name": "奉节县" + }, + { + "code": "500237", + "name": "巫山县" + }, + { + "code": "500238", + "name": "巫溪县" + }, + { + "code": "500240", + "name": "石柱土家族自治县" + }, + { + "code": "500241", + "name": "秀山土家族苗族自治县" + }, + { + "code": "500242", + "name": "酉阳土家族苗族自治县" + }, + { + "code": "500243", + "name": "彭水苗族土家族自治县" + } + ] + } + ] + }, + { + "code": "51", + "name": "四川省", + "children": [ + { + "code": "5101", + "name": "成都市", + "children": [ + { + "code": "510104", + "name": "锦江区" + }, + { + "code": "510105", + "name": "青羊区" + }, + { + "code": "510106", + "name": "金牛区" + }, + { + "code": "510107", + "name": "武侯区" + }, + { + "code": "510108", + "name": "成华区" + }, + { + "code": "510112", + "name": "龙泉驿区" + }, + { + "code": "510113", + "name": "青白江区" + }, + { + "code": "510114", + "name": "新都区" + }, + { + "code": "510115", + "name": "温江区" + }, + { + "code": "510116", + "name": "双流区" + }, + { + "code": "510117", + "name": "郫都区" + }, + { + "code": "510118", + "name": "新津区" + }, + { + "code": "510121", + "name": "金堂县" + }, + { + "code": "510129", + "name": "大邑县" + }, + { + "code": "510131", + "name": "蒲江县" + }, + { + "code": "510181", + "name": "都江堰市" + }, + { + "code": "510182", + "name": "彭州市" + }, + { + "code": "510183", + "name": "邛崃市" + }, + { + "code": "510184", + "name": "崇州市" + }, + { + "code": "510185", + "name": "简阳市" + } + ] + }, + { + "code": "5103", + "name": "自贡市", + "children": [ + { + "code": "510302", + "name": "自流井区" + }, + { + "code": "510303", + "name": "贡井区" + }, + { + "code": "510304", + "name": "大安区" + }, + { + "code": "510311", + "name": "沿滩区" + }, + { + "code": "510321", + "name": "荣县" + }, + { + "code": "510322", + "name": "富顺县" + } + ] + }, + { + "code": "5104", + "name": "攀枝花市", + "children": [ + { + "code": "510402", + "name": "东区" + }, + { + "code": "510403", + "name": "西区" + }, + { + "code": "510411", + "name": "仁和区" + }, + { + "code": "510421", + "name": "米易县" + }, + { + "code": "510422", + "name": "盐边县" + } + ] + }, + { + "code": "5105", + "name": "泸州市", + "children": [ + { + "code": "510502", + "name": "江阳区" + }, + { + "code": "510503", + "name": "纳溪区" + }, + { + "code": "510504", + "name": "龙马潭区" + }, + { + "code": "510521", + "name": "泸县" + }, + { + "code": "510522", + "name": "合江县" + }, + { + "code": "510524", + "name": "叙永县" + }, + { + "code": "510525", + "name": "古蔺县" + } + ] + }, + { + "code": "5106", + "name": "德阳市", + "children": [ + { + "code": "510603", + "name": "旌阳区" + }, + { + "code": "510604", + "name": "罗江区" + }, + { + "code": "510623", + "name": "中江县" + }, + { + "code": "510681", + "name": "广汉市" + }, + { + "code": "510682", + "name": "什邡市" + }, + { + "code": "510683", + "name": "绵竹市" + } + ] + }, + { + "code": "5107", + "name": "绵阳市", + "children": [ + { + "code": "510703", + "name": "涪城区" + }, + { + "code": "510704", + "name": "游仙区" + }, + { + "code": "510705", + "name": "安州区" + }, + { + "code": "510722", + "name": "三台县" + }, + { + "code": "510723", + "name": "盐亭县" + }, + { + "code": "510725", + "name": "梓潼县" + }, + { + "code": "510726", + "name": "北川羌族自治县" + }, + { + "code": "510727", + "name": "平武县" + }, + { + "code": "510781", + "name": "江油市" + } + ] + }, + { + "code": "5108", + "name": "广元市", + "children": [ + { + "code": "510802", + "name": "利州区" + }, + { + "code": "510811", + "name": "昭化区" + }, + { + "code": "510812", + "name": "朝天区" + }, + { + "code": "510821", + "name": "旺苍县" + }, + { + "code": "510822", + "name": "青川县" + }, + { + "code": "510823", + "name": "剑阁县" + }, + { + "code": "510824", + "name": "苍溪县" + } + ] + }, + { + "code": "5109", + "name": "遂宁市", + "children": [ + { + "code": "510903", + "name": "船山区" + }, + { + "code": "510904", + "name": "安居区" + }, + { + "code": "510921", + "name": "蓬溪县" + }, + { + "code": "510923", + "name": "大英县" + }, + { + "code": "510981", + "name": "射洪市" + } + ] + }, + { + "code": "5110", + "name": "内江市", + "children": [ + { + "code": "511002", + "name": "市中区" + }, + { + "code": "511011", + "name": "东兴区" + }, + { + "code": "511024", + "name": "威远县" + }, + { + "code": "511025", + "name": "资中县" + }, + { + "code": "511083", + "name": "隆昌市" + } + ] + }, + { + "code": "5111", + "name": "乐山市", + "children": [ + { + "code": "511102", + "name": "市中区" + }, + { + "code": "511111", + "name": "沙湾区" + }, + { + "code": "511112", + "name": "五通桥区" + }, + { + "code": "511113", + "name": "金口河区" + }, + { + "code": "511123", + "name": "犍为县" + }, + { + "code": "511124", + "name": "井研县" + }, + { + "code": "511126", + "name": "夹江县" + }, + { + "code": "511129", + "name": "沐川县" + }, + { + "code": "511132", + "name": "峨边彝族自治县" + }, + { + "code": "511133", + "name": "马边彝族自治县" + }, + { + "code": "511181", + "name": "峨眉山市" + } + ] + }, + { + "code": "5113", + "name": "南充市", + "children": [ + { + "code": "511302", + "name": "顺庆区" + }, + { + "code": "511303", + "name": "高坪区" + }, + { + "code": "511304", + "name": "嘉陵区" + }, + { + "code": "511321", + "name": "南部县" + }, + { + "code": "511322", + "name": "营山县" + }, + { + "code": "511323", + "name": "蓬安县" + }, + { + "code": "511324", + "name": "仪陇县" + }, + { + "code": "511325", + "name": "西充县" + }, + { + "code": "511381", + "name": "阆中市" + } + ] + }, + { + "code": "5114", + "name": "眉山市", + "children": [ + { + "code": "511402", + "name": "东坡区" + }, + { + "code": "511403", + "name": "彭山区" + }, + { + "code": "511421", + "name": "仁寿县" + }, + { + "code": "511423", + "name": "洪雅县" + }, + { + "code": "511424", + "name": "丹棱县" + }, + { + "code": "511425", + "name": "青神县" + } + ] + }, + { + "code": "5115", + "name": "宜宾市", + "children": [ + { + "code": "511502", + "name": "翠屏区" + }, + { + "code": "511503", + "name": "南溪区" + }, + { + "code": "511504", + "name": "叙州区" + }, + { + "code": "511523", + "name": "江安县" + }, + { + "code": "511524", + "name": "长宁县" + }, + { + "code": "511525", + "name": "高县" + }, + { + "code": "511526", + "name": "珙县" + }, + { + "code": "511527", + "name": "筠连县" + }, + { + "code": "511528", + "name": "兴文县" + }, + { + "code": "511529", + "name": "屏山县" + } + ] + }, + { + "code": "5116", + "name": "广安市", + "children": [ + { + "code": "511602", + "name": "广安区" + }, + { + "code": "511603", + "name": "前锋区" + }, + { + "code": "511621", + "name": "岳池县" + }, + { + "code": "511622", + "name": "武胜县" + }, + { + "code": "511623", + "name": "邻水县" + }, + { + "code": "511681", + "name": "华蓥市" + } + ] + }, + { + "code": "5117", + "name": "达州市", + "children": [ + { + "code": "511702", + "name": "通川区" + }, + { + "code": "511703", + "name": "达川区" + }, + { + "code": "511722", + "name": "宣汉县" + }, + { + "code": "511723", + "name": "开江县" + }, + { + "code": "511724", + "name": "大竹县" + }, + { + "code": "511725", + "name": "渠县" + }, + { + "code": "511781", + "name": "万源市" + } + ] + }, + { + "code": "5118", + "name": "雅安市", + "children": [ + { + "code": "511802", + "name": "雨城区" + }, + { + "code": "511803", + "name": "名山区" + }, + { + "code": "511822", + "name": "荥经县" + }, + { + "code": "511823", + "name": "汉源县" + }, + { + "code": "511824", + "name": "石棉县" + }, + { + "code": "511825", + "name": "天全县" + }, + { + "code": "511826", + "name": "芦山县" + }, + { + "code": "511827", + "name": "宝兴县" + } + ] + }, + { + "code": "5119", + "name": "巴中市", + "children": [ + { + "code": "511902", + "name": "巴州区" + }, + { + "code": "511903", + "name": "恩阳区" + }, + { + "code": "511921", + "name": "通江县" + }, + { + "code": "511922", + "name": "南江县" + }, + { + "code": "511923", + "name": "平昌县" + } + ] + }, + { + "code": "5120", + "name": "资阳市", + "children": [ + { + "code": "512002", + "name": "雁江区" + }, + { + "code": "512021", + "name": "安岳县" + }, + { + "code": "512022", + "name": "乐至县" + } + ] + }, + { + "code": "5132", + "name": "阿坝藏族羌族自治州", + "children": [ + { + "code": "513201", + "name": "马尔康市" + }, + { + "code": "513221", + "name": "汶川县" + }, + { + "code": "513222", + "name": "理县" + }, + { + "code": "513223", + "name": "茂县" + }, + { + "code": "513224", + "name": "松潘县" + }, + { + "code": "513225", + "name": "九寨沟县" + }, + { + "code": "513226", + "name": "金川县" + }, + { + "code": "513227", + "name": "小金县" + }, + { + "code": "513228", + "name": "黑水县" + }, + { + "code": "513230", + "name": "壤塘县" + }, + { + "code": "513231", + "name": "阿坝县" + }, + { + "code": "513232", + "name": "若尔盖县" + }, + { + "code": "513233", + "name": "红原县" + } + ] + }, + { + "code": "5133", + "name": "甘孜藏族自治州", + "children": [ + { + "code": "513301", + "name": "康定市" + }, + { + "code": "513322", + "name": "泸定县" + }, + { + "code": "513323", + "name": "丹巴县" + }, + { + "code": "513324", + "name": "九龙县" + }, + { + "code": "513325", + "name": "雅江县" + }, + { + "code": "513326", + "name": "道孚县" + }, + { + "code": "513327", + "name": "炉霍县" + }, + { + "code": "513328", + "name": "甘孜县" + }, + { + "code": "513329", + "name": "新龙县" + }, + { + "code": "513330", + "name": "德格县" + }, + { + "code": "513331", + "name": "白玉县" + }, + { + "code": "513332", + "name": "石渠县" + }, + { + "code": "513333", + "name": "色达县" + }, + { + "code": "513334", + "name": "理塘县" + }, + { + "code": "513335", + "name": "巴塘县" + }, + { + "code": "513336", + "name": "乡城县" + }, + { + "code": "513337", + "name": "稻城县" + }, + { + "code": "513338", + "name": "得荣县" + } + ] + }, + { + "code": "5134", + "name": "凉山彝族自治州", + "children": [ + { + "code": "513401", + "name": "西昌市" + }, + { + "code": "513402", + "name": "会理市" + }, + { + "code": "513422", + "name": "木里藏族自治县" + }, + { + "code": "513423", + "name": "盐源县" + }, + { + "code": "513424", + "name": "德昌县" + }, + { + "code": "513426", + "name": "会东县" + }, + { + "code": "513427", + "name": "宁南县" + }, + { + "code": "513428", + "name": "普格县" + }, + { + "code": "513429", + "name": "布拖县" + }, + { + "code": "513430", + "name": "金阳县" + }, + { + "code": "513431", + "name": "昭觉县" + }, + { + "code": "513432", + "name": "喜德县" + }, + { + "code": "513433", + "name": "冕宁县" + }, + { + "code": "513434", + "name": "越西县" + }, + { + "code": "513435", + "name": "甘洛县" + }, + { + "code": "513436", + "name": "美姑县" + }, + { + "code": "513437", + "name": "雷波县" + } + ] + } + ] + }, + { + "code": "52", + "name": "贵州省", + "children": [ + { + "code": "5201", + "name": "贵阳市", + "children": [ + { + "code": "520102", + "name": "南明区" + }, + { + "code": "520103", + "name": "云岩区" + }, + { + "code": "520111", + "name": "花溪区" + }, + { + "code": "520112", + "name": "乌当区" + }, + { + "code": "520113", + "name": "白云区" + }, + { + "code": "520115", + "name": "观山湖区" + }, + { + "code": "520121", + "name": "开阳县" + }, + { + "code": "520122", + "name": "息烽县" + }, + { + "code": "520123", + "name": "修文县" + }, + { + "code": "520181", + "name": "清镇市" + } + ] + }, + { + "code": "5202", + "name": "六盘水市", + "children": [ + { + "code": "520201", + "name": "钟山区" + }, + { + "code": "520203", + "name": "六枝特区" + }, + { + "code": "520204", + "name": "水城区" + }, + { + "code": "520281", + "name": "盘州市" + } + ] + }, + { + "code": "5203", + "name": "遵义市", + "children": [ + { + "code": "520302", + "name": "红花岗区" + }, + { + "code": "520303", + "name": "汇川区" + }, + { + "code": "520304", + "name": "播州区" + }, + { + "code": "520322", + "name": "桐梓县" + }, + { + "code": "520323", + "name": "绥阳县" + }, + { + "code": "520324", + "name": "正安县" + }, + { + "code": "520325", + "name": "道真仡佬族苗族自治县" + }, + { + "code": "520326", + "name": "务川仡佬族苗族自治县" + }, + { + "code": "520327", + "name": "凤冈县" + }, + { + "code": "520328", + "name": "湄潭县" + }, + { + "code": "520329", + "name": "余庆县" + }, + { + "code": "520330", + "name": "习水县" + }, + { + "code": "520381", + "name": "赤水市" + }, + { + "code": "520382", + "name": "仁怀市" + } + ] + }, + { + "code": "5204", + "name": "安顺市", + "children": [ + { + "code": "520402", + "name": "西秀区" + }, + { + "code": "520403", + "name": "平坝区" + }, + { + "code": "520422", + "name": "普定县" + }, + { + "code": "520423", + "name": "镇宁布依族苗族自治县" + }, + { + "code": "520424", + "name": "关岭布依族苗族自治县" + }, + { + "code": "520425", + "name": "紫云苗族布依族自治县" + } + ] + }, + { + "code": "5205", + "name": "毕节市", + "children": [ + { + "code": "520502", + "name": "七星关区" + }, + { + "code": "520521", + "name": "大方县" + }, + { + "code": "520523", + "name": "金沙县" + }, + { + "code": "520524", + "name": "织金县" + }, + { + "code": "520525", + "name": "纳雍县" + }, + { + "code": "520526", + "name": "威宁彝族回族苗族自治县" + }, + { + "code": "520527", + "name": "赫章县" + }, + { + "code": "520581", + "name": "黔西市" + } + ] + }, + { + "code": "5206", + "name": "铜仁市", + "children": [ + { + "code": "520602", + "name": "碧江区" + }, + { + "code": "520603", + "name": "万山区" + }, + { + "code": "520621", + "name": "江口县" + }, + { + "code": "520622", + "name": "玉屏侗族自治县" + }, + { + "code": "520623", + "name": "石阡县" + }, + { + "code": "520624", + "name": "思南县" + }, + { + "code": "520625", + "name": "印江土家族苗族自治县" + }, + { + "code": "520626", + "name": "德江县" + }, + { + "code": "520627", + "name": "沿河土家族自治县" + }, + { + "code": "520628", + "name": "松桃苗族自治县" + } + ] + }, + { + "code": "5223", + "name": "黔西南布依族苗族自治州", + "children": [ + { + "code": "522301", + "name": "兴义市" + }, + { + "code": "522302", + "name": "兴仁市" + }, + { + "code": "522323", + "name": "普安县" + }, + { + "code": "522324", + "name": "晴隆县" + }, + { + "code": "522325", + "name": "贞丰县" + }, + { + "code": "522326", + "name": "望谟县" + }, + { + "code": "522327", + "name": "册亨县" + }, + { + "code": "522328", + "name": "安龙县" + } + ] + }, + { + "code": "5226", + "name": "黔东南苗族侗族自治州", + "children": [ + { + "code": "522601", + "name": "凯里市" + }, + { + "code": "522622", + "name": "黄平县" + }, + { + "code": "522623", + "name": "施秉县" + }, + { + "code": "522624", + "name": "三穗县" + }, + { + "code": "522625", + "name": "镇远县" + }, + { + "code": "522626", + "name": "岑巩县" + }, + { + "code": "522627", + "name": "天柱县" + }, + { + "code": "522628", + "name": "锦屏县" + }, + { + "code": "522629", + "name": "剑河县" + }, + { + "code": "522630", + "name": "台江县" + }, + { + "code": "522631", + "name": "黎平县" + }, + { + "code": "522632", + "name": "榕江县" + }, + { + "code": "522633", + "name": "从江县" + }, + { + "code": "522634", + "name": "雷山县" + }, + { + "code": "522635", + "name": "麻江县" + }, + { + "code": "522636", + "name": "丹寨县" + } + ] + }, + { + "code": "5227", + "name": "黔南布依族苗族自治州", + "children": [ + { + "code": "522701", + "name": "都匀市" + }, + { + "code": "522702", + "name": "福泉市" + }, + { + "code": "522722", + "name": "荔波县" + }, + { + "code": "522723", + "name": "贵定县" + }, + { + "code": "522725", + "name": "瓮安县" + }, + { + "code": "522726", + "name": "独山县" + }, + { + "code": "522727", + "name": "平塘县" + }, + { + "code": "522728", + "name": "罗甸县" + }, + { + "code": "522729", + "name": "长顺县" + }, + { + "code": "522730", + "name": "龙里县" + }, + { + "code": "522731", + "name": "惠水县" + }, + { + "code": "522732", + "name": "三都水族自治县" + } + ] + } + ] + }, + { + "code": "53", + "name": "云南省", + "children": [ + { + "code": "5301", + "name": "昆明市", + "children": [ + { + "code": "530102", + "name": "五华区" + }, + { + "code": "530103", + "name": "盘龙区" + }, + { + "code": "530111", + "name": "官渡区" + }, + { + "code": "530112", + "name": "西山区" + }, + { + "code": "530113", + "name": "东川区" + }, + { + "code": "530114", + "name": "呈贡区" + }, + { + "code": "530115", + "name": "晋宁区" + }, + { + "code": "530124", + "name": "富民县" + }, + { + "code": "530125", + "name": "宜良县" + }, + { + "code": "530126", + "name": "石林彝族自治县" + }, + { + "code": "530127", + "name": "嵩明县" + }, + { + "code": "530128", + "name": "禄劝彝族苗族自治县" + }, + { + "code": "530129", + "name": "寻甸回族彝族自治县" + }, + { + "code": "530181", + "name": "安宁市" + } + ] + }, + { + "code": "5303", + "name": "曲靖市", + "children": [ + { + "code": "530302", + "name": "麒麟区" + }, + { + "code": "530303", + "name": "沾益区" + }, + { + "code": "530304", + "name": "马龙区" + }, + { + "code": "530322", + "name": "陆良县" + }, + { + "code": "530323", + "name": "师宗县" + }, + { + "code": "530324", + "name": "罗平县" + }, + { + "code": "530325", + "name": "富源县" + }, + { + "code": "530326", + "name": "会泽县" + }, + { + "code": "530381", + "name": "宣威市" + } + ] + }, + { + "code": "5304", + "name": "玉溪市", + "children": [ + { + "code": "530402", + "name": "红塔区" + }, + { + "code": "530403", + "name": "江川区" + }, + { + "code": "530423", + "name": "通海县" + }, + { + "code": "530424", + "name": "华宁县" + }, + { + "code": "530425", + "name": "易门县" + }, + { + "code": "530426", + "name": "峨山彝族自治县" + }, + { + "code": "530427", + "name": "新平彝族傣族自治县" + }, + { + "code": "530428", + "name": "元江哈尼族彝族傣族自治县" + }, + { + "code": "530481", + "name": "澄江市" + } + ] + }, + { + "code": "5305", + "name": "保山市", + "children": [ + { + "code": "530502", + "name": "隆阳区" + }, + { + "code": "530521", + "name": "施甸县" + }, + { + "code": "530523", + "name": "龙陵县" + }, + { + "code": "530524", + "name": "昌宁县" + }, + { + "code": "530581", + "name": "腾冲市" + } + ] + }, + { + "code": "5306", + "name": "昭通市", + "children": [ + { + "code": "530602", + "name": "昭阳区" + }, + { + "code": "530621", + "name": "鲁甸县" + }, + { + "code": "530622", + "name": "巧家县" + }, + { + "code": "530623", + "name": "盐津县" + }, + { + "code": "530624", + "name": "大关县" + }, + { + "code": "530625", + "name": "永善县" + }, + { + "code": "530626", + "name": "绥江县" + }, + { + "code": "530627", + "name": "镇雄县" + }, + { + "code": "530628", + "name": "彝良县" + }, + { + "code": "530629", + "name": "威信县" + }, + { + "code": "530681", + "name": "水富市" + } + ] + }, + { + "code": "5307", + "name": "丽江市", + "children": [ + { + "code": "530702", + "name": "古城区" + }, + { + "code": "530721", + "name": "玉龙纳西族自治县" + }, + { + "code": "530722", + "name": "永胜县" + }, + { + "code": "530723", + "name": "华坪县" + }, + { + "code": "530724", + "name": "宁蒗彝族自治县" + } + ] + }, + { + "code": "5308", + "name": "普洱市", + "children": [ + { + "code": "530802", + "name": "思茅区" + }, + { + "code": "530821", + "name": "宁洱哈尼族彝族自治县" + }, + { + "code": "530822", + "name": "墨江哈尼族自治县" + }, + { + "code": "530823", + "name": "景东彝族自治县" + }, + { + "code": "530824", + "name": "景谷傣族彝族自治县" + }, + { + "code": "530825", + "name": "镇沅彝族哈尼族拉祜族自治县" + }, + { + "code": "530826", + "name": "江城哈尼族彝族自治县" + }, + { + "code": "530827", + "name": "孟连傣族拉祜族佤族自治县" + }, + { + "code": "530828", + "name": "澜沧拉祜族自治县" + }, + { + "code": "530829", + "name": "西盟佤族自治县" + } + ] + }, + { + "code": "5309", + "name": "临沧市", + "children": [ + { + "code": "530902", + "name": "临翔区" + }, + { + "code": "530921", + "name": "凤庆县" + }, + { + "code": "530922", + "name": "云县" + }, + { + "code": "530923", + "name": "永德县" + }, + { + "code": "530924", + "name": "镇康县" + }, + { + "code": "530925", + "name": "双江拉祜族佤族布朗族傣族自治县" + }, + { + "code": "530926", + "name": "耿马傣族佤族自治县" + }, + { + "code": "530927", + "name": "沧源佤族自治县" + } + ] + }, + { + "code": "5323", + "name": "楚雄彝族自治州", + "children": [ + { + "code": "532301", + "name": "楚雄市" + }, + { + "code": "532302", + "name": "禄丰市" + }, + { + "code": "532322", + "name": "双柏县" + }, + { + "code": "532323", + "name": "牟定县" + }, + { + "code": "532324", + "name": "南华县" + }, + { + "code": "532325", + "name": "姚安县" + }, + { + "code": "532326", + "name": "大姚县" + }, + { + "code": "532327", + "name": "永仁县" + }, + { + "code": "532328", + "name": "元谋县" + }, + { + "code": "532329", + "name": "武定县" + } + ] + }, + { + "code": "5325", + "name": "红河哈尼族彝族自治州", + "children": [ + { + "code": "532501", + "name": "个旧市" + }, + { + "code": "532502", + "name": "开远市" + }, + { + "code": "532503", + "name": "蒙自市" + }, + { + "code": "532504", + "name": "弥勒市" + }, + { + "code": "532523", + "name": "屏边苗族自治县" + }, + { + "code": "532524", + "name": "建水县" + }, + { + "code": "532525", + "name": "石屏县" + }, + { + "code": "532527", + "name": "泸西县" + }, + { + "code": "532528", + "name": "元阳县" + }, + { + "code": "532529", + "name": "红河县" + }, + { + "code": "532530", + "name": "金平苗族瑶族傣族自治县" + }, + { + "code": "532531", + "name": "绿春县" + }, + { + "code": "532532", + "name": "河口瑶族自治县" + } + ] + }, + { + "code": "5326", + "name": "文山壮族苗族自治州", + "children": [ + { + "code": "532601", + "name": "文山市" + }, + { + "code": "532622", + "name": "砚山县" + }, + { + "code": "532623", + "name": "西畴县" + }, + { + "code": "532624", + "name": "麻栗坡县" + }, + { + "code": "532625", + "name": "马关县" + }, + { + "code": "532626", + "name": "丘北县" + }, + { + "code": "532627", + "name": "广南县" + }, + { + "code": "532628", + "name": "富宁县" + } + ] + }, + { + "code": "5328", + "name": "西双版纳傣族自治州", + "children": [ + { + "code": "532801", + "name": "景洪市" + }, + { + "code": "532822", + "name": "勐海县" + }, + { + "code": "532823", + "name": "勐腊县" + } + ] + }, + { + "code": "5329", + "name": "大理白族自治州", + "children": [ + { + "code": "532901", + "name": "大理市" + }, + { + "code": "532922", + "name": "漾濞彝族自治县" + }, + { + "code": "532923", + "name": "祥云县" + }, + { + "code": "532924", + "name": "宾川县" + }, + { + "code": "532925", + "name": "弥渡县" + }, + { + "code": "532926", + "name": "南涧彝族自治县" + }, + { + "code": "532927", + "name": "巍山彝族回族自治县" + }, + { + "code": "532928", + "name": "永平县" + }, + { + "code": "532929", + "name": "云龙县" + }, + { + "code": "532930", + "name": "洱源县" + }, + { + "code": "532931", + "name": "剑川县" + }, + { + "code": "532932", + "name": "鹤庆县" + } + ] + }, + { + "code": "5331", + "name": "德宏傣族景颇族自治州", + "children": [ + { + "code": "533102", + "name": "瑞丽市" + }, + { + "code": "533103", + "name": "芒市" + }, + { + "code": "533122", + "name": "梁河县" + }, + { + "code": "533123", + "name": "盈江县" + }, + { + "code": "533124", + "name": "陇川县" + } + ] + }, + { + "code": "5333", + "name": "怒江傈僳族自治州", + "children": [ + { + "code": "533301", + "name": "泸水市" + }, + { + "code": "533323", + "name": "福贡县" + }, + { + "code": "533324", + "name": "贡山独龙族怒族自治县" + }, + { + "code": "533325", + "name": "兰坪白族普米族自治县" + } + ] + }, + { + "code": "5334", + "name": "迪庆藏族自治州", + "children": [ + { + "code": "533401", + "name": "香格里拉市" + }, + { + "code": "533422", + "name": "德钦县" + }, + { + "code": "533423", + "name": "维西傈僳族自治县" + } + ] + } + ] + }, + { + "code": "54", + "name": "西藏自治区", + "children": [ + { + "code": "5401", + "name": "拉萨市", + "children": [ + { + "code": "540102", + "name": "城关区" + }, + { + "code": "540103", + "name": "堆龙德庆区" + }, + { + "code": "540104", + "name": "达孜区" + }, + { + "code": "540121", + "name": "林周县" + }, + { + "code": "540122", + "name": "当雄县" + }, + { + "code": "540123", + "name": "尼木县" + }, + { + "code": "540124", + "name": "曲水县" + }, + { + "code": "540127", + "name": "墨竹工卡县" + }, + { + "code": "540171", + "name": "格尔木藏青工业园区" + }, + { + "code": "540172", + "name": "拉萨经济技术开发区" + }, + { + "code": "540173", + "name": "西藏文化旅游创意园区" + }, + { + "code": "540174", + "name": "达孜工业园区" + } + ] + }, + { + "code": "5402", + "name": "日喀则市", + "children": [ + { + "code": "540202", + "name": "桑珠孜区" + }, + { + "code": "540221", + "name": "南木林县" + }, + { + "code": "540222", + "name": "江孜县" + }, + { + "code": "540223", + "name": "定日县" + }, + { + "code": "540224", + "name": "萨迦县" + }, + { + "code": "540225", + "name": "拉孜县" + }, + { + "code": "540226", + "name": "昂仁县" + }, + { + "code": "540227", + "name": "谢通门县" + }, + { + "code": "540228", + "name": "白朗县" + }, + { + "code": "540229", + "name": "仁布县" + }, + { + "code": "540230", + "name": "康马县" + }, + { + "code": "540231", + "name": "定结县" + }, + { + "code": "540232", + "name": "仲巴县" + }, + { + "code": "540233", + "name": "亚东县" + }, + { + "code": "540234", + "name": "吉隆县" + }, + { + "code": "540235", + "name": "聂拉木县" + }, + { + "code": "540236", + "name": "萨嘎县" + }, + { + "code": "540237", + "name": "岗巴县" + } + ] + }, + { + "code": "5403", + "name": "昌都市", + "children": [ + { + "code": "540302", + "name": "卡若区" + }, + { + "code": "540321", + "name": "江达县" + }, + { + "code": "540322", + "name": "贡觉县" + }, + { + "code": "540323", + "name": "类乌齐县" + }, + { + "code": "540324", + "name": "丁青县" + }, + { + "code": "540325", + "name": "察雅县" + }, + { + "code": "540326", + "name": "八宿县" + }, + { + "code": "540327", + "name": "左贡县" + }, + { + "code": "540328", + "name": "芒康县" + }, + { + "code": "540329", + "name": "洛隆县" + }, + { + "code": "540330", + "name": "边坝县" + } + ] + }, + { + "code": "5404", + "name": "林芝市", + "children": [ + { + "code": "540402", + "name": "巴宜区" + }, + { + "code": "540421", + "name": "工布江达县" + }, + { + "code": "540423", + "name": "墨脱县" + }, + { + "code": "540424", + "name": "波密县" + }, + { + "code": "540425", + "name": "察隅县" + }, + { + "code": "540426", + "name": "朗县" + }, + { + "code": "540481", + "name": "米林市" + } + ] + }, + { + "code": "5405", + "name": "山南市", + "children": [ + { + "code": "540502", + "name": "乃东区" + }, + { + "code": "540521", + "name": "扎囊县" + }, + { + "code": "540522", + "name": "贡嘎县" + }, + { + "code": "540523", + "name": "桑日县" + }, + { + "code": "540524", + "name": "琼结县" + }, + { + "code": "540525", + "name": "曲松县" + }, + { + "code": "540526", + "name": "措美县" + }, + { + "code": "540527", + "name": "洛扎县" + }, + { + "code": "540528", + "name": "加查县" + }, + { + "code": "540529", + "name": "隆子县" + }, + { + "code": "540531", + "name": "浪卡子县" + }, + { + "code": "540581", + "name": "错那市" + } + ] + }, + { + "code": "5406", + "name": "那曲市", + "children": [ + { + "code": "540602", + "name": "色尼区" + }, + { + "code": "540621", + "name": "嘉黎县" + }, + { + "code": "540622", + "name": "比如县" + }, + { + "code": "540623", + "name": "聂荣县" + }, + { + "code": "540624", + "name": "安多县" + }, + { + "code": "540625", + "name": "申扎县" + }, + { + "code": "540626", + "name": "索县" + }, + { + "code": "540627", + "name": "班戈县" + }, + { + "code": "540628", + "name": "巴青县" + }, + { + "code": "540629", + "name": "尼玛县" + }, + { + "code": "540630", + "name": "双湖县" + } + ] + }, + { + "code": "5425", + "name": "阿里地区", + "children": [ + { + "code": "542521", + "name": "普兰县" + }, + { + "code": "542522", + "name": "札达县" + }, + { + "code": "542523", + "name": "噶尔县" + }, + { + "code": "542524", + "name": "日土县" + }, + { + "code": "542525", + "name": "革吉县" + }, + { + "code": "542526", + "name": "改则县" + }, + { + "code": "542527", + "name": "措勤县" + } + ] + } + ] + }, + { + "code": "61", + "name": "陕西省", + "children": [ + { + "code": "6101", + "name": "西安市", + "children": [ + { + "code": "610102", + "name": "新城区" + }, + { + "code": "610103", + "name": "碑林区" + }, + { + "code": "610104", + "name": "莲湖区" + }, + { + "code": "610111", + "name": "灞桥区" + }, + { + "code": "610112", + "name": "未央区" + }, + { + "code": "610113", + "name": "雁塔区" + }, + { + "code": "610114", + "name": "阎良区" + }, + { + "code": "610115", + "name": "临潼区" + }, + { + "code": "610116", + "name": "长安区" + }, + { + "code": "610117", + "name": "高陵区" + }, + { + "code": "610118", + "name": "鄠邑区" + }, + { + "code": "610122", + "name": "蓝田县" + }, + { + "code": "610124", + "name": "周至县" + } + ] + }, + { + "code": "6102", + "name": "铜川市", + "children": [ + { + "code": "610202", + "name": "王益区" + }, + { + "code": "610203", + "name": "印台区" + }, + { + "code": "610204", + "name": "耀州区" + }, + { + "code": "610222", + "name": "宜君县" + } + ] + }, + { + "code": "6103", + "name": "宝鸡市", + "children": [ + { + "code": "610302", + "name": "渭滨区" + }, + { + "code": "610303", + "name": "金台区" + }, + { + "code": "610304", + "name": "陈仓区" + }, + { + "code": "610305", + "name": "凤翔区" + }, + { + "code": "610323", + "name": "岐山县" + }, + { + "code": "610324", + "name": "扶风县" + }, + { + "code": "610326", + "name": "眉县" + }, + { + "code": "610327", + "name": "陇县" + }, + { + "code": "610328", + "name": "千阳县" + }, + { + "code": "610329", + "name": "麟游县" + }, + { + "code": "610330", + "name": "凤县" + }, + { + "code": "610331", + "name": "太白县" + } + ] + }, + { + "code": "6104", + "name": "咸阳市", + "children": [ + { + "code": "610402", + "name": "秦都区" + }, + { + "code": "610403", + "name": "杨陵区" + }, + { + "code": "610404", + "name": "渭城区" + }, + { + "code": "610422", + "name": "三原县" + }, + { + "code": "610423", + "name": "泾阳县" + }, + { + "code": "610424", + "name": "乾县" + }, + { + "code": "610425", + "name": "礼泉县" + }, + { + "code": "610426", + "name": "永寿县" + }, + { + "code": "610428", + "name": "长武县" + }, + { + "code": "610429", + "name": "旬邑县" + }, + { + "code": "610430", + "name": "淳化县" + }, + { + "code": "610431", + "name": "武功县" + }, + { + "code": "610481", + "name": "兴平市" + }, + { + "code": "610482", + "name": "彬州市" + } + ] + }, + { + "code": "6105", + "name": "渭南市", + "children": [ + { + "code": "610502", + "name": "临渭区" + }, + { + "code": "610503", + "name": "华州区" + }, + { + "code": "610522", + "name": "潼关县" + }, + { + "code": "610523", + "name": "大荔县" + }, + { + "code": "610524", + "name": "合阳县" + }, + { + "code": "610525", + "name": "澄城县" + }, + { + "code": "610526", + "name": "蒲城县" + }, + { + "code": "610527", + "name": "白水县" + }, + { + "code": "610528", + "name": "富平县" + }, + { + "code": "610581", + "name": "韩城市" + }, + { + "code": "610582", + "name": "华阴市" + } + ] + }, + { + "code": "6106", + "name": "延安市", + "children": [ + { + "code": "610602", + "name": "宝塔区" + }, + { + "code": "610603", + "name": "安塞区" + }, + { + "code": "610621", + "name": "延长县" + }, + { + "code": "610622", + "name": "延川县" + }, + { + "code": "610625", + "name": "志丹县" + }, + { + "code": "610626", + "name": "吴起县" + }, + { + "code": "610627", + "name": "甘泉县" + }, + { + "code": "610628", + "name": "富县" + }, + { + "code": "610629", + "name": "洛川县" + }, + { + "code": "610630", + "name": "宜川县" + }, + { + "code": "610631", + "name": "黄龙县" + }, + { + "code": "610632", + "name": "黄陵县" + }, + { + "code": "610681", + "name": "子长市" + } + ] + }, + { + "code": "6107", + "name": "汉中市", + "children": [ + { + "code": "610702", + "name": "汉台区" + }, + { + "code": "610703", + "name": "南郑区" + }, + { + "code": "610722", + "name": "城固县" + }, + { + "code": "610723", + "name": "洋县" + }, + { + "code": "610724", + "name": "西乡县" + }, + { + "code": "610725", + "name": "勉县" + }, + { + "code": "610726", + "name": "宁强县" + }, + { + "code": "610727", + "name": "略阳县" + }, + { + "code": "610728", + "name": "镇巴县" + }, + { + "code": "610729", + "name": "留坝县" + }, + { + "code": "610730", + "name": "佛坪县" + } + ] + }, + { + "code": "6108", + "name": "榆林市", + "children": [ + { + "code": "610802", + "name": "榆阳区" + }, + { + "code": "610803", + "name": "横山区" + }, + { + "code": "610822", + "name": "府谷县" + }, + { + "code": "610824", + "name": "靖边县" + }, + { + "code": "610825", + "name": "定边县" + }, + { + "code": "610826", + "name": "绥德县" + }, + { + "code": "610827", + "name": "米脂县" + }, + { + "code": "610828", + "name": "佳县" + }, + { + "code": "610829", + "name": "吴堡县" + }, + { + "code": "610830", + "name": "清涧县" + }, + { + "code": "610831", + "name": "子洲县" + }, + { + "code": "610881", + "name": "神木市" + } + ] + }, + { + "code": "6109", + "name": "安康市", + "children": [ + { + "code": "610902", + "name": "汉滨区" + }, + { + "code": "610921", + "name": "汉阴县" + }, + { + "code": "610922", + "name": "石泉县" + }, + { + "code": "610923", + "name": "宁陕县" + }, + { + "code": "610924", + "name": "紫阳县" + }, + { + "code": "610925", + "name": "岚皋县" + }, + { + "code": "610926", + "name": "平利县" + }, + { + "code": "610927", + "name": "镇坪县" + }, + { + "code": "610929", + "name": "白河县" + }, + { + "code": "610981", + "name": "旬阳市" + } + ] + }, + { + "code": "6110", + "name": "商洛市", + "children": [ + { + "code": "611002", + "name": "商州区" + }, + { + "code": "611021", + "name": "洛南县" + }, + { + "code": "611022", + "name": "丹凤县" + }, + { + "code": "611023", + "name": "商南县" + }, + { + "code": "611024", + "name": "山阳县" + }, + { + "code": "611025", + "name": "镇安县" + }, + { + "code": "611026", + "name": "柞水县" + } + ] + } + ] + }, + { + "code": "62", + "name": "甘肃省", + "children": [ + { + "code": "6201", + "name": "兰州市", + "children": [ + { + "code": "620102", + "name": "城关区" + }, + { + "code": "620103", + "name": "七里河区" + }, + { + "code": "620104", + "name": "西固区" + }, + { + "code": "620105", + "name": "安宁区" + }, + { + "code": "620111", + "name": "红古区" + }, + { + "code": "620121", + "name": "永登县" + }, + { + "code": "620122", + "name": "皋兰县" + }, + { + "code": "620123", + "name": "榆中县" + }, + { + "code": "620171", + "name": "兰州新区" + } + ] + }, + { + "code": "6202", + "name": "嘉峪关市", + "children": [ + { + "code": "620201001", + "name": "雄关街道" + }, + { + "code": "620201002", + "name": "钢城街道" + }, + { + "code": "620201100", + "name": "新城镇" + }, + { + "code": "620201101", + "name": "峪泉镇" + }, + { + "code": "620201102", + "name": "文殊镇" + } + ] + }, + { + "code": "6203", + "name": "金昌市", + "children": [ + { + "code": "620302", + "name": "金川区" + }, + { + "code": "620321", + "name": "永昌县" + } + ] + }, + { + "code": "6204", + "name": "白银市", + "children": [ + { + "code": "620402", + "name": "白银区" + }, + { + "code": "620403", + "name": "平川区" + }, + { + "code": "620421", + "name": "靖远县" + }, + { + "code": "620422", + "name": "会宁县" + }, + { + "code": "620423", + "name": "景泰县" + } + ] + }, + { + "code": "6205", + "name": "天水市", + "children": [ + { + "code": "620502", + "name": "秦州区" + }, + { + "code": "620503", + "name": "麦积区" + }, + { + "code": "620521", + "name": "清水县" + }, + { + "code": "620522", + "name": "秦安县" + }, + { + "code": "620523", + "name": "甘谷县" + }, + { + "code": "620524", + "name": "武山县" + }, + { + "code": "620525", + "name": "张家川回族自治县" + } + ] + }, + { + "code": "6206", + "name": "武威市", + "children": [ + { + "code": "620602", + "name": "凉州区" + }, + { + "code": "620621", + "name": "民勤县" + }, + { + "code": "620622", + "name": "古浪县" + }, + { + "code": "620623", + "name": "天祝藏族自治县" + } + ] + }, + { + "code": "6207", + "name": "张掖市", + "children": [ + { + "code": "620702", + "name": "甘州区" + }, + { + "code": "620721", + "name": "肃南裕固族自治县" + }, + { + "code": "620722", + "name": "民乐县" + }, + { + "code": "620723", + "name": "临泽县" + }, + { + "code": "620724", + "name": "高台县" + }, + { + "code": "620725", + "name": "山丹县" + } + ] + }, + { + "code": "6208", + "name": "平凉市", + "children": [ + { + "code": "620802", + "name": "崆峒区" + }, + { + "code": "620821", + "name": "泾川县" + }, + { + "code": "620822", + "name": "灵台县" + }, + { + "code": "620823", + "name": "崇信县" + }, + { + "code": "620825", + "name": "庄浪县" + }, + { + "code": "620826", + "name": "静宁县" + }, + { + "code": "620881", + "name": "华亭市" + } + ] + }, + { + "code": "6209", + "name": "酒泉市", + "children": [ + { + "code": "620902", + "name": "肃州区" + }, + { + "code": "620921", + "name": "金塔县" + }, + { + "code": "620922", + "name": "瓜州县" + }, + { + "code": "620923", + "name": "肃北蒙古族自治县" + }, + { + "code": "620924", + "name": "阿克塞哈萨克族自治县" + }, + { + "code": "620981", + "name": "玉门市" + }, + { + "code": "620982", + "name": "敦煌市" + } + ] + }, + { + "code": "6210", + "name": "庆阳市", + "children": [ + { + "code": "621002", + "name": "西峰区" + }, + { + "code": "621021", + "name": "庆城县" + }, + { + "code": "621022", + "name": "环县" + }, + { + "code": "621023", + "name": "华池县" + }, + { + "code": "621024", + "name": "合水县" + }, + { + "code": "621025", + "name": "正宁县" + }, + { + "code": "621026", + "name": "宁县" + }, + { + "code": "621027", + "name": "镇原县" + } + ] + }, + { + "code": "6211", + "name": "定西市", + "children": [ + { + "code": "621102", + "name": "安定区" + }, + { + "code": "621121", + "name": "通渭县" + }, + { + "code": "621122", + "name": "陇西县" + }, + { + "code": "621123", + "name": "渭源县" + }, + { + "code": "621124", + "name": "临洮县" + }, + { + "code": "621125", + "name": "漳县" + }, + { + "code": "621126", + "name": "岷县" + } + ] + }, + { + "code": "6212", + "name": "陇南市", + "children": [ + { + "code": "621202", + "name": "武都区" + }, + { + "code": "621221", + "name": "成县" + }, + { + "code": "621222", + "name": "文县" + }, + { + "code": "621223", + "name": "宕昌县" + }, + { + "code": "621224", + "name": "康县" + }, + { + "code": "621225", + "name": "西和县" + }, + { + "code": "621226", + "name": "礼县" + }, + { + "code": "621227", + "name": "徽县" + }, + { + "code": "621228", + "name": "两当县" + } + ] + }, + { + "code": "6229", + "name": "临夏回族自治州", + "children": [ + { + "code": "622901", + "name": "临夏市" + }, + { + "code": "622921", + "name": "临夏县" + }, + { + "code": "622922", + "name": "康乐县" + }, + { + "code": "622923", + "name": "永靖县" + }, + { + "code": "622924", + "name": "广河县" + }, + { + "code": "622925", + "name": "和政县" + }, + { + "code": "622926", + "name": "东乡族自治县" + }, + { + "code": "622927", + "name": "积石山保安族东乡族撒拉族自治县" + } + ] + }, + { + "code": "6230", + "name": "甘南藏族自治州", + "children": [ + { + "code": "623001", + "name": "合作市" + }, + { + "code": "623021", + "name": "临潭县" + }, + { + "code": "623022", + "name": "卓尼县" + }, + { + "code": "623023", + "name": "舟曲县" + }, + { + "code": "623024", + "name": "迭部县" + }, + { + "code": "623025", + "name": "玛曲县" + }, + { + "code": "623026", + "name": "碌曲县" + }, + { + "code": "623027", + "name": "夏河县" + } + ] + } + ] + }, + { + "code": "63", + "name": "青海省", + "children": [ + { + "code": "6301", + "name": "西宁市", + "children": [ + { + "code": "630102", + "name": "城东区" + }, + { + "code": "630103", + "name": "城中区" + }, + { + "code": "630104", + "name": "城西区" + }, + { + "code": "630105", + "name": "城北区" + }, + { + "code": "630106", + "name": "湟中区" + }, + { + "code": "630121", + "name": "大通回族土族自治县" + }, + { + "code": "630123", + "name": "湟源县" + } + ] + }, + { + "code": "6302", + "name": "海东市", + "children": [ + { + "code": "630202", + "name": "乐都区" + }, + { + "code": "630203", + "name": "平安区" + }, + { + "code": "630222", + "name": "民和回族土族自治县" + }, + { + "code": "630223", + "name": "互助土族自治县" + }, + { + "code": "630224", + "name": "化隆回族自治县" + }, + { + "code": "630225", + "name": "循化撒拉族自治县" + } + ] + }, + { + "code": "6322", + "name": "海北藏族自治州", + "children": [ + { + "code": "632221", + "name": "门源回族自治县" + }, + { + "code": "632222", + "name": "祁连县" + }, + { + "code": "632223", + "name": "海晏县" + }, + { + "code": "632224", + "name": "刚察县" + } + ] + }, + { + "code": "6323", + "name": "黄南藏族自治州", + "children": [ + { + "code": "632301", + "name": "同仁市" + }, + { + "code": "632322", + "name": "尖扎县" + }, + { + "code": "632323", + "name": "泽库县" + }, + { + "code": "632324", + "name": "河南蒙古族自治县" + } + ] + }, + { + "code": "6325", + "name": "海南藏族自治州", + "children": [ + { + "code": "632521", + "name": "共和县" + }, + { + "code": "632522", + "name": "同德县" + }, + { + "code": "632523", + "name": "贵德县" + }, + { + "code": "632524", + "name": "兴海县" + }, + { + "code": "632525", + "name": "贵南县" + } + ] + }, + { + "code": "6326", + "name": "果洛藏族自治州", + "children": [ + { + "code": "632621", + "name": "玛沁县" + }, + { + "code": "632622", + "name": "班玛县" + }, + { + "code": "632623", + "name": "甘德县" + }, + { + "code": "632624", + "name": "达日县" + }, + { + "code": "632625", + "name": "久治县" + }, + { + "code": "632626", + "name": "玛多县" + } + ] + }, + { + "code": "6327", + "name": "玉树藏族自治州", + "children": [ + { + "code": "632701", + "name": "玉树市" + }, + { + "code": "632722", + "name": "杂多县" + }, + { + "code": "632723", + "name": "称多县" + }, + { + "code": "632724", + "name": "治多县" + }, + { + "code": "632725", + "name": "囊谦县" + }, + { + "code": "632726", + "name": "曲麻莱县" + } + ] + }, + { + "code": "6328", + "name": "海西蒙古族藏族自治州", + "children": [ + { + "code": "632801", + "name": "格尔木市" + }, + { + "code": "632802", + "name": "德令哈市" + }, + { + "code": "632803", + "name": "茫崖市" + }, + { + "code": "632821", + "name": "乌兰县" + }, + { + "code": "632822", + "name": "都兰县" + }, + { + "code": "632823", + "name": "天峻县" + }, + { + "code": "632857", + "name": "大柴旦行政委员会" + } + ] + } + ] + }, + { + "code": "64", + "name": "宁夏回族自治区", + "children": [ + { + "code": "6401", + "name": "银川市", + "children": [ + { + "code": "640104", + "name": "兴庆区" + }, + { + "code": "640105", + "name": "西夏区" + }, + { + "code": "640106", + "name": "金凤区" + }, + { + "code": "640121", + "name": "永宁县" + }, + { + "code": "640122", + "name": "贺兰县" + }, + { + "code": "640181", + "name": "灵武市" + } + ] + }, + { + "code": "6402", + "name": "石嘴山市", + "children": [ + { + "code": "640202", + "name": "大武口区" + }, + { + "code": "640205", + "name": "惠农区" + }, + { + "code": "640221", + "name": "平罗县" + } + ] + }, + { + "code": "6403", + "name": "吴忠市", + "children": [ + { + "code": "640302", + "name": "利通区" + }, + { + "code": "640303", + "name": "红寺堡区" + }, + { + "code": "640323", + "name": "盐池县" + }, + { + "code": "640324", + "name": "同心县" + }, + { + "code": "640381", + "name": "青铜峡市" + } + ] + }, + { + "code": "6404", + "name": "固原市", + "children": [ + { + "code": "640402", + "name": "原州区" + }, + { + "code": "640422", + "name": "西吉县" + }, + { + "code": "640423", + "name": "隆德县" + }, + { + "code": "640424", + "name": "泾源县" + }, + { + "code": "640425", + "name": "彭阳县" + } + ] + }, + { + "code": "6405", + "name": "中卫市", + "children": [ + { + "code": "640502", + "name": "沙坡头区" + }, + { + "code": "640521", + "name": "中宁县" + }, + { + "code": "640522", + "name": "海原县" + } + ] + } + ] + }, + { + "code": "65", + "name": "新疆维吾尔自治区", + "children": [ + { + "code": "6501", + "name": "乌鲁木齐市", + "children": [ + { + "code": "650102", + "name": "天山区" + }, + { + "code": "650103", + "name": "沙依巴克区" + }, + { + "code": "650104", + "name": "新市区" + }, + { + "code": "650105", + "name": "水磨沟区" + }, + { + "code": "650106", + "name": "头屯河区" + }, + { + "code": "650107", + "name": "达坂城区" + }, + { + "code": "650109", + "name": "米东区" + }, + { + "code": "650121", + "name": "乌鲁木齐县" + } + ] + }, + { + "code": "6502", + "name": "克拉玛依市", + "children": [ + { + "code": "650202", + "name": "独山子区" + }, + { + "code": "650203", + "name": "克拉玛依区" + }, + { + "code": "650204", + "name": "白碱滩区" + }, + { + "code": "650205", + "name": "乌尔禾区" + } + ] + }, + { + "code": "6504", + "name": "吐鲁番市", + "children": [ + { + "code": "650402", + "name": "高昌区" + }, + { + "code": "650421", + "name": "鄯善县" + }, + { + "code": "650422", + "name": "托克逊县" + } + ] + }, + { + "code": "6505", + "name": "哈密市", + "children": [ + { + "code": "650502", + "name": "伊州区" + }, + { + "code": "650521", + "name": "巴里坤哈萨克自治县" + }, + { + "code": "650522", + "name": "伊吾县" + } + ] + }, + { + "code": "6523", + "name": "昌吉回族自治州", + "children": [ + { + "code": "652301", + "name": "昌吉市" + }, + { + "code": "652302", + "name": "阜康市" + }, + { + "code": "652323", + "name": "呼图壁县" + }, + { + "code": "652324", + "name": "玛纳斯县" + }, + { + "code": "652325", + "name": "奇台县" + }, + { + "code": "652327", + "name": "吉木萨尔县" + }, + { + "code": "652328", + "name": "木垒哈萨克自治县" + } + ] + }, + { + "code": "6527", + "name": "博尔塔拉蒙古自治州", + "children": [ + { + "code": "652701", + "name": "博乐市" + }, + { + "code": "652702", + "name": "阿拉山口市" + }, + { + "code": "652722", + "name": "精河县" + }, + { + "code": "652723", + "name": "温泉县" + } + ] + }, + { + "code": "6528", + "name": "巴音郭楞蒙古自治州", + "children": [ + { + "code": "652801", + "name": "库尔勒市" + }, + { + "code": "652822", + "name": "轮台县" + }, + { + "code": "652823", + "name": "尉犁县" + }, + { + "code": "652824", + "name": "若羌县" + }, + { + "code": "652825", + "name": "且末县" + }, + { + "code": "652826", + "name": "焉耆回族自治县" + }, + { + "code": "652827", + "name": "和静县" + }, + { + "code": "652828", + "name": "和硕县" + }, + { + "code": "652829", + "name": "博湖县" + } + ] + }, + { + "code": "6529", + "name": "阿克苏地区", + "children": [ + { + "code": "652901", + "name": "阿克苏市" + }, + { + "code": "652902", + "name": "库车市" + }, + { + "code": "652922", + "name": "温宿县" + }, + { + "code": "652924", + "name": "沙雅县" + }, + { + "code": "652925", + "name": "新和县" + }, + { + "code": "652926", + "name": "拜城县" + }, + { + "code": "652927", + "name": "乌什县" + }, + { + "code": "652928", + "name": "阿瓦提县" + }, + { + "code": "652929", + "name": "柯坪县" + } + ] + }, + { + "code": "6530", + "name": "克孜勒苏柯尔克孜自治州", + "children": [ + { + "code": "653001", + "name": "阿图什市" + }, + { + "code": "653022", + "name": "阿克陶县" + }, + { + "code": "653023", + "name": "阿合奇县" + }, + { + "code": "653024", + "name": "乌恰县" + } + ] + }, + { + "code": "6531", + "name": "喀什地区", + "children": [ + { + "code": "653101", + "name": "喀什市" + }, + { + "code": "653121", + "name": "疏附县" + }, + { + "code": "653122", + "name": "疏勒县" + }, + { + "code": "653123", + "name": "英吉沙县" + }, + { + "code": "653124", + "name": "泽普县" + }, + { + "code": "653125", + "name": "莎车县" + }, + { + "code": "653126", + "name": "叶城县" + }, + { + "code": "653127", + "name": "麦盖提县" + }, + { + "code": "653128", + "name": "岳普湖县" + }, + { + "code": "653129", + "name": "伽师县" + }, + { + "code": "653130", + "name": "巴楚县" + }, + { + "code": "653131", + "name": "塔什库尔干塔吉克自治县" + } + ] + }, + { + "code": "6532", + "name": "和田地区", + "children": [ + { + "code": "653201", + "name": "和田市" + }, + { + "code": "653221", + "name": "和田县" + }, + { + "code": "653222", + "name": "墨玉县" + }, + { + "code": "653223", + "name": "皮山县" + }, + { + "code": "653224", + "name": "洛浦县" + }, + { + "code": "653225", + "name": "策勒县" + }, + { + "code": "653226", + "name": "于田县" + }, + { + "code": "653227", + "name": "民丰县" + } + ] + }, + { + "code": "6540", + "name": "伊犁哈萨克自治州", + "children": [ + { + "code": "654002", + "name": "伊宁市" + }, + { + "code": "654003", + "name": "奎屯市" + }, + { + "code": "654004", + "name": "霍尔果斯市" + }, + { + "code": "654021", + "name": "伊宁县" + }, + { + "code": "654022", + "name": "察布查尔锡伯自治县" + }, + { + "code": "654023", + "name": "霍城县" + }, + { + "code": "654024", + "name": "巩留县" + }, + { + "code": "654025", + "name": "新源县" + }, + { + "code": "654026", + "name": "昭苏县" + }, + { + "code": "654027", + "name": "特克斯县" + }, + { + "code": "654028", + "name": "尼勒克县" + } + ] + }, + { + "code": "6542", + "name": "塔城地区", + "children": [ + { + "code": "654201", + "name": "塔城市" + }, + { + "code": "654202", + "name": "乌苏市" + }, + { + "code": "654203", + "name": "沙湾市" + }, + { + "code": "654221", + "name": "额敏县" + }, + { + "code": "654224", + "name": "托里县" + }, + { + "code": "654225", + "name": "裕民县" + }, + { + "code": "654226", + "name": "和布克赛尔蒙古自治县" + } + ] + }, + { + "code": "6543", + "name": "阿勒泰地区", + "children": [ + { + "code": "654301", + "name": "阿勒泰市" + }, + { + "code": "654321", + "name": "布尔津县" + }, + { + "code": "654322", + "name": "富蕴县" + }, + { + "code": "654323", + "name": "福海县" + }, + { + "code": "654324", + "name": "哈巴河县" + }, + { + "code": "654325", + "name": "青河县" + }, + { + "code": "654326", + "name": "吉木乃县" + } + ] + }, + { + "code": "6590", + "name": "自治区直辖县级行政区划", + "children": [ + { + "code": "659001", + "name": "石河子市" + }, + { + "code": "659002", + "name": "阿拉尔市" + }, + { + "code": "659003", + "name": "图木舒克市" + }, + { + "code": "659004", + "name": "五家渠市" + }, + { + "code": "659005", + "name": "北屯市" + }, + { + "code": "659006", + "name": "铁门关市" + }, + { + "code": "659007", + "name": "双河市" + }, + { + "code": "659008", + "name": "可克达拉市" + }, + { + "code": "659009", + "name": "昆玉市" + }, + { + "code": "659010", + "name": "胡杨河市" + }, + { + "code": "659011", + "name": "新星市" + }, + { + "code": "659012", + "name": "白杨市" + } + ] + } + ] + }, + { + "children": [ + { + "children": [ + { + "name": "中正区", + "code": "710101" + }, + { + "name": "大同区", + "code": "710102" + }, + { + "name": "中山区", + "code": "710103" + }, + { + "name": "松山区", + "code": "710104" + }, + { + "name": "大安区", + "code": "710105" + }, + { + "name": "万华区", + "code": "710106" + }, + { + "name": "信义区", + "code": "710107" + }, + { + "name": "士林区", + "code": "710108" + }, + { + "name": "北投区", + "code": "710109" + }, + { + "name": "内湖区", + "code": "710110" + }, + { + "name": "南港区", + "code": "710111" + }, + { + "name": "文山区", + "code": "710112" + }, + { + "name": "其它区", + "code": "710113" + } + ], + "name": "台北市", + "code": "7101" + }, + { + "children": [ + { + "name": "新兴区", + "code": "710201" + }, + { + "name": "前金区", + "code": "710202" + }, + { + "name": "芩雅区", + "code": "710203" + }, + { + "name": "盐埕区", + "code": "710204" + }, + { + "name": "鼓山区", + "code": "710205" + }, + { + "name": "旗津区", + "code": "710206" + }, + { + "name": "前镇区", + "code": "710207" + }, + { + "name": "三民区", + "code": "710208" + }, + { + "name": "左营区", + "code": "710209" + }, + { + "name": "楠梓区", + "code": "710210" + }, + { + "name": "小港区", + "code": "710211" + }, + { + "name": "其它区", + "code": "710212" + } + ], + "name": "高雄市", + "code": "7102" + }, + { + "children": [ + { + "name": "中西区", + "code": "710301" + }, + { + "name": "东区", + "code": "710302" + }, + { + "name": "南区", + "code": "710303" + }, + { + "name": "北区", + "code": "710304" + }, + { + "name": "安平区", + "code": "710305" + }, + { + "name": "安南区", + "code": "710306" + }, + { + "name": "其它区", + "code": "710307" + } + ], + "name": "台南市", + "code": "7103" + }, + { + "children": [ + { + "name": "中区", + "code": "710401" + }, + { + "name": "东区", + "code": "710402" + }, + { + "name": "南区", + "code": "710403" + }, + { + "name": "西区", + "code": "710404" + }, + { + "name": "北区", + "code": "710405" + }, + { + "name": "北屯区", + "code": "710406" + }, + { + "name": "西屯区", + "code": "710407" + }, + { + "name": "南屯区", + "code": "710408" + }, + { + "name": "其它区", + "code": "710409" + } + ], + "name": "台中市", + "code": "7104" + }, + { + "name": "金门县", + "code": "7105" + }, + { + "name": "南投县", + "code": "7106" + }, + { + "children": [ + { + "name": "仁爱区", + "code": "710701" + }, + { + "name": "信义区", + "code": "710702" + }, + { + "name": "中正区", + "code": "710703" + }, + { + "name": "中山区", + "code": "710704" + }, + { + "name": "安乐区", + "code": "710705" + }, + { + "name": "暖暖区", + "code": "710706" + }, + { + "name": "七堵区", + "code": "710707" + }, + { + "name": "其它区", + "code": "710708" + } + ], + "name": "基隆市", + "code": "7107" + }, + { + "children": [ + { + "name": "东区", + "code": "710801" + }, + { + "name": "北区", + "code": "710802" + }, + { + "name": "香山区", + "code": "710803" + }, + { + "name": "其它区", + "code": "710804" + } + ], + "name": "新竹市", + "code": "7108" + }, + { + "children": [ + { + "name": "东区", + "code": "710901" + }, + { + "name": "西区", + "code": "710902" + }, + { + "name": "其它区", + "code": "710903" + } + ], + "name": "嘉义市", + "code": "7109" + }, + { + "name": "新北市", + "code": "7111" + }, + { + "name": "宜兰县", + "code": "7112" + }, + { + "name": "新竹县", + "code": "7113" + }, + { + "name": "桃园县", + "code": "7114" + }, + { + "name": "苗栗县", + "code": "7115" + }, + { + "name": "彰化县", + "code": "7117" + }, + { + "name": "嘉义县", + "code": "7119" + }, + { + "name": "云林县", + "code": "7121" + }, + { + "name": "屏东县", + "code": "7124" + }, + { + "name": "台东县", + "code": "7125" + }, + { + "name": "花莲县", + "code": "7126" + }, + { + "name": "澎湖县", + "code": "7127" + } + ], + "name": "台湾省", + "code": "71" + }, + { + "children": [ + { + "children": [ + { + "name": "中西区", + "code": "810101" + }, + { + "name": "湾仔", + "code": "810102" + }, + { + "name": "东区", + "code": "810103" + }, + { + "name": "南区", + "code": "810104" + } + ], + "name": "香港岛", + "code": "8101" + }, + { + "children": [ + { + "name": "九龙城区", + "code": "810201" + }, + { + "name": "油尖旺区", + "code": "810202" + }, + { + "name": "深水埗区", + "code": "810203" + }, + { + "name": "黄大仙区", + "code": "810204" + }, + { + "name": "观塘区", + "code": "810205" + } + ], + "name": "九龙", + "code": "8102" + }, + { + "children": [ + { + "name": "北区", + "code": "810301" + }, + { + "name": "大埔区", + "code": "810302" + }, + { + "name": "沙田区", + "code": "810303" + }, + { + "name": "西贡区", + "code": "810304" + }, + { + "name": "元朗区", + "code": "810305" + }, + { + "name": "屯门区", + "code": "810306" + }, + { + "name": "荃湾区", + "code": "810307" + }, + { + "name": "葵青区", + "code": "810308" + }, + { + "name": "离岛区", + "code": "810309" + } + ], + "name": "新界", + "code": "8103" + } + ], + "name": "香港特别行政区", + "code": "81" + }, + { + "children": [ + { + "children": [ + { + "name": "花地玛堂区", + "code": "820101" + }, + { + "name": "花王堂区", + "code": "820102" + }, + { + "name": "大堂区", + "code": "820103" + }, + { + "name": "望德堂区", + "code": "820104" + }, + { + "name": "风顺堂区", + "code": "820105" + }, + { + "name": "其他区", + "code": "820100" + } + ], + "name": "澳门半岛", + "code": "8201" + }, + { + "children": [ + { + "name": "嘉模堂区", + "code": "820201" + }, + { + "name": "其他区", + "code": "820200" + } + ], + "name": "离岛", + "code": "8202" + }, + { + "name": "圣方济各堂区", + "code": "820301" + }, + { + "name": "路凼填海区", + "code": "820401" + } + ], + "name": "澳门特别行政区", + "code": "82" + } + ] + }, + { + "name": "阿鲁巴", + "code": "ABW" + }, + { + "name": "阿富汗", + "code": "AFG", + "children": [ + { + "name": "赫拉特", + "code": "AFG_HEA" + }, + { + "name": "喀布尔", + "code": "AFG_KBL" + }, + { + "name": "坎大哈", + "code": "AFG_KDH" + }, + { + "name": "马扎里沙里夫", + "code": "AFG_MZR" + } + ] + }, + { + "name": "安圭拉", + "code": "AIA" + }, + { + "name": "奥兰群岛", + "code": "ALA" + }, + { + "name": "阿尔巴尼亚", + "code": "ALB", + "children": [ + { + "name": "爱尔巴桑", + "code": "ALB_EL" + }, + { + "name": "迪勃拉", + "code": "ALB_DI" + }, + { + "name": "地拉那", + "code": "ALB_TR" + }, + { + "name": "都拉斯", + "code": "ALB_DR" + }, + { + "name": "发罗拉", + "code": "ALB_VL" + }, + { + "name": "费里", + "code": "ALB_FR" + }, + { + "name": "吉诺卡斯特", + "code": "ALB_GJ" + }, + { + "name": "科尔察", + "code": "ALB_KO" + }, + { + "name": "库克斯", + "code": "ALB_KU" + }, + { + "name": "莱什", + "code": "ALB_LE" + }, + { + "name": "培拉特", + "code": "ALB_BR" + }, + { + "name": "斯库台", + "code": "ALB_SH" + } + ] + }, + { + "name": "阿尔及利亚", + "code": "ALG" + }, + { + "name": "安道尔", + "code": "AND", + "children": [ + { + "name": "卡尼略", + "code": "AND_2" + }, + { + "name": "恩坎普", + "code": "AND_3" + }, + { + "name": "马萨纳", + "code": "AND_4" + }, + { + "name": "奥尔迪诺", + "code": "AND_5" + }, + { + "name": "圣胡利娅-德洛里亚", + "code": "AND_6" + }, + { + "name": "安道尔城", + "code": "AND_7" + }, + { + "name": "莱塞斯卡尔德-恩戈尔达", + "code": "AND_8" + } + ] + }, + { + "name": "安哥拉", + "code": "ANG" + }, + { + "name": "荷属安地列斯", + "code": "AHO" + }, + { + "name": "安提瓜和巴布达", + "code": "ANT" + }, + { + "name": "阿拉伯联合酋长国", + "code": "ARE", + "children": [ + { + "name": "阿布扎比", + "code": "ARE_AZ" + }, + { + "name": "艾因", + "code": "ARE_AL" + }, + { + "name": "迪拜", + "code": "ARE_DU" + }, + { + "name": "沙迦", + "code": "ARE_SH" + } + ] + }, + { + "name": "阿根廷", + "code": "ARG", + "children": [ + { + "name": "巴拉那", + "code": "ARG_PRA" + }, + { + "name": "别德马", + "code": "ARG_VDM" + }, + { + "name": "波萨达斯", + "code": "ARG_PSS" + }, + { + "name": "布兰卡港", + "code": "ARG_BHI" + }, + { + "name": "布宜诺斯艾利斯", + "code": "ARG_BUE" + }, + { + "name": "福莫萨", + "code": "ARG_FMA" + }, + { + "name": "胡胡伊", + "code": "ARG_JUJ" + }, + { + "name": "卡塔马卡", + "code": "ARG_CTC" + }, + { + "name": "科尔多瓦", + "code": "ARG_COR" + }, + { + "name": "科连特斯", + "code": "ARG_CNQ" + }, + { + "name": "克劳斯城", + "code": "ARG_VLK" + }, + { + "name": "肯考迪娅", + "code": "ARG_COC" + }, + { + "name": "拉里奥哈", + "code": "ARG_IRJ" + }, + { + "name": "拉普拉塔", + "code": "ARG_LPG" + }, + { + "name": "雷西斯滕匹亚", + "code": "ARG_RES" + }, + { + "name": "里奥加耶戈斯", + "code": "ARG_RGL" + }, + { + "name": "里奥夸尔托", + "code": "ARG_RCU" + }, + { + "name": "里瓦达维亚海军准将城", + "code": "ARG_CRD" + }, + { + "name": "罗萨里奥", + "code": "ARG_ROS" + }, + { + "name": "罗森", + "code": "ARG_RWO" + }, + { + "name": "马德普拉塔", + "code": "ARG_MDQ" + }, + { + "name": "门多萨", + "code": "ARG_MDZ" + }, + { + "name": "内乌肯", + "code": "ARG_NQN" + }, + { + "name": "萨尔塔", + "code": "ARG_SLA" + }, + { + "name": "圣地亚哥-德尔埃斯特罗", + "code": "ARG_SDE" + }, + { + "name": "圣菲", + "code": "ARG_SFN" + }, + { + "name": "圣胡安", + "code": "ARG_UAQ" + }, + { + "name": "圣拉斐尔", + "code": "ARG_AFA" + }, + { + "name": "圣路易斯", + "code": "ARG_LUQ" + }, + { + "name": "圣罗莎", + "code": "ARG_RSA" + }, + { + "name": "圣米格尔-德图库曼", + "code": "ARG_SMC" + }, + { + "name": "圣尼古拉斯", + "code": "ARG_SNS" + }, + { + "name": "特雷利乌", + "code": "ARG_REL" + }, + { + "name": "乌斯怀亚", + "code": "ARG_USH" + } + ] + }, + { + "name": "亚美尼亚", + "code": "ARM", + "children": [ + { + "name": "阿尔马维尔", + "code": "ARM_ARM" + }, + { + "name": "阿拉加措特恩", + "code": "ARM_AGT" + }, + { + "name": "阿拉拉特", + "code": "ARM_ARA" + }, + { + "name": "埃里温市", + "code": "ARM_EVN" + }, + { + "name": "格加尔库尼克", + "code": "ARM_GEG" + }, + { + "name": "科泰克", + "code": "ARM_KOT" + }, + { + "name": "洛里", + "code": "ARM_LOR" + }, + { + "name": "塔武什", + "code": "ARM_TAV" + }, + { + "name": "瓦约茨·佐尔", + "code": "ARM_VAY" + }, + { + "name": "希拉克", + "code": "ARM_SHI" + }, + { + "name": "休尼克", + "code": "ARM_SYU" + } + ] + }, + { + "name": "阿森松岛", + "code": "ASC" + }, + { + "name": "美属萨摩亚", + "code": "ASM", + "children": [ + { + "name": "阿纳", + "code": "ASM_AAN" + }, + { + "name": "阿图阿", + "code": "ASM_ATU" + }, + { + "name": "艾加伊勒泰", + "code": "ASM_AIT" + }, + { + "name": "法塞莱莱阿加", + "code": "ASM_FAA" + }, + { + "name": "加盖福毛加", + "code": "ASM_GFG" + }, + { + "name": "加加埃毛加", + "code": "ASM_GMG" + }, + { + "name": "帕劳利", + "code": "ASM_PAL" + }, + { + "name": "萨图帕伊泰阿", + "code": "ASM_SAT" + }, + { + "name": "萨瓦伊岛", + "code": "ASM_SAV" + }, + { + "name": "图阿马萨加", + "code": "ASM_TUA" + }, + { + "name": "瓦奥福诺蒂", + "code": "ASM_VAF" + }, + { + "name": "韦西加诺", + "code": "ASM_VAI" + }, + { + "name": "乌波卢岛", + "code": "ASM_UPO" + } + ] + }, + { + "name": "南极洲", + "code": "ATA" + }, + { + "name": "法属南部领地", + "code": "ATF" + }, + { + "name": "安提瓜岛和巴布达", + "code": "ATG" + }, + { + "name": "澳大利亚", + "code": "AUS", + "children": [ + { + "name": "北部地区", + "code": "AUS_NT" + }, + { + "name": "堪培拉", + "code": "AUS_ACT" + }, + { + "name": "昆士兰", + "code": "AUS_QLD" + }, + { + "name": "南澳大利亚", + "code": "AUS_SA" + }, + { + "name": "塔斯马尼亚", + "code": "AUS_TAS" + }, + { + "name": "维多利亚", + "code": "AUS_VIC" + }, + { + "name": "西澳大利亚", + "code": "AUS_AWA" + }, + { + "name": "新南威尔士", + "code": "AUS_NSW" + } + ] + }, + { + "name": "奥地利", + "code": "AUT", + "children": [ + { + "name": "布尔根兰", + "code": "AUT_BUR" + }, + { + "name": "蒂罗尔", + "code": "AUT_TYR" + }, + { + "name": "福拉尔贝格", + "code": "AUT_VOR" + }, + { + "name": "克恩顿", + "code": "AUT_CAT" + }, + { + "name": "萨尔茨堡", + "code": "AUT_SZG" + }, + { + "name": "上奥地利", + "code": "AUT_UAU" + }, + { + "name": "施蒂利亚", + "code": "AUT_STY" + }, + { + "name": "维也纳", + "code": "AUT_VDD" + }, + { + "name": "下奥地利", + "code": "AUT_LAU" + } + ] + }, + { + "name": "阿塞拜疆", + "code": "AZE", + "children": [ + { + "name": "阿布歇隆", + "code": "AZE_ABS" + }, + { + "name": "哈奇马斯", + "code": "AZE_XAC" + }, + { + "name": "卡尔巴卡尔", + "code": "AZE_KAL" + }, + { + "name": "卡扎赫", + "code": "AZE_QAZ" + }, + { + "name": "连科兰", + "code": "AZE_LAN" + }, + { + "name": "密尔-卡拉巴赫", + "code": "AZE_MQA" + }, + { + "name": "穆甘-萨连", + "code": "AZE_MSA" + }, + { + "name": "纳戈尔诺-卡拉巴赫", + "code": "AZE_NQA" + }, + { + "name": "纳希切万", + "code": "AZE_NX" + }, + { + "name": "普利亚拉克斯", + "code": "AZE_PRI" + }, + { + "name": "舍基", + "code": "AZE_SA" + }, + { + "name": "苏姆盖特", + "code": "AZE_SMC" + }, + { + "name": "锡尔万", + "code": "AZE_SIR" + }, + { + "name": "占贾", + "code": "AZE_GA" + } + ] + }, + { + "name": "巴哈马", + "code": "BAH" + }, + { + "name": "孟加拉国", + "code": "BAN" + }, + { + "name": "巴巴多斯", + "code": "BAR" + }, + { + "name": "布隆迪", + "code": "BDI", + "children": [ + { + "name": "布班扎", + "code": "BDI_BB" + }, + { + "name": "布鲁里", + "code": "BDI_BR" + }, + { + "name": "布琼布拉城市", + "code": "BDI_BM" + }, + { + "name": "布琼布拉乡村", + "code": "BDI_BU" + }, + { + "name": "恩戈齐", + "code": "BDI_NG" + }, + { + "name": "基龙多", + "code": "BDI_KI" + }, + { + "name": "基特加", + "code": "BDI_GI" + }, + { + "name": "卡鲁济", + "code": "BDI_KR" + }, + { + "name": "卡扬扎", + "code": "BDI_KY" + }, + { + "name": "坎库佐", + "code": "BDI_CA" + }, + { + "name": "鲁塔纳", + "code": "BDI_RT" + }, + { + "name": "鲁伊吉", + "code": "BDI_RY" + }, + { + "name": "马坎巴", + "code": "BDI_MA" + }, + { + "name": "穆拉姆维亚", + "code": "BDI_MU" + }, + { + "name": "穆瓦洛", + "code": "BDI_MW" + }, + { + "name": "穆因加", + "code": "BDI_MY" + }, + { + "name": "锡比托凯", + "code": "BDI_CI" + } + ] + }, + { + "name": "比利时", + "code": "BEL", + "children": [ + { + "name": "埃诺", + "code": "BEL_WHT" + }, + { + "name": "安特卫普", + "code": "BEL_VAN" + }, + { + "name": "布拉班特-瓦隆", + "code": "BEL_WBR" + }, + { + "name": "布鲁塞尔", + "code": "BEL_BRU" + }, + { + "name": "东佛兰德", + "code": "BEL_VOV" + }, + { + "name": "佛兰芒-布拉班特", + "code": "BEL_VBR" + }, + { + "name": "列日", + "code": "BEL_WLG" + }, + { + "name": "林堡", + "code": "BEL_VLI" + }, + { + "name": "卢森堡", + "code": "BEL_WLX" + }, + { + "name": "那慕尔", + "code": "BEL_WNA" + }, + { + "name": "西佛兰德", + "code": "BEL_VWV" + } + ] + }, + { + "name": "贝宁", + "code": "BEN", + "children": [ + { + "name": "阿黎博里", + "code": "BEN_AL" + }, + { + "name": "阿塔科拉", + "code": "BEN_AK" + }, + { + "name": "滨海", + "code": "BEN_LI" + }, + { + "name": "波希康市", + "code": "BEN_BOH" + }, + { + "name": "博尔古", + "code": "BEN_BO" + }, + { + "name": "大西洋", + "code": "BEN_AQ" + }, + { + "name": "高原", + "code": "BEN_PL" + }, + { + "name": "库福", + "code": "BEN_KO" + }, + { + "name": "莫诺", + "code": "BEN_MO" + }, + { + "name": "丘陵", + "code": "BEN_CO" + }, + { + "name": "韦梅", + "code": "BEN_OU" + }, + { + "name": "峡谷", + "code": "BEN_DO" + }, + { + "name": "祖", + "code": "BEN_ZO" + } + ] + }, + { + "name": "布基纳法索", + "code": "BFA", + "children": [ + { + "name": "巴雷", + "code": "BFA_BAL" + }, + { + "name": "巴姆", + "code": "BFA_BAM" + }, + { + "name": "巴瓦", + "code": "BFA_BAN" + }, + { + "name": "巴泽加", + "code": "BFA_BAZ" + }, + { + "name": "波尼", + "code": "BFA_PON" + }, + { + "name": "布尔古", + "code": "BFA_BLG" + }, + { + "name": "布尔基恩德", + "code": "BFA_BOK" + }, + { + "name": "布古里巴", + "code": "BFA_BOR" + }, + { + "name": "冈祖尔古", + "code": "BFA_GAN" + }, + { + "name": "古尔马", + "code": "BFA_GOU" + }, + { + "name": "济罗", + "code": "BFA_ZIR" + }, + { + "name": "卡焦戈", + "code": "BFA_KAD" + }, + { + "name": "科蒙加里", + "code": "BFA_KOO" + }, + { + "name": "科莫埃", + "code": "BFA_COM" + }, + { + "name": "孔皮恩加", + "code": "BFA_KOP" + }, + { + "name": "孔西", + "code": "BFA_KOS" + }, + { + "name": "库尔佩罗戈", + "code": "BFA_KOL" + }, + { + "name": "库尔维奥戈", + "code": "BFA_KOW" + }, + { + "name": "库里滕加", + "code": "BFA_KOT" + }, + { + "name": "雷拉巴", + "code": "BFA_LER" + }, + { + "name": "罗卢姆", + "code": "BFA_LOR" + }, + { + "name": "穆翁", + "code": "BFA_MOU" + }, + { + "name": "纳门滕加", + "code": "BFA_NAM" + }, + { + "name": "纳乌里", + "code": "BFA_NAH" + }, + { + "name": "纳亚拉", + "code": "BFA_NAY" + }, + { + "name": "尼亚尼亚", + "code": "BFA_GNA" + }, + { + "name": "努姆比埃尔", + "code": "BFA_NOU" + }, + { + "name": "帕索雷", + "code": "BFA_PAS" + }, + { + "name": "塞诺", + "code": "BFA_SEN" + }, + { + "name": "桑吉", + "code": "BFA_SAG" + }, + { + "name": "桑马滕加", + "code": "BFA_SAM" + }, + { + "name": "苏鲁", + "code": "BFA_SOR" + }, + { + "name": "苏姆", + "code": "BFA_SOM" + }, + { + "name": "塔波阿", + "code": "BFA_TAP" + }, + { + "name": "图伊", + "code": "BFA_TUY" + }, + { + "name": "乌埃", + "code": "BFA_HOU" + }, + { + "name": "乌布里滕加", + "code": "BFA_OUB" + }, + { + "name": "乌达兰", + "code": "BFA_OUD" + }, + { + "name": "锡西里", + "code": "BFA_SIS" + }, + { + "name": "亚加", + "code": "BFA_YAG" + }, + { + "name": "亚滕加", + "code": "BFA_YAT" + }, + { + "name": "伊奥巴", + "code": "BFA_IOA" + }, + { + "name": "宗德韦奥戈", + "code": "BFA_ZOW" + }, + { + "name": "宗多马", + "code": "BFA_ZOD" + } + ] + }, + { + "name": "孟加拉", + "code": "BGD", + "children": [ + { + "name": "达卡", + "code": "BGD_DAC" + }, + { + "name": "吉大港", + "code": "BGD_CGP" + }, + { + "name": "库尔纳", + "code": "BGD_KHL" + } + ] + }, + { + "name": "不丹", + "code": "BHU" + }, + { + "name": "波斯尼亚和黑塞哥维那", + "code": "BIH", + "children": [ + { + "name": "波萨维纳", + "code": "BIH_FPO" + }, + { + "name": "波斯尼亚-波德里涅", + "code": "BIH_FBP" + }, + { + "name": "多米斯拉夫格勒", + "code": "BIH_FTO" + }, + { + "name": "黑塞哥维那-涅雷特瓦", + "code": "BIH_FHN" + }, + { + "name": "萨拉热窝", + "code": "BIH_FSA" + }, + { + "name": "图兹拉-波德里涅", + "code": "BIH_FTU" + }, + { + "name": "乌纳-萨纳", + "code": "BIH_FUS" + }, + { + "name": "西波斯尼亚", + "code": "BIH_FHB" + }, + { + "name": "西黑塞哥维那", + "code": "BIH_FZH" + }, + { + "name": "泽尼察-多博伊", + "code": "BIH_FZE" + }, + { + "name": "中波斯尼亚", + "code": "BIH_FSB" + } + ] + }, + { + "name": "伯利兹", + "code": "BIZ" + }, + { + "name": "白俄罗斯", + "code": "BLR", + "children": [ + { + "name": "布列斯特", + "code": "BLR_BR" + }, + { + "name": "戈梅利", + "code": "BLR_HO" + }, + { + "name": "格罗德诺", + "code": "BLR_HR" + }, + { + "name": "明斯克市", + "code": "BLR_MI" + }, + { + "name": "莫吉廖夫", + "code": "BLR_MA" + }, + { + "name": "维捷布斯克", + "code": "BLR_VI" + } + ] + }, + { + "name": "百慕大", + "code": "BMU" + }, + { + "name": "玻利维亚", + "code": "BOL", + "children": [ + { + "name": "奥尔托", + "code": "BOL_ALT" + }, + { + "name": "奥鲁罗", + "code": "BOL_ORU" + }, + { + "name": "贝尼", + "code": "BOL_BEN" + }, + { + "name": "波多西", + "code": "BOL_POI" + }, + { + "name": "基拉科洛", + "code": "BOL_QUI" + }, + { + "name": "科恰班巴", + "code": "BOL_CBB" + }, + { + "name": "拉巴斯", + "code": "BOL_LPB" + }, + { + "name": "潘多", + "code": "BOL_PAN" + }, + { + "name": "丘基萨卡", + "code": "BOL_CHU" + }, + { + "name": "萨卡巴", + "code": "BOL_SAC" + }, + { + "name": "圣克鲁斯", + "code": "BOL_SRZ" + }, + { + "name": "塔里哈", + "code": "BOL_TJA" + } + ] + }, + { + "name": "博茨瓦纳", + "code": "BOT" + }, + { + "name": "巴西", + "code": "BRA", + "children": [ + { + "name": "阿克里", + "code": "BRA_AC" + }, + { + "name": "阿拉戈斯", + "code": "BRA_AL" + }, + { + "name": "阿马帕", + "code": "BRA_AP" + }, + { + "name": "巴拉那", + "code": "BRA_PR" + }, + { + "name": "巴西利亚", + "code": "BRA_BSB" + }, + { + "name": "巴伊亚", + "code": "BRA_BA" + }, + { + "name": "北里奥格兰德", + "code": "BRA_RN" + }, + { + "name": "伯南布哥", + "code": "BRA_PE" + }, + { + "name": "戈亚斯", + "code": "BRA_GO" + }, + { + "name": "朗多尼亚", + "code": "BRA_RO" + }, + { + "name": "里约热内卢", + "code": "BRA_RJ" + }, + { + "name": "罗赖马", + "code": "BRA_RR" + }, + { + "name": "马拉尼昂", + "code": "BRA_MA" + }, + { + "name": "马托格罗索", + "code": "BRA_MT" + }, + { + "name": "米纳斯吉拉斯", + "code": "BRA_MG" + }, + { + "name": "南里奥格兰德", + "code": "BRA_RS" + }, + { + "name": "南马托格罗索", + "code": "BRA_MS" + }, + { + "name": "帕拉", + "code": "BRA_PA" + }, + { + "name": "帕拉伊巴", + "code": "BRA_PB" + }, + { + "name": "皮奥伊", + "code": "BRA_PI" + }, + { + "name": "塞阿拉", + "code": "BRA_CE" + }, + { + "name": "塞尔希培", + "code": "BRA_SE" + }, + { + "name": "圣埃斯皮里图", + "code": "BRA_ES" + }, + { + "name": "圣保罗", + "code": "BRA_SP" + }, + { + "name": "圣卡塔琳娜", + "code": "BRA_SC" + }, + { + "name": "托坎廷斯", + "code": "BRA_TO" + }, + { + "name": "亚马孙", + "code": "BRA_AM" + } + ] + }, + { + "name": "巴巴多斯岛", + "code": "BRB" + }, + { + "name": "巴林", + "code": "BRN" + }, + { + "name": "文莱", + "code": "BRU" + }, + { + "name": "保加利亚", + "code": "BUL" + }, + { + "name": "布基纳国", + "code": "BUR" + }, + { + "name": "布韦岛", + "code": "BVT" + }, + { + "name": "中非共和国", + "code": "CAF", + "children": [ + { + "name": "巴明吉-班戈兰", + "code": "CAF_BB" + }, + { + "name": "班吉直辖市", + "code": "CAF_BGF" + }, + { + "name": "宾博", + "code": "CAF_BI" + }, + { + "name": "凯莫", + "code": "CAF_KG" + }, + { + "name": "洛巴伊", + "code": "CAF_LB" + }, + { + "name": "曼贝雷-卡代", + "code": "CAF_HS" + }, + { + "name": "姆博穆", + "code": "CAF_MB" + }, + { + "name": "纳纳-格里比齐", + "code": "CAF_KB" + }, + { + "name": "纳纳-曼贝雷", + "code": "CAF_NM" + }, + { + "name": "桑加-姆巴埃雷", + "code": "CAF_SE" + }, + { + "name": "上科托", + "code": "CAF_HK" + }, + { + "name": "上姆博穆", + "code": "CAF_HM" + }, + { + "name": "瓦卡", + "code": "CAF_UK" + }, + { + "name": "瓦卡加", + "code": "CAF_VK" + }, + { + "name": "瓦姆", + "code": "CAF_AC" + }, + { + "name": "瓦姆-彭代", + "code": "CAF_OP" + }, + { + "name": "翁贝拉-姆波科", + "code": "CAF_MP" + }, + { + "name": "下科托", + "code": "CAF_BK" + } + ] + }, + { + "name": "柬埔寨", + "code": "CAM" + }, + { + "name": "加拿大", + "code": "CAN", + "children": [ + { + "name": "阿伯茨福", + "code": "CAN_ABB" + }, + { + "name": "埃德蒙顿", + "code": "CAN_EDM" + }, + { + "name": "奥沙瓦", + "code": "CAN_OSH" + }, + { + "name": "巴里", + "code": "CAN_BAR" + }, + { + "name": "布列塔尼角", + "code": "CAN_CBR" + }, + { + "name": "多伦多", + "code": "CAN_TOR" + }, + { + "name": "弗雷德里顿", + "code": "CAN_FRE" + }, + { + "name": "圭尔夫", + "code": "CAN_GLP" + }, + { + "name": "哈利法克斯", + "code": "CAN_HAL" + }, + { + "name": "哈密尔顿", + "code": "CAN_HAM" + }, + { + "name": "怀特霍斯", + "code": "CAN_YXY" + }, + { + "name": "基劳纳", + "code": "CAN_KWL" + }, + { + "name": "基奇纳", + "code": "CAN_BRP" + }, + { + "name": "金斯敦", + "code": "CAN_KGN" + }, + { + "name": "卡里加里", + "code": "CAN_CAL" + }, + { + "name": "魁北克", + "code": "CAN_QUE" + }, + { + "name": "里贾纳", + "code": "CAN_REG" + }, + { + "name": "伦敦", + "code": "CAN_LOD" + }, + { + "name": "蒙特利尔", + "code": "CAN_MTR" + }, + { + "name": "萨德伯里", + "code": "CAN_SUD" + }, + { + "name": "萨斯卡通", + "code": "CAN_SAK" + }, + { + "name": "三河城", + "code": "CAN_TRR" + }, + { + "name": "桑德贝", + "code": "CAN_THU" + }, + { + "name": "舍布鲁克", + "code": "CAN_SBE" + }, + { + "name": "圣卡塔琳娜", + "code": "CAN_SCA" + }, + { + "name": "圣约翰斯", + "code": "CAN_SJB" + }, + { + "name": "维多利亚", + "code": "CAN_VIC" + }, + { + "name": "温哥华", + "code": "CAN_VAN" + }, + { + "name": "温尼伯", + "code": "CAN_WNP" + }, + { + "name": "温莎", + "code": "CAN_WDR" + }, + { + "name": "渥太华", + "code": "CAN_OTT" + }, + { + "name": "夏洛特敦", + "code": "CAN_CHA" + }, + { + "name": "耶洛奈夫", + "code": "CAN_YZF" + }, + { + "name": "伊魁特", + "code": "CAN_IQL" + } + ] + }, + { + "name": "科科斯群岛", + "code": "CCK" + }, + { + "name": "刚果", + "code": "CGO" + }, + { + "name": "乍得", + "code": "CHA" + }, + { + "name": "智利", + "code": "CHI" + }, + { + "name": "科特迪瓦", + "code": "CIV", + "children": [ + { + "name": "阿涅比", + "code": "CIV_AG" + }, + { + "name": "巴芬", + "code": "CIV_BF" + }, + { + "name": "邦达马河谷", + "code": "CIV_VB" + }, + { + "name": "登盖莱", + "code": "CIV_DE" + }, + { + "name": "恩济-科莫埃", + "code": "CIV_NC" + }, + { + "name": "弗罗马格尔", + "code": "CIV_FR" + }, + { + "name": "湖泊", + "code": "CIV_LC" + }, + { + "name": "马拉韦", + "code": "CIV_MR" + }, + { + "name": "南科莫埃", + "code": "CIV_SC" + }, + { + "name": "萨桑德拉", + "code": "CIV_HT" + }, + { + "name": "萨瓦纳", + "code": "CIV_SV" + }, + { + "name": "山地", + "code": "CIV_DH" + }, + { + "name": "沃罗杜古", + "code": "CIV_WR" + }, + { + "name": "下萨桑德拉", + "code": "CIV_BS" + }, + { + "name": "泻湖", + "code": "CIV_LG" + }, + { + "name": "赞赞", + "code": "CIV_ZA" + }, + { + "name": "中卡瓦利", + "code": "CIV_MV" + }, + { + "name": "中科莫埃", + "code": "CIV_MC" + } + ] + }, + { + "name": "喀麦隆", + "code": "CMR", + "children": [ + { + "name": "阿达马瓦", + "code": "CMR_ADA" + }, + { + "name": "北部", + "code": "CMR_NOR" + }, + { + "name": "北端", + "code": "CMR_EXN" + }, + { + "name": "滨海", + "code": "CMR_LIT" + }, + { + "name": "东部", + "code": "CMR_EST" + }, + { + "name": "南部", + "code": "CMR_SUD" + }, + { + "name": "西北", + "code": "CMR_NOT" + }, + { + "name": "西部", + "code": "CMR_OUE" + }, + { + "name": "西南", + "code": "CMR_SOU" + }, + { + "name": "中央", + "code": "CMR_CEN" + } + ] + }, + { + "name": "刚果民主共和国", + "code": "COD" + }, + { + "name": "库克群岛", + "code": "COK" + }, + { + "name": "哥伦比亚", + "code": "COL", + "children": [ + { + "name": "阿劳卡", + "code": "COL_ARA" + }, + { + "name": "安提奥基亚", + "code": "COL_ANT" + }, + { + "name": "北桑坦德", + "code": "COL_NDS" + }, + { + "name": "波哥大首都区", + "code": "COL_BDC" + }, + { + "name": "博利瓦尔", + "code": "COL_BOL" + }, + { + "name": "博亚卡", + "code": "COL_BOY" + }, + { + "name": "大西洋", + "code": "COL_ATL" + }, + { + "name": "瓜维亚雷", + "code": "COL_GVR" + }, + { + "name": "瓜希拉", + "code": "COL_GJR" + }, + { + "name": "瓜伊尼亚", + "code": "COL_GNA" + }, + { + "name": "金迪奥", + "code": "COL_QUI" + }, + { + "name": "卡尔达斯", + "code": "COL_CAL" + }, + { + "name": "卡克塔", + "code": "COL_CAQ" + }, + { + "name": "卡萨纳雷", + "code": "COL_CAS" + }, + { + "name": "考卡", + "code": "COL_CAU" + }, + { + "name": "考卡山谷", + "code": "COL_VDC" + }, + { + "name": "科尔多巴", + "code": "COL_COR" + }, + { + "name": "昆迪纳马卡", + "code": "COL_CAM" + }, + { + "name": "利萨拉尔达", + "code": "COL_RIS" + }, + { + "name": "马格达雷那", + "code": "COL_MAG" + }, + { + "name": "梅塔", + "code": "COL_MET" + }, + { + "name": "纳里尼奥", + "code": "COL_NAR" + }, + { + "name": "普图马约", + "code": "COL_PUT" + }, + { + "name": "乔科", + "code": "COL_CHO" + }, + { + "name": "塞萨尔", + "code": "COL_CES" + }, + { + "name": "桑坦德", + "code": "COL_SAN" + }, + { + "name": "圣安德烈斯-普罗维登西亚", + "code": "COL_SAP" + }, + { + "name": "苏克雷", + "code": "COL_SUC" + }, + { + "name": "托利马", + "code": "COL_TOL" + }, + { + "name": "维查达", + "code": "COL_VIC" + }, + { + "name": "沃佩斯", + "code": "COL_VAU" + }, + { + "name": "乌伊拉", + "code": "COL_HUI" + }, + { + "name": "亚马孙", + "code": "COL_AMZ" + } + ] + }, + { + "name": "科摩罗", + "code": "COM" + }, + { + "name": "佛得角", + "code": "CPV", + "children": [ + { + "name": "保尔", + "code": "CPV_PA" + }, + { + "name": "波多诺伏", + "code": "CPV_PN" + }, + { + "name": "博阿维斯塔岛", + "code": "CPV_BV" + }, + { + "name": "布拉瓦岛", + "code": "CPV_BR" + }, + { + "name": "大里贝拉", + "code": "CPV_RG" + }, + { + "name": "福古岛", + "code": "CPV_FO" + }, + { + "name": "马尤岛", + "code": "CPV_MA" + }, + { + "name": "莫斯特罗", + "code": "CPV_MO" + }, + { + "name": "普拉亚", + "code": "CPV_PR" + }, + { + "name": "萨尔岛", + "code": "CPV_SL" + }, + { + "name": "圣安唐岛", + "code": "CPV_SA" + }, + { + "name": "圣地亚哥岛", + "code": "CPV_IA" + }, + { + "name": "圣多明戈", + "code": "CPV_SD" + }, + { + "name": "圣菲利普", + "code": "CPV_SF" + }, + { + "name": "圣卡塔琳娜", + "code": "CPV_CA" + }, + { + "name": "圣克鲁斯", + "code": "CPV_CR" + }, + { + "name": "圣米戈尔", + "code": "CPV_SM" + }, + { + "name": "圣尼古拉岛", + "code": "CPV_SN" + }, + { + "name": "圣维森特岛", + "code": "CPV_SV" + }, + { + "name": "塔拉法尔", + "code": "CPV_TA" + } + ] + }, + { + "name": "哥斯达黎加", + "code": "CRC" + }, + { + "name": "克罗地亚", + "code": "CRO" + }, + { + "name": "古巴", + "code": "CUB", + "children": [ + { + "name": "比那尔德里奥", + "code": "CUB_1" + }, + { + "name": "哈瓦那", + "code": "CUB_2" + }, + { + "name": "哈瓦那城", + "code": "CUB_3" + }, + { + "name": "马坦萨斯", + "code": "CUB_4" + }, + { + "name": "比亚克拉拉", + "code": "CUB_5" + }, + { + "name": "西恩富戈斯", + "code": "CUB_6" + }, + { + "name": "圣斯皮里图斯", + "code": "CUB_7" + }, + { + "name": "谢戈德阿维拉", + "code": "CUB_8" + }, + { + "name": "卡马圭", + "code": "CUB_9" + }, + { + "name": "拉斯图纳斯", + "code": "CUB_10" + }, + { + "name": "奥尔金", + "code": "CUB_11" + }, + { + "name": "格拉玛", + "code": "CUB_12" + }, + { + "name": "圣地亚哥", + "code": "CUB_13" + }, + { + "name": "关塔那摩", + "code": "CUB_14" + }, + { + "name": "青年岛特区", + "code": "CUB_99" + }, + { + "name": "马亚里", + "code": "CUB_MAY" + }, + { + "name": "曼萨尼罗", + "code": "CUB_MZO" + } + ] + }, + { + "name": "圣诞岛", + "code": "CXR" + }, + { + "name": "开曼群岛", + "code": "CYM" + }, + { + "name": "塞浦路斯", + "code": "CYP", + "children": [ + { + "name": "尼科西亚", + "code": "CYP_1" + }, + { + "name": "利马索尔", + "code": "CYP_2" + }, + { + "name": "拉纳卡", + "code": "CYP_3" + }, + { + "name": "法马古斯塔", + "code": "CYP_4" + }, + { + "name": "帕福斯", + "code": "CYP_5" + }, + { + "name": "凯里尼亚", + "code": "CYP_6" + } + ] + }, + { + "name": "捷克共和国", + "code": "CZE", + "children": [ + { + "name": "奥洛穆茨", + "code": "CZE_OL" + }, + { + "name": "比尔森", + "code": "CZE_PL" + }, + { + "name": "布拉格直辖市", + "code": "CZE_PR" + }, + { + "name": "赫拉德茨-克拉洛韦", + "code": "CZE_KR" + }, + { + "name": "卡罗维发利", + "code": "CZE_KA" + }, + { + "name": "利贝雷克", + "code": "CZE_LI" + }, + { + "name": "摩拉维亚-西里西亚", + "code": "CZE_MO" + }, + { + "name": "南摩拉维亚", + "code": "CZE_JC" + }, + { + "name": "帕尔杜比采", + "code": "CZE_PA" + }, + { + "name": "维索基纳", + "code": "CZE_VY" + }, + { + "name": "乌斯季", + "code": "CZE_US" + }, + { + "name": "中捷克", + "code": "CZE_ST" + }, + { + "name": "兹林", + "code": "CZE_ZL" + } + ] + }, + { + "name": "丹麦", + "code": "DEN" + }, + { + "name": "吉布提", + "code": "DJI", + "children": [ + { + "name": "阿里萨比赫区", + "code": "DJI_S" + }, + { + "name": "奥博克区", + "code": "DJI_O" + }, + { + "name": "迪基勒区", + "code": "DJI_K" + }, + { + "name": "塔朱拉区", + "code": "DJI_T" + } + ] + }, + { + "name": "多米尼克", + "code": "DMA" + }, + { + "name": "多米尼加共和国", + "code": "DOM" + }, + { + "name": "厄瓜多尔", + "code": "ECU", + "children": [ + { + "name": "阿苏艾", + "code": "ECU_A" + }, + { + "name": "埃尔奥罗", + "code": "ECU_O" + }, + { + "name": "埃斯梅拉尔达斯", + "code": "ECU_E" + }, + { + "name": "玻利瓦尔", + "code": "ECU_B" + }, + { + "name": "瓜亚斯", + "code": "ECU_G" + }, + { + "name": "加拉帕戈斯", + "code": "ECU_W" + }, + { + "name": "卡尔奇", + "code": "ECU_C" + }, + { + "name": "卡尼亚尔", + "code": "ECU_F" + }, + { + "name": "科托帕希", + "code": "ECU_X" + }, + { + "name": "洛哈", + "code": "ECU_L" + }, + { + "name": "洛斯里奥斯", + "code": "ECU_R" + }, + { + "name": "马纳比", + "code": "ECU_M" + }, + { + "name": "莫罗纳-圣地亚哥", + "code": "ECU_S" + }, + { + "name": "纳波,奥雷利亚纳", + "code": "ECU_D" + }, + { + "name": "帕斯塔萨", + "code": "ECU_Y" + }, + { + "name": "皮钦查", + "code": "ECU_P" + }, + { + "name": "钦博拉索", + "code": "ECU_H" + }, + { + "name": "萨莫拉-钦奇佩", + "code": "ECU_Z" + }, + { + "name": "苏昆毕奥斯", + "code": "ECU_U" + }, + { + "name": "通古拉瓦", + "code": "ECU_T" + }, + { + "name": "因巴布拉", + "code": "ECU_I" + } + ] + }, + { + "name": "埃及", + "code": "EGY", + "children": [ + { + "name": "阿斯旺", + "code": "EGY_ASW" + }, + { + "name": "古尔代盖", + "code": "EGY_GBY" + }, + { + "name": "开罗", + "code": "EGY_CAI" + }, + { + "name": "苏布拉开马", + "code": "EGY_SKH" + }, + { + "name": "亚历山大", + "code": "EGY_ALY" + } + ] + }, + { + "name": "厄立特里亚", + "code": "ERI", + "children": [ + { + "name": "安塞巴", + "code": "ERI_KE" + }, + { + "name": "北红海", + "code": "ERI_SK" + }, + { + "name": "加什·巴尔卡", + "code": "ERI_BR" + }, + { + "name": "南部", + "code": "ERI_DE" + }, + { + "name": "南红海", + "code": "ERI_DK" + }, + { + "name": "中部", + "code": "ERI_MA" + } + ] + }, + { + "name": "萨尔瓦多", + "code": "ESA" + }, + { + "name": "西班牙", + "code": "ESP", + "children": [ + { + "name": "阿尔梅里亚", + "code": "ESP_LEI" + }, + { + "name": "阿尔瓦塞特", + "code": "ESP_ALB" + }, + { + "name": "阿拉瓦", + "code": "ESP_ALA" + }, + { + "name": "阿利坎特", + "code": "ESP_ALC" + }, + { + "name": "阿斯图利亚斯", + "code": "ESP_AST" + }, + { + "name": "阿维拉", + "code": "ESP_AVI" + }, + { + "name": "奥伦塞", + "code": "ESP_ORE" + }, + { + "name": "巴达霍斯", + "code": "ESP_BJZ" + }, + { + "name": "巴利阿里", + "code": "ESP_BLR" + }, + { + "name": "巴利亚多利德", + "code": "ESP_VLL" + }, + { + "name": "巴伦西亚", + "code": "ESP_VLC" + }, + { + "name": "巴塞罗那", + "code": "ESP_BCN" + }, + { + "name": "比斯开", + "code": "ESP_VSE" + }, + { + "name": "布尔戈斯", + "code": "ESP_BUR" + }, + { + "name": "格拉纳达", + "code": "ESP_GRX" + }, + { + "name": "瓜达拉哈拉", + "code": "ESP_GUA" + }, + { + "name": "哈恩", + "code": "ESP_JAE" + }, + { + "name": "赫罗纳", + "code": "ESP_GRO" + }, + { + "name": "吉普斯夸", + "code": "ESP_GUI" + }, + { + "name": "加的斯", + "code": "ESP_CAD" + }, + { + "name": "卡塞雷斯", + "code": "ESP_CCS" + }, + { + "name": "卡斯蒂利亚", + "code": "ESP_CIR" + }, + { + "name": "卡斯特利翁", + "code": "ESP_CAS" + }, + { + "name": "科尔多瓦", + "code": "ESP_ODB" + }, + { + "name": "昆卡", + "code": "ESP_CUE" + }, + { + "name": "拉科鲁尼亚", + "code": "ESP_LCG" + }, + { + "name": "拉里奥哈", + "code": "ESP_ARL" + }, + { + "name": "拉斯帕尔马斯", + "code": "ESP_LPA" + }, + { + "name": "莱昂", + "code": "ESP_LEN" + }, + { + "name": "莱里达", + "code": "ESP_LLE" + }, + { + "name": "卢戈", + "code": "ESP_LGO" + }, + { + "name": "马德里", + "code": "ESP_MAD" + }, + { + "name": "马拉加", + "code": "ESP_AGP" + }, + { + "name": "穆尔西亚", + "code": "ESP_MJV" + }, + { + "name": "纳瓦拉", + "code": "ESP_NVV" + }, + { + "name": "帕伦西亚", + "code": "ESP_PAC" + }, + { + "name": "蓬特韦德拉", + "code": "ESP_PEV" + }, + { + "name": "萨拉戈萨", + "code": "ESP_ZAZ" + }, + { + "name": "萨拉曼卡", + "code": "ESP_SLM" + }, + { + "name": "萨莫拉", + "code": "ESP_ZMR" + }, + { + "name": "塞哥维亚", + "code": "ESP_SEG" + }, + { + "name": "塞维利亚", + "code": "ESP_SVQ" + }, + { + "name": "桑坦德", + "code": "ESP_SDR" + }, + { + "name": "圣克鲁斯-德特内里费", + "code": "ESP_SCT" + }, + { + "name": "索里亚", + "code": "ESP_SOR" + }, + { + "name": "塔拉戈纳", + "code": "ESP_TAR" + }, + { + "name": "特鲁埃尔", + "code": "ESP_TER" + }, + { + "name": "托莱多", + "code": "ESP_TOL" + }, + { + "name": "韦尔瓦", + "code": "ESP_HUV" + }, + { + "name": "韦斯卡", + "code": "ESP_HUC" + } + ] + }, + { + "name": "爱沙尼亚", + "code": "EST", + "children": [ + { + "name": "哈留", + "code": "EST_37" + }, + { + "name": "希尤", + "code": "EST_39" + }, + { + "name": "依达-维鲁", + "code": "EST_44" + }, + { + "name": "耶盖瓦", + "code": "EST_49" + }, + { + "name": "耶尔韦", + "code": "EST_51" + }, + { + "name": "里亚内", + "code": "EST_57" + }, + { + "name": "维鲁", + "code": "EST_59" + }, + { + "name": "贝尔瓦", + "code": "EST_65" + }, + { + "name": "帕尔努", + "code": "EST_67" + }, + { + "name": "拉普拉", + "code": "EST_70" + }, + { + "name": "萨雷", + "code": "EST_74" + }, + { + "name": "塔尔图", + "code": "EST_78" + }, + { + "name": "瓦尔加", + "code": "EST_82" + }, + { + "name": "维良地", + "code": "EST_84" + }, + { + "name": "沃鲁", + "code": "EST_86" + } + ] + }, + { + "name": "埃塞俄比亚", + "code": "ETH", + "children": [ + { + "name": "阿法尔", + "code": "ETH_AF" + }, + { + "name": "阿姆哈拉", + "code": "ETH_AH" + }, + { + "name": "奥罗米亚", + "code": "ETH_OR" + }, + { + "name": "宾香古尔", + "code": "ETH_BG" + }, + { + "name": "德雷达瓦", + "code": "ETH_DD" + }, + { + "name": "甘贝拉各族", + "code": "ETH_GB" + }, + { + "name": "哈勒里民族", + "code": "ETH_HR" + }, + { + "name": "南方各族", + "code": "ETH_SN" + }, + { + "name": "索马里", + "code": "ETH_SM" + }, + { + "name": "提格雷", + "code": "ETH_TG" + }, + { + "name": "亚的斯亚贝巴", + "code": "ETH_AA" + } + ] + }, + { + "name": "斐济", + "code": "FIJ" + }, + { + "name": "芬兰", + "code": "FIN", + "children": [ + { + "name": "埃斯波", + "code": "FIN_ESP" + }, + { + "name": "奥卢", + "code": "FIN_OLU" + }, + { + "name": "波里", + "code": "FIN_POR" + }, + { + "name": "博尔沃", + "code": "FIN_PRV" + }, + { + "name": "海门林纳", + "code": "FIN_HMY" + }, + { + "name": "赫尔辛基", + "code": "FIN_HEL" + }, + { + "name": "卡亚尼", + "code": "FIN_KAJ" + }, + { + "name": "科科拉", + "code": "FIN_KOK" + }, + { + "name": "科特卡", + "code": "FIN_KTK" + }, + { + "name": "库奥皮奥", + "code": "FIN_KUO" + }, + { + "name": "拉赫蒂", + "code": "FIN_LHI" + }, + { + "name": "拉彭兰塔", + "code": "FIN_LPP" + }, + { + "name": "罗瓦涅米", + "code": "FIN_RVN" + }, + { + "name": "玛丽港", + "code": "FIN_MHQ" + }, + { + "name": "米凯利", + "code": "FIN_MIK" + }, + { + "name": "坦佩雷", + "code": "FIN_TMP" + }, + { + "name": "图尔库", + "code": "FIN_TKU" + }, + { + "name": "瓦萨", + "code": "FIN_VAA" + }, + { + "name": "万塔", + "code": "FIN_VAT" + }, + { + "name": "约恩苏", + "code": "FIN_JOE" + } + ] + }, + { + "name": "弗兰克群岛", + "code": "FLK" + }, + { + "name": "法国", + "code": "FRA", + "children": [ + { + "name": "阿尔勒", + "code": "FRA_ARL" + }, + { + "name": "阿雅克修", + "code": "FRA_AJA" + }, + { + "name": "艾克斯", + "code": "FRA_QXB" + }, + { + "name": "奥尔良", + "code": "FRA_ORR" + }, + { + "name": "巴黎", + "code": "FRA_PAR" + }, + { + "name": "贝桑松", + "code": "FRA_BSN" + }, + { + "name": "第戎", + "code": "FRA_DIJ" + }, + { + "name": "弗雷瑞斯", + "code": "FRA_FRJ" + }, + { + "name": "卡昂", + "code": "FRA_CFR" + }, + { + "name": "雷恩", + "code": "FRA_RNS" + }, + { + "name": "里昂", + "code": "FRA_LIO" + }, + { + "name": "里尔", + "code": "FRA_LLE" + }, + { + "name": "利摩日", + "code": "FRA_LIG" + }, + { + "name": "鲁昂", + "code": "FRA_URO" + }, + { + "name": "马赛", + "code": "FRA_MRS" + }, + { + "name": "梅斯", + "code": "FRA_MZM" + }, + { + "name": "蒙彼利埃", + "code": "FRA_MPL" + }, + { + "name": "南特", + "code": "FRA_NTE" + }, + { + "name": "尼斯", + "code": "FRA_NCE" + }, + { + "name": "沙隆", + "code": "FRA_CSM" + }, + { + "name": "图卢兹", + "code": "FRA_TLS" + }, + { + "name": "瓦朗斯", + "code": "FRA_VAA" + }, + { + "name": "亚眠", + "code": "FRA_AMI" + } + ] + }, + { + "name": "法罗群岛", + "code": "FRO" + }, + { + "name": "密克罗尼西亚", + "code": "FSM" + }, + { + "name": "加蓬", + "code": "GAB", + "children": [ + { + "name": "奥果韦-洛洛", + "code": "GAB_OL" + }, + { + "name": "奥果韦-伊温多", + "code": "GAB_OI" + }, + { + "name": "滨海奥果韦", + "code": "GAB_OM" + }, + { + "name": "恩古涅", + "code": "GAB_NG" + }, + { + "name": "河口", + "code": "GAB_ES" + }, + { + "name": "尼扬加", + "code": "GAB_NY" + }, + { + "name": "上奥果韦", + "code": "GAB_HO" + }, + { + "name": "沃勒-恩特姆", + "code": "GAB_WN" + }, + { + "name": "中奥果韦", + "code": "GAB_MO" + } + ] + }, + { + "name": "冈比亚", + "code": "GAM" + }, + { + "name": "泽西", + "code": "GBJ" + }, + { + "name": "英国", + "code": "GBR", + "children": [ + { + "name": "北爱尔兰", + "code": "GBR_NIR" + }, + { + "name": "苏格兰", + "code": "GBR_SCT" + }, + { + "name": "威尔士", + "code": "GBR_WLS" + }, + { + "name": "英格兰", + "code": "GBR_ENG" + } + ] + }, + { + "name": "格鲁吉亚", + "code": "GEO" + }, + { + "name": "赤道几内亚", + "code": "GEQ" + }, + { + "name": "德国", + "code": "GER" + }, + { + "name": "格恩西岛", + "code": "GGY" + }, + { + "name": "加纳", + "code": "GHA", + "children": [ + { + "name": "阿散蒂", + "code": "GHA_AS" + }, + { + "name": "奥布阿西", + "code": "GHA_OBU" + }, + { + "name": "北部", + "code": "GHA_NO" + }, + { + "name": "布朗阿哈福", + "code": "GHA_BA" + }, + { + "name": "大阿克拉", + "code": "GHA_GA" + }, + { + "name": "东部", + "code": "GHA_EA" + }, + { + "name": "上东部", + "code": "GHA_UE" + }, + { + "name": "上西部", + "code": "GHA_UW" + }, + { + "name": "沃尔特", + "code": "GHA_VO" + }, + { + "name": "西部", + "code": "GHA_WE" + }, + { + "name": "中部", + "code": "GHA_CE" + } + ] + }, + { + "name": "直布罗陀", + "code": "GIB" + }, + { + "name": "瓜德罗普", + "code": "GLP" + }, + { + "name": "几内亚比绍", + "code": "GNB" + }, + { + "name": "希腊", + "code": "GRE" + }, + { + "name": "格陵兰", + "code": "GRL" + }, + { + "name": "格林纳达", + "code": "GRN" + }, + { + "name": "危地马拉", + "code": "GUA" + }, + { + "name": "法属圭亚那", + "code": "GUF" + }, + { + "name": "几内亚", + "code": "GUI" + }, + { + "name": "关岛", + "code": "GUM" + }, + { + "name": "圭亚那", + "code": "GUY", + "children": [ + { + "name": "埃塞奎博群岛-西德梅拉拉", + "code": "GUY_EW" + }, + { + "name": "巴里马-瓦伊尼", + "code": "GUY_BW" + }, + { + "name": "波默伦-苏佩纳姆", + "code": "GUY_PM" + }, + { + "name": "波塔罗-锡帕鲁尼", + "code": "GUY_PI" + }, + { + "name": "德梅拉拉-马海卡", + "code": "GUY_DM" + }, + { + "name": "东伯比斯-科兰太因", + "code": "GUY_EC" + }, + { + "name": "库尤尼-马扎鲁尼", + "code": "GUY_CM" + }, + { + "name": "马海卡-伯比斯", + "code": "GUY_MB" + }, + { + "name": "上德梅拉拉-伯比斯", + "code": "GUY_UD" + }, + { + "name": "上塔库图-上埃塞奎博", + "code": "GUY_UT" + } + ] + }, + { + "name": "海地", + "code": "HAI" + }, + { + "name": "赫德和麦克唐纳群岛", + "code": "HMD" + }, + { + "name": "洪都拉斯", + "code": "HON" + }, + { + "name": "匈牙利", + "code": "HUN", + "children": [ + { + "name": "巴兰尼亚", + "code": "HUN_BA" + }, + { + "name": "巴奇-基什孔", + "code": "HUN_BK" + }, + { + "name": "包尔绍德-奥包乌伊-曾普伦", + "code": "HUN_BZ" + }, + { + "name": "贝凯什", + "code": "HUN_BE" + }, + { + "name": "布达佩斯", + "code": "HUN_BU" + }, + { + "name": "费耶尔", + "code": "HUN_FE" + }, + { + "name": "豪伊杜-比豪尔", + "code": "HUN_HB" + }, + { + "name": "赫维什", + "code": "HUN_HE" + }, + { + "name": "加兹-纳杰孔-索尔诺克", + "code": "HUN_JN" + }, + { + "name": "杰尔-莫松-肖普朗", + "code": "HUN_GS" + }, + { + "name": "科马罗姆", + "code": "HUN_KE" + }, + { + "name": "诺格拉德", + "code": "HUN_NO" + }, + { + "name": "佩斯", + "code": "HUN_PE" + }, + { + "name": "琼格拉德", + "code": "HUN_CS" + }, + { + "name": "绍莫吉", + "code": "HUN_SO" + }, + { + "name": "索博尔奇-索特马尔-贝拉格", + "code": "HUN_SZ" + }, + { + "name": "托尔瑙", + "code": "HUN_TO" + }, + { + "name": "维斯普雷姆", + "code": "HUN_VE" + }, + { + "name": "沃什", + "code": "HUN_VA" + }, + { + "name": "佐洛", + "code": "HUN_ZA" + } + ] + }, + { + "name": "印度尼西亚", + "code": "IDN", + "children": [ + { + "name": "巴厘", + "code": "IDN_BA" + }, + { + "name": "邦加-勿里洞群岛", + "code": "IDN_BB" + }, + { + "name": "北苏拉威西", + "code": "IDN_SA" + }, + { + "name": "北苏门答腊", + "code": "IDN_SU" + }, + { + "name": "大雅加达首都特区", + "code": "IDN_KB" + }, + { + "name": "东加里曼丹", + "code": "IDN_KI" + }, + { + "name": "东南苏拉威西", + "code": "IDN_SG" + }, + { + "name": "东努沙登加拉", + "code": "IDN_NT" + }, + { + "name": "东爪哇", + "code": "IDN_JI" + }, + { + "name": "廖内", + "code": "IDN_RI" + }, + { + "name": "马鲁古", + "code": "IDN_MA" + }, + { + "name": "明古鲁", + "code": "IDN_BE" + }, + { + "name": "楠榜", + "code": "IDN_LA" + }, + { + "name": "南加里曼丹", + "code": "IDN_KS" + }, + { + "name": "南苏拉威西", + "code": "IDN_SN" + }, + { + "name": "南苏门答腊", + "code": "IDN_SS" + }, + { + "name": "日惹特区", + "code": "IDN_YO" + }, + { + "name": "万丹", + "code": "IDN_BT" + }, + { + "name": "西努沙登加拉", + "code": "IDN_NB" + }, + { + "name": "西苏门答腊", + "code": "IDN_SR" + }, + { + "name": "西爪哇", + "code": "IDN_JB" + }, + { + "name": "雅加达", + "code": "IDN_JK" + }, + { + "name": "亚齐", + "code": "IDN_AC" + }, + { + "name": "伊里安查亚", + "code": "IDN_IJ" + }, + { + "name": "占碑", + "code": "IDN_JA" + }, + { + "name": "中加里曼丹", + "code": "IDN_KT" + }, + { + "name": "中苏拉威西", + "code": "IDN_ST" + }, + { + "name": "中爪哇", + "code": "IDN_JT" + } + ] + }, + { + "name": "曼岛", + "code": "IMN" + }, + { + "name": "印尼", + "code": "INA" + }, + { + "name": "印度", + "code": "IND", + "children": [ + { + "name": "艾藻尔", + "code": "IND_AJL" + }, + { + "name": "班加罗尔", + "code": "IND_BLR" + }, + { + "name": "本地治里", + "code": "IND_PNY" + }, + { + "name": "博帕尔", + "code": "IND_BHO" + }, + { + "name": "布巴内斯瓦尔", + "code": "IND_BBI" + }, + { + "name": "昌迪加尔", + "code": "IND_IXC" + }, + { + "name": "达曼", + "code": "IND_DAM" + }, + { + "name": "第乌", + "code": "IND_DIU" + }, + { + "name": "甘托克", + "code": "IND_GTO" + }, + { + "name": "哥印拜陀", + "code": "IND_CJB" + }, + { + "name": "加尔各答", + "code": "IND_CCU" + }, + { + "name": "加里加尔", + "code": "IND_KRK" + }, + { + "name": "贾巴尔普尔", + "code": "IND_JLR" + }, + { + "name": "贾朗达尔", + "code": "IND_JUC" + }, + { + "name": "焦特布尔", + "code": "IND_JDH" + }, + { + "name": "金奈", + "code": "IND_MAA" + }, + { + "name": "卡瓦拉蒂", + "code": "IND_KVA" + }, + { + "name": "科希马", + "code": "IND_KOM" + }, + { + "name": "马埃", + "code": "IND_MAH" + }, + { + "name": "马杜赖", + "code": "IND_IXM" + }, + { + "name": "森伯尔布尔", + "code": "IND_SLR" + }, + { + "name": "特里凡得琅", + "code": "IND_TRV" + }, + { + "name": "乌代布尔", + "code": "IND_UDR" + }, + { + "name": "西隆", + "code": "IND_SHL" + }, + { + "name": "锡尔萨瓦", + "code": "IND_SIL" + }, + { + "name": "新德里", + "code": "IND_ICD" + }, + { + "name": "亚南", + "code": "IND_SRV" + }, + { + "name": "因帕尔", + "code": "IND_IMF" + }, + { + "name": "印多尔", + "code": "IND_IDR" + }, + { + "name": "斋普尔", + "code": "IND_JAI" + } + ] + }, + { + "name": "英属印度洋领地", + "code": "IOT" + }, + { + "name": "伊朗", + "code": "IRI" + }, + { + "name": "爱尔兰", + "code": "IRL", + "children": [ + { + "name": "奥法利", + "code": "IRL_OF" + }, + { + "name": "蒂珀雷里", + "code": "IRL_TP" + }, + { + "name": "都柏林", + "code": "IRL_DB" + }, + { + "name": "多内加尔", + "code": "IRL_DG" + }, + { + "name": "戈尔韦", + "code": "IRL_GW" + }, + { + "name": "基尔代尔", + "code": "IRL_KD" + }, + { + "name": "基尔肯尼", + "code": "IRL_KK" + }, + { + "name": "卡范", + "code": "IRL_CV" + }, + { + "name": "卡洛", + "code": "IRL_CW" + }, + { + "name": "凯里", + "code": "IRL_KR" + }, + { + "name": "科克", + "code": "IRL_CK" + }, + { + "name": "克莱尔", + "code": "IRL_CL" + }, + { + "name": "朗福德", + "code": "IRL_LF" + }, + { + "name": "劳斯", + "code": "IRL_LT" + }, + { + "name": "崂斯", + "code": "IRL_LA" + }, + { + "name": "利默里克", + "code": "IRL_LM" + }, + { + "name": "利特里姆", + "code": "IRL_LR" + }, + { + "name": "罗斯康芒", + "code": "IRL_RC" + }, + { + "name": "梅奥", + "code": "IRL_MY" + }, + { + "name": "米斯", + "code": "IRL_MT" + }, + { + "name": "莫内根", + "code": "IRL_MG" + }, + { + "name": "斯莱戈", + "code": "IRL_SL" + }, + { + "name": "威克洛", + "code": "IRL_WK" + }, + { + "name": "韦克斯福德", + "code": "IRL_WX" + }, + { + "name": "沃特福德", + "code": "IRL_WF" + }, + { + "name": "西米斯", + "code": "IRL_WM" + } + ] + }, + { + "name": "伊拉克", + "code": "IRQ" + }, + { + "name": "冰岛", + "code": "ISL" + }, + { + "name": "以色列", + "code": "ISR", + "children": [ + { + "name": "阿什杜德", + "code": "ISR_ASH" + }, + { + "name": "贝尔谢巴", + "code": "ISR_BEV" + }, + { + "name": "贝特雁", + "code": "ISR_BAT" + }, + { + "name": "海法", + "code": "ISR_HFA" + }, + { + "name": "霍隆", + "code": "ISR_HOL" + }, + { + "name": "内坦亚", + "code": "ISR_NAT" + }, + { + "name": "特拉维夫", + "code": "ISR_TLV" + }, + { + "name": "耶路撒冷", + "code": "ISR_J" + } + ] + }, + { + "name": "意大利", + "code": "ITA", + "children": [ + { + "name": "阿斯蒂", + "code": "ITA_AST" + }, + { + "name": "阿斯科利皮切诺", + "code": "ITA_ASP" + }, + { + "name": "安科纳", + "code": "ITA_AOI" + }, + { + "name": "奥尔比亚", + "code": "ITA_OLB" + }, + { + "name": "奥里斯塔诺", + "code": "ITA_QOS" + }, + { + "name": "奥斯塔", + "code": "ITA_AOT" + }, + { + "name": "巴勒莫", + "code": "ITA_PMO" + }, + { + "name": "巴里", + "code": "ITA_BRI" + }, + { + "name": "贝加莫", + "code": "ITA_BGO" + }, + { + "name": "贝内文托", + "code": "ITA_BEN" + }, + { + "name": "比萨", + "code": "ITA_PSA" + }, + { + "name": "波代诺内", + "code": "ITA_PRD" + }, + { + "name": "波坦察", + "code": "ITA_QPO" + }, + { + "name": "博洛尼亚", + "code": "ITA_BLQ" + }, + { + "name": "布拉", + "code": "ITA_BIE" + }, + { + "name": "布雷西亚", + "code": "ITA_BRC" + }, + { + "name": "布林迪西", + "code": "ITA_BDS" + }, + { + "name": "的里雅斯特", + "code": "ITA_TRS" + }, + { + "name": "都灵", + "code": "ITA_TRN" + }, + { + "name": "费拉拉", + "code": "ITA_FRR" + }, + { + "name": "佛罗伦萨", + "code": "ITA_FLR" + }, + { + "name": "福贾", + "code": "ITA_FOG" + }, + { + "name": "卡利亚里", + "code": "ITA_CAG" + }, + { + "name": "卡塞塔", + "code": "ITA_CST" + }, + { + "name": "卡塔尼亚", + "code": "ITA_CTA" + }, + { + "name": "卡坦扎罗", + "code": "ITA_QCZ" + }, + { + "name": "坎波巴索", + "code": "ITA_COB" + }, + { + "name": "科摩", + "code": "ITA_CIY" + }, + { + "name": "科森扎", + "code": "ITA_QCS" + }, + { + "name": "克罗托内", + "code": "ITA_CRV" + }, + { + "name": "库内奥", + "code": "ITA_CUN" + }, + { + "name": "拉奎拉", + "code": "ITA_LAQ" + }, + { + "name": "拉斯佩齐亚", + "code": "ITA_SPE" + }, + { + "name": "莱科", + "code": "ITA_LCO" + }, + { + "name": "莱切", + "code": "ITA_LCC" + }, + { + "name": "雷焦艾米利亚", + "code": "ITA_RNE" + }, + { + "name": "雷焦卡拉布里亚", + "code": "ITA_REG" + }, + { + "name": "里窝那", + "code": "ITA_LIV" + }, + { + "name": "罗马", + "code": "ITA_ROM" + }, + { + "name": "马萨", + "code": "ITA_MCR" + }, + { + "name": "马泰拉", + "code": "ITA_MTR" + }, + { + "name": "蒙扎", + "code": "ITA_MZA" + }, + { + "name": "米兰", + "code": "ITA_MIL" + }, + { + "name": "摩德纳", + "code": "ITA_MOD" + }, + { + "name": "墨西拿", + "code": "ITA_MSN" + }, + { + "name": "那不勒斯", + "code": "ITA_NAP" + }, + { + "name": "努奥罗", + "code": "ITA_QNU" + }, + { + "name": "诺瓦拉", + "code": "ITA_NVR" + }, + { + "name": "帕尔马", + "code": "ITA_PMF" + }, + { + "name": "帕维亚", + "code": "ITA_PAV" + }, + { + "name": "佩鲁贾", + "code": "ITA_PEG" + }, + { + "name": "热那亚", + "code": "ITA_CAX" + }, + { + "name": "萨莱诺", + "code": "ITA_SAL" + }, + { + "name": "萨萨里", + "code": "ITA_QSS" + }, + { + "name": "萨沃纳", + "code": "ITA_SVN" + }, + { + "name": "塔兰托", + "code": "ITA_TAR" + }, + { + "name": "特拉帕尼", + "code": "ITA_TPS" + }, + { + "name": "特伦托", + "code": "ITA_TRT" + }, + { + "name": "威尼斯", + "code": "ITA_VCE" + }, + { + "name": "韦尔切利", + "code": "ITA_VRL" + }, + { + "name": "维泰博", + "code": "ITA_VIT" + }, + { + "name": "乌迪内", + "code": "ITA_UDN" + }, + { + "name": "锡拉库扎", + "code": "ITA_SYR" + }, + { + "name": "锡耶纳", + "code": "ITA_SNA" + }, + { + "name": "亚历山德里亚", + "code": "ITA_ALE" + }, + { + "name": "伊塞尔尼亚", + "code": "ITA_ISE" + } + ] + }, + { + "name": "牙买加", + "code": "JAM", + "children": [ + { + "name": "波特兰", + "code": "JAM_POR" + }, + { + "name": "汉诺威", + "code": "JAM_HAN" + }, + { + "name": "金斯敦", + "code": "JAM_KIN" + }, + { + "name": "克拉伦登", + "code": "JAM_CLA" + }, + { + "name": "曼彻斯特", + "code": "JAM_MAN" + }, + { + "name": "圣安德鲁斯", + "code": "JAM_AND" + }, + { + "name": "圣安娜", + "code": "JAM_ANN" + }, + { + "name": "圣凯瑟琳", + "code": "JAM_CAT" + }, + { + "name": "圣玛丽", + "code": "JAM_MAR" + }, + { + "name": "圣托马斯", + "code": "JAM_THO" + }, + { + "name": "圣伊丽莎白", + "code": "JAM_ELI" + }, + { + "name": "圣詹姆斯", + "code": "JAM_JAM" + }, + { + "name": "特里洛尼", + "code": "JAM_TRL" + }, + { + "name": "西摩兰", + "code": "JAM_WML" + } + ] + }, + { + "name": "泽西岛", + "code": "JEY" + }, + { + "name": "约旦", + "code": "JOR", + "children": [ + { + "name": "阿吉隆", + "code": "JOR_AJ" + }, + { + "name": "安曼", + "code": "JOR_AM" + }, + { + "name": "拜勒加", + "code": "JOR_BA" + }, + { + "name": "杰拉什", + "code": "JOR_JA" + }, + { + "name": "卡拉克", + "code": "JOR_KA" + }, + { + "name": "鲁赛法", + "code": "JOR_RU" + }, + { + "name": "马安", + "code": "JOR_MN" + }, + { + "name": "马德巴", + "code": "JOR_MD" + }, + { + "name": "马夫拉克", + "code": "JOR_MF" + }, + { + "name": "塔菲拉", + "code": "JOR_TA" + }, + { + "name": "亚喀巴", + "code": "JOR_AQ" + }, + { + "name": "伊尔比德", + "code": "JOR_IR" + }, + { + "name": "扎尔卡", + "code": "JOR_ZA" + } + ] + }, + { + "name": "日本", + "code": "JPN", + "children": [ + { + "name": "北海道", + "code": "JPN_1" + }, + { + "name": "青森", + "code": "JPN_2" + }, + { + "name": "岩手", + "code": "JPN_3" + }, + { + "name": "宮城", + "code": "JPN_4" + }, + { + "name": "秋田", + "code": "JPN_5" + }, + { + "name": "山形", + "code": "JPN_6" + }, + { + "name": "福岛", + "code": "JPN_7" + }, + { + "name": "茨城", + "code": "JPN_8" + }, + { + "name": "枥木", + "code": "JPN_9" + }, + { + "name": "群马", + "code": "JPN_10" + }, + { + "name": "埼玉", + "code": "JPN_11" + }, + { + "name": "千叶", + "code": "JPN_12" + }, + { + "name": "东京", + "code": "JPN_13" + }, + { + "name": "神奈川", + "code": "JPN_14" + }, + { + "name": "新潟", + "code": "JPN_15" + }, + { + "name": "富山", + "code": "JPN_16" + }, + { + "name": "石川", + "code": "JPN_17" + }, + { + "name": "福井", + "code": "JPN_18" + }, + { + "name": "山梨", + "code": "JPN_19" + }, + { + "name": "长野", + "code": "JPN_20" + }, + { + "name": "岐阜", + "code": "JPN_21" + }, + { + "name": "静冈", + "code": "JPN_22" + }, + { + "name": "爱知", + "code": "JPN_23" + }, + { + "name": "三重", + "code": "JPN_24" + }, + { + "name": "滋贺", + "code": "JPN_25" + }, + { + "name": "京都", + "code": "JPN_26" + }, + { + "name": "大阪", + "code": "JPN_27" + }, + { + "name": "兵库", + "code": "JPN_28" + }, + { + "name": "奈良", + "code": "JPN_29" + }, + { + "name": "和歌山", + "code": "JPN_30" + }, + { + "name": "鸟取", + "code": "JPN_31" + }, + { + "name": "岛根", + "code": "JPN_32" + }, + { + "name": "冈山", + "code": "JPN_33" + }, + { + "name": "广岛", + "code": "JPN_34" + }, + { + "name": "山口", + "code": "JPN_35" + }, + { + "name": "徳岛", + "code": "JPN_36" + }, + { + "name": "香川", + "code": "JPN_37" + }, + { + "name": "爱媛", + "code": "JPN_38" + }, + { + "name": "高知", + "code": "JPN_39" + }, + { + "name": "福冈", + "code": "JPN_40" + }, + { + "name": "佐贺", + "code": "JPN_41" + }, + { + "name": "长崎", + "code": "JPN_42" + }, + { + "name": "熊本", + "code": "JPN_43" + }, + { + "name": "大分", + "code": "JPN_44" + }, + { + "name": "宫崎", + "code": "JPN_45" + }, + { + "name": "鹿儿岛", + "code": "JPN_46" + }, + { + "name": "冲绳", + "code": "JPN_47" + } + ] + }, + { + "name": "哈萨克斯坦", + "code": "KAZ", + "children": [ + { + "name": "阿尔卡累克", + "code": "KAZ_AYK" + }, + { + "name": "阿克莫拉", + "code": "KAZ_AKM" + }, + { + "name": "阿克苏", + "code": "KAZ_AKS" + }, + { + "name": "阿克托别", + "code": "KAZ_AKT" + }, + { + "name": "阿拉木图", + "code": "KAZ_ALA" + }, + { + "name": "阿雷斯", + "code": "KAZ_ARY" + }, + { + "name": "阿斯塔纳市", + "code": "KAZ_AST" + }, + { + "name": "阿特劳", + "code": "KAZ_ATY" + }, + { + "name": "埃基巴斯图兹", + "code": "KAZ_EKB" + }, + { + "name": "巴尔喀什", + "code": "KAZ_BXH" + }, + { + "name": "巴甫洛达尔", + "code": "KAZ_PAV" + }, + { + "name": "北哈萨克斯坦", + "code": "KAZ_SEV" + }, + { + "name": "东哈萨克斯坦", + "code": "KAZ_VOS" + }, + { + "name": "济良诺夫斯克", + "code": "KAZ_ZYR" + }, + { + "name": "江布尔", + "code": "KAZ_DMB" + }, + { + "name": "杰兹卡兹甘", + "code": "KAZ_DZH" + }, + { + "name": "卡拉干达", + "code": "KAZ_KAR" + }, + { + "name": "卡拉扎尔", + "code": "KAZ_KZO" + }, + { + "name": "卡普恰盖", + "code": "KAZ_KAP" + }, + { + "name": "科斯塔奈", + "code": "KAZ_KST" + }, + { + "name": "克孜勒奥尔达", + "code": "KAZ_KZY" + }, + { + "name": "库尔恰托夫", + "code": "KAZ_KUR" + }, + { + "name": "利萨科夫斯克", + "code": "KAZ_LKK" + }, + { + "name": "列宁诺戈尔斯克", + "code": "KAZ_LEN" + }, + { + "name": "鲁德内", + "code": "KAZ_RUD" + }, + { + "name": "曼格斯套", + "code": "KAZ_MAN" + }, + { + "name": "南哈萨克斯坦", + "code": "KAZ_KGT" + }, + { + "name": "萨兰", + "code": "KAZ_SAR" + }, + { + "name": "塞梅伊", + "code": "KAZ_SEM" + }, + { + "name": "沙赫京斯克", + "code": "KAZ_SAK" + }, + { + "name": "斯捷普诺戈尔斯克", + "code": "KAZ_STE" + }, + { + "name": "铁克利", + "code": "KAZ_TEK" + }, + { + "name": "铁米尔套", + "code": "KAZ_TEM" + }, + { + "name": "突厥斯坦", + "code": "KAZ_TUR" + }, + { + "name": "西哈萨克斯坦", + "code": "KAZ_ZAP" + }, + { + "name": "扎纳奥津", + "code": "KAZ_ZHA" + } + ] + }, + { + "name": "肯尼亚", + "code": "KEN", + "children": [ + { + "name": "埃尔格约-马拉奎特", + "code": "KEN_EMA" + }, + { + "name": "巴林戈", + "code": "KEN_BAR" + }, + { + "name": "邦戈马", + "code": "KEN_BUN" + }, + { + "name": "博美特", + "code": "KEN_BOM" + }, + { + "name": "布希亚", + "code": "KEN_BUS" + }, + { + "name": "恩布", + "code": "KEN_EMB" + }, + { + "name": "霍马湾", + "code": "KEN_HOB" + }, + { + "name": "基安布", + "code": "KEN_KIA" + }, + { + "name": "基里菲", + "code": "KEN_KIL" + }, + { + "name": "基里尼亚加", + "code": "KEN_KIR" + }, + { + "name": "基苏木", + "code": "KEN_KIS" + }, + { + "name": "基图伊", + "code": "KEN_KIT" + }, + { + "name": "基西", + "code": "KEN_KII" + }, + { + "name": "加里萨", + "code": "KEN_GAS" + }, + { + "name": "卡卡梅加", + "code": "KEN_KAK" + }, + { + "name": "卡耶亚多", + "code": "KEN_KAJ" + }, + { + "name": "凯里乔", + "code": "KEN_KEY" + }, + { + "name": "夸勒", + "code": "KEN_KWA" + }, + { + "name": "拉木", + "code": "KEN_LAU" + }, + { + "name": "莱基皮亚", + "code": "KEN_LAI" + }, + { + "name": "马查科斯", + "code": "KEN_MAC" + }, + { + "name": "马瓜尼", + "code": "KEN_MAK" + }, + { + "name": "马萨布布", + "code": "KEN_RBT" + }, + { + "name": "曼德拉", + "code": "KEN_MAN" + }, + { + "name": "梅鲁", + "code": "KEN_MER" + }, + { + "name": "蒙巴萨", + "code": "KEN_MOM" + }, + { + "name": "米戈利", + "code": "KEN_MIG" + }, + { + "name": "穆兰卡", + "code": "KEN_MUR" + }, + { + "name": "纳库鲁", + "code": "KEN_NUU" + }, + { + "name": "纳罗克", + "code": "KEN_NAR" + }, + { + "name": "南迪", + "code": "KEN_NAN" + }, + { + "name": "内罗毕", + "code": "KEN_NA" + }, + { + "name": "尼蒂", + "code": "KEN_NIT" + }, + { + "name": "尼亚米拉", + "code": "KEN_NYM" + }, + { + "name": "年达鲁阿", + "code": "KEN_NYN" + }, + { + "name": "涅里", + "code": "KEN_NYE" + }, + { + "name": "桑布卢", + "code": "KEN_UAS" + }, + { + "name": "塔纳河", + "code": "KEN_TRI" + }, + { + "name": "泰塔塔维塔", + "code": "KEN_TTA" + }, + { + "name": "特兰斯-恩佐亚", + "code": "KEN_TNZ" + }, + { + "name": "瓦吉尔", + "code": "KEN_WJR" + }, + { + "name": "瓦辛基苏", + "code": "KEN_UGI" + }, + { + "name": "韦希加", + "code": "KEN_VIH" + }, + { + "name": "西波克特", + "code": "KEN_WPO" + }, + { + "name": "夏亚", + "code": "KEN_SIA" + }, + { + "name": "伊希约洛", + "code": "KEN_ISI" + }, + { + "name": "中央", + "code": "KEN_CE" + } + ] + }, + { + "name": "吉尔吉斯斯坦", + "code": "KGZ", + "children": [ + { + "name": "奥什", + "code": "KGZ_O" + }, + { + "name": "巴特肯", + "code": "KGZ_B" + }, + { + "name": "比什凯克市", + "code": "KGZ_GB" + }, + { + "name": "楚河", + "code": "KGZ_C" + }, + { + "name": "贾拉拉巴德", + "code": "KGZ_J" + }, + { + "name": "卡拉巴尔塔", + "code": "KGZ_KBA" + }, + { + "name": "卡拉库尔", + "code": "KGZ_KKO" + }, + { + "name": "坎特", + "code": "KGZ_KAN" + }, + { + "name": "科克扬加克", + "code": "KGZ_KJ" + }, + { + "name": "迈利赛", + "code": "KGZ_MS" + }, + { + "name": "纳伦", + "code": "KGZ_N" + }, + { + "name": "苏卢克图", + "code": "KGZ_SU" + }, + { + "name": "塔拉斯", + "code": "KGZ_T" + }, + { + "name": "塔什库梅尔", + "code": "KGZ_TK" + }, + { + "name": "乌兹根", + "code": "KGZ_UG" + }, + { + "name": "伊塞克湖", + "code": "KGZ_Y" + } + ] + }, + { + "name": "基里巴斯", + "code": "KIR", + "children": [ + { + "name": "菲尼克斯群岛", + "code": "KIR_PHO" + }, + { + "name": "吉尔伯特群岛", + "code": "KIR_GIL" + }, + { + "name": "莱恩群岛", + "code": "KIR_LIN" + } + ] + }, + { + "name": "韩国", + "code": "KOR", + "children": [ + { + "name": "首尔", + "code": "KOR_11" + }, + { + "name": "釜山", + "code": "KOR_26" + }, + { + "name": "大邱", + "code": "KOR_27" + }, + { + "name": "仁川", + "code": "KOR_28" + }, + { + "name": "光州", + "code": "KOR_29" + }, + { + "name": "大田", + "code": "KOR_30" + }, + { + "name": "蔚山", + "code": "KOR_31" + }, + { + "name": "济州特别自治道", + "code": "KOR_41" + }, + { + "name": "江原道", + "code": "KOR_42" + }, + { + "name": "庆尚北道", + "code": "KOR_43" + }, + { + "name": "庆尚南道", + "code": "KOR_44" + }, + { + "name": "忠清北道", + "code": "KOR_45" + }, + { + "name": "忠清南道", + "code": "KOR_46" + }, + { + "name": "全罗北道", + "code": "KOR_47" + }, + { + "name": "全罗南道", + "code": "KOR_48" + }, + { + "name": "京畿道", + "code": "KOR_49" + } + ] + }, + { + "name": "沙特阿拉伯", + "code": "KSA" + }, + { + "name": "科威特", + "code": "KUW" + }, + { + "name": "老挝", + "code": "LAO", + "children": [ + { + "name": "阿速坡", + "code": "LAO_AT" + }, + { + "name": "波里坎赛", + "code": "LAO_BL" + }, + { + "name": "博乔", + "code": "LAO_BK" + }, + { + "name": "川圹", + "code": "LAO_XI" + }, + { + "name": "丰沙里", + "code": "LAO_PH" + }, + { + "name": "甘蒙", + "code": "LAO_KH" + }, + { + "name": "华潘", + "code": "LAO_HO" + }, + { + "name": "琅勃拉邦", + "code": "LAO_LP" + }, + { + "name": "琅南塔", + "code": "LAO_LM" + }, + { + "name": "赛宋本行政特区", + "code": "LAO_XN" + }, + { + "name": "色贡", + "code": "LAO_XE" + }, + { + "name": "沙拉湾", + "code": "LAO_SL" + }, + { + "name": "沙湾拿吉", + "code": "LAO_SV" + }, + { + "name": "沙耶武里", + "code": "LAO_XA" + }, + { + "name": "万象", + "code": "LAO_VI" + }, + { + "name": "乌多姆赛", + "code": "LAO_OU" + }, + { + "name": "占巴塞", + "code": "LAO_CH" + } + ] + }, + { + "name": "拉脱维亚", + "code": "LAT" + }, + { + "name": "利比亚", + "code": "LBA" + }, + { + "name": "利比里亚", + "code": "LBR", + "children": [ + { + "name": "巴波卢", + "code": "LBR_GBA" + }, + { + "name": "邦", + "code": "LBR_BG" + }, + { + "name": "博波卢", + "code": "LBR_BOP" + }, + { + "name": "博米", + "code": "LBR_BM" + }, + { + "name": "大巴萨", + "code": "LBR_GB" + }, + { + "name": "大吉德", + "code": "LBR_GG" + }, + { + "name": "大角山", + "code": "LBR_CM" + }, + { + "name": "大克鲁", + "code": "LBR_GK" + }, + { + "name": "菲什敦", + "code": "LBR_FT" + }, + { + "name": "吉河", + "code": "LBR_RG" + }, + { + "name": "里弗塞斯", + "code": "LBR_RI" + }, + { + "name": "洛法", + "code": "LBR_LO" + }, + { + "name": "马吉比", + "code": "LBR_MG" + }, + { + "name": "马里兰", + "code": "LBR_MY" + }, + { + "name": "蒙特塞拉多", + "code": "LBR_MO" + }, + { + "name": "宁巴", + "code": "LBR_NI" + }, + { + "name": "锡诺", + "code": "LBR_SI" + } + ] + }, + { + "name": "圣卢西亚", + "code": "LCA" + }, + { + "name": "莱索托", + "code": "LES" + }, + { + "name": "黎巴嫩", + "code": "LIB" + }, + { + "name": "列支敦士登", + "code": "LIE" + }, + { + "name": "立陶宛", + "code": "LTU", + "children": [ + { + "name": "阿利图斯", + "code": "LTU_AL" + }, + { + "name": "考纳斯", + "code": "LTU_KA" + }, + { + "name": "克莱佩达", + "code": "LTU_KL" + }, + { + "name": "马里扬泊列", + "code": "LTU_MA" + }, + { + "name": "帕涅韦日斯", + "code": "LTU_PA" + }, + { + "name": "陶拉格", + "code": "LTU_TA" + }, + { + "name": "特尔希艾", + "code": "LTU_TE" + }, + { + "name": "维尔纽斯", + "code": "LTU_VI" + }, + { + "name": "乌田纳", + "code": "LTU_UT" + }, + { + "name": "希奥利艾", + "code": "LTU_SI" + }, + { + "name": "亚克曼", + "code": "LTU_AKM" + } + ] + }, + { + "name": "卢森堡", + "code": "LUX", + "children": [ + { + "name": "迪基希", + "code": "LUX_DD" + }, + { + "name": "格雷文马赫", + "code": "LUX_GG" + }, + { + "name": "卢森堡", + "code": "LUX_LL" + } + ] + }, + { + "name": "马达加斯加", + "code": "MAD" + }, + { + "name": "摩洛哥", + "code": "MAR", + "children": [ + { + "name": "丹吉尔", + "code": "MAR_TGR" + }, + { + "name": "得土安", + "code": "MAR_TET" + }, + { + "name": "非斯", + "code": "MAR_FES" + }, + { + "name": "卡萨布兰卡", + "code": "MAR_CBL" + }, + { + "name": "拉巴特", + "code": "MAR_RSA" + }, + { + "name": "马拉喀什", + "code": "MAR_MRK" + }, + { + "name": "梅克内斯", + "code": "MAR_MKN" + }, + { + "name": "乌季达", + "code": "MAR_OUJ" + }, + { + "name": "西撒哈拉", + "code": "MAR_WSH" + } + ] + }, + { + "name": "马来西亚", + "code": "MAS" + }, + { + "name": "马拉维", + "code": "MAW" + }, + { + "name": "摩尔多瓦", + "code": "MDA" + }, + { + "name": "马尔代夫", + "code": "MDV", + "children": [ + { + "name": "阿杜", + "code": "MDV_ADD" + }, + { + "name": "北阿里", + "code": "MDV_AAD" + }, + { + "name": "北蒂拉杜马蒂", + "code": "MDV_THD" + }, + { + "name": "北马洛斯马杜卢", + "code": "MDV_MAD" + }, + { + "name": "北米拉杜马杜卢", + "code": "MDV_MLD" + }, + { + "name": "北尼兰杜", + "code": "MDV_NAD" + }, + { + "name": "北苏瓦迪瓦", + "code": "MDV_HAD" + }, + { + "name": "法迪福卢", + "code": "MDV_FAA" + }, + { + "name": "费利杜", + "code": "MDV_FEA" + }, + { + "name": "福阿穆拉库", + "code": "MDV_FMU" + }, + { + "name": "哈杜马蒂", + "code": "MDV_HDH" + }, + { + "name": "科卢马杜卢", + "code": "MDV_KLH" + }, + { + "name": "马累", + "code": "MDV_MAL" + }, + { + "name": "马累岛", + "code": "MDV_MAA" + }, + { + "name": "穆拉库", + "code": "MDV_MUA" + }, + { + "name": "南阿里", + "code": "MDV_AAU" + }, + { + "name": "南蒂拉杜马蒂", + "code": "MDV_THU" + }, + { + "name": "南马洛斯马杜卢", + "code": "MDV_MAU" + }, + { + "name": "南米拉杜马杜卢", + "code": "MDV_MLU" + }, + { + "name": "南尼兰杜", + "code": "MDV_NAU" + }, + { + "name": "南苏瓦迪瓦", + "code": "MDV_HAU" + } + ] + }, + { + "name": "墨西哥", + "code": "MEX", + "children": [ + { + "name": "阿瓜斯卡连斯特", + "code": "MEX_AGU" + }, + { + "name": "阿卡普尔科", + "code": "MEX_ACA" + }, + { + "name": "埃莫西约", + "code": "MEX_HMO" + }, + { + "name": "埃佩切", + "code": "MEX_CAM" + }, + { + "name": "奥夫雷贡城", + "code": "MEX_OBR" + }, + { + "name": "奥里萨巴", + "code": "MEX_ORI" + }, + { + "name": "巴利城", + "code": "MEX_VHM" + }, + { + "name": "巴亚尔塔港", + "code": "MEX_PVR" + }, + { + "name": "比利亚埃尔莫萨", + "code": "MEX_VSA" + }, + { + "name": "波萨里卡", + "code": "MEX_PRH" + }, + { + "name": "蒂华纳", + "code": "MEX_TIJ" + }, + { + "name": "杜兰戈", + "code": "MEX_DUR" + }, + { + "name": "恩塞纳达", + "code": "MEX_ESE" + }, + { + "name": "瓜达拉哈拉", + "code": "MEX_GDL" + }, + { + "name": "瓜纳华托", + "code": "MEX_GUA" + }, + { + "name": "哈拉帕", + "code": "MEX_JAL" + }, + { + "name": "华雷斯", + "code": "MEX_JUZ" + }, + { + "name": "华雷斯港", + "code": "MEX_BJU" + }, + { + "name": "卡门", + "code": "MEX_CAR" + }, + { + "name": "科利马", + "code": "MEX_COL" + }, + { + "name": "克雷塔罗", + "code": "MEX_QUE" + }, + { + "name": "库埃纳瓦卡", + "code": "MEX_CVC" + }, + { + "name": "库利阿坎", + "code": "MEX_CUL" + }, + { + "name": "夸察夸拉克斯", + "code": "MEX_COA" + }, + { + "name": "拉巴斯", + "code": "MEX_LAP" + }, + { + "name": "莱昂", + "code": "MEX_LEN" + }, + { + "name": "雷诺萨", + "code": "MEX_REX" + }, + { + "name": "洛斯莫奇斯", + "code": "MEX_LMM" + }, + { + "name": "马萨特兰", + "code": "MEX_MZT" + }, + { + "name": "马塔莫罗斯", + "code": "MEX_MAM" + }, + { + "name": "梅里达", + "code": "MEX_MID" + }, + { + "name": "蒙克洛瓦", + "code": "MEX_LOV" + }, + { + "name": "蒙特雷", + "code": "MEX_MTY" + }, + { + "name": "莫雷利亚", + "code": "MEX_MLM" + }, + { + "name": "墨西哥城", + "code": "MEX_MEX" + }, + { + "name": "墨西卡利", + "code": "MEX_MXL" + }, + { + "name": "诺加莱斯", + "code": "MEX_NOG" + }, + { + "name": "帕丘卡", + "code": "MEX_PAC" + }, + { + "name": "普埃布拉", + "code": "MEX_PUE" + }, + { + "name": "奇尔潘辛戈", + "code": "MEX_CHI" + }, + { + "name": "奇瓦瓦", + "code": "MEX_CHH" + }, + { + "name": "切图马尔", + "code": "MEX_CTM" + }, + { + "name": "萨尔蒂约", + "code": "MEX_SLW" + }, + { + "name": "萨卡特卡斯", + "code": "MEX_ZAC" + }, + { + "name": "塞拉亚", + "code": "MEX_CLY" + }, + { + "name": "圣路易斯波托亚", + "code": "MEX_SLP" + }, + { + "name": "塔帕丘拉", + "code": "MEX_TAP" + }, + { + "name": "坦皮科", + "code": "MEX_TAM" + }, + { + "name": "特拉斯卡拉", + "code": "MEX_TLA" + }, + { + "name": "特皮克", + "code": "MEX_TPQ" + }, + { + "name": "特瓦坎", + "code": "MEX_TCN" + }, + { + "name": "图斯特拉-古铁雷斯", + "code": "MEX_TGZ" + }, + { + "name": "托雷翁", + "code": "MEX_TRC" + }, + { + "name": "托卢卡", + "code": "MEX_TLC" + }, + { + "name": "瓦哈卡", + "code": "MEX_OAX" + }, + { + "name": "维多利亚城", + "code": "MEX_VIC" + }, + { + "name": "韦拉克鲁斯", + "code": "MEX_VER" + }, + { + "name": "乌鲁阿潘", + "code": "MEX_UPN" + }, + { + "name": "新拉雷多", + "code": "MEX_NLE" + }, + { + "name": "伊拉普阿托", + "code": "MEX_IRP" + } + ] + }, + { + "name": "蒙古", + "code": "MGL" + }, + { + "name": "马其顿", + "code": "MKD" + }, + { + "name": "马里", + "code": "MLI", + "children": [ + { + "name": "巴马科首都区", + "code": "MLI_CD" + }, + { + "name": "基达尔", + "code": "MLI_KD" + }, + { + "name": "加奥", + "code": "MLI_GA" + }, + { + "name": "卡伊", + "code": "MLI_KY" + }, + { + "name": "库利科罗", + "code": "MLI_KL" + }, + { + "name": "莫普提", + "code": "MLI_MP" + }, + { + "name": "塞古", + "code": "MLI_SG" + }, + { + "name": "通布图", + "code": "MLI_TB" + }, + { + "name": "锡卡索", + "code": "MLI_SK" + } + ] + }, + { + "name": "马耳他", + "code": "MLT" + }, + { + "name": "北马里亚纳群岛", + "code": "MNP" + }, + { + "name": "摩纳哥", + "code": "MON" + }, + { + "name": "莫桑比克", + "code": "MOZ" + }, + { + "name": "毛里求斯", + "code": "MRI" + }, + { + "name": "蒙特塞拉特", + "code": "MSR" + }, + { + "name": "毛里塔尼亚", + "code": "MTN" + }, + { + "name": "马提尼克", + "code": "MTQ" + }, + { + "name": "缅甸", + "code": "MYA" + }, + { + "name": "马约特岛", + "code": "MYT" + }, + { + "name": "纳米比亚", + "code": "NAM", + "children": [ + { + "name": "埃龙戈", + "code": "NAM_ER" + }, + { + "name": "奥汉圭纳", + "code": "NAM_OW" + }, + { + "name": "奥卡万戈", + "code": "NAM_KV" + }, + { + "name": "奥马赫科", + "code": "NAM_OK" + }, + { + "name": "奥姆沙蒂", + "code": "NAM_OT" + }, + { + "name": "奥乔宗蒂约巴", + "code": "NAM_OJ" + }, + { + "name": "奥沙纳", + "code": "NAM_ON" + }, + { + "name": "奥希科托", + "code": "NAM_OO" + }, + { + "name": "哈达普", + "code": "NAM_HA" + }, + { + "name": "霍马斯", + "code": "NAM_KH" + }, + { + "name": "卡拉斯", + "code": "NAM_KR" + }, + { + "name": "卡普里维", + "code": "NAM_CA" + }, + { + "name": "库内内", + "code": "NAM_KU" + } + ] + }, + { + "name": "尼加拉瓜", + "code": "NCA" + }, + { + "name": "新喀里多尼亚", + "code": "NCL" + }, + { + "name": "荷兰", + "code": "NED" + }, + { + "name": "尼泊尔", + "code": "NEP" + }, + { + "name": "诺福克", + "code": "NFK" + }, + { + "name": "尼日利亚", + "code": "NGR" + }, + { + "name": "尼日尔", + "code": "NIG" + }, + { + "name": "纽埃", + "code": "NIU" + }, + { + "name": "挪威", + "code": "NOR", + "children": [ + { + "name": "东福尔", + "code": "NOR_1" + }, + { + "name": "阿克什胡斯", + "code": "NOR_2" + }, + { + "name": "奥斯陆市", + "code": "NOR_3" + }, + { + "name": "海德马克", + "code": "NOR_4" + }, + { + "name": "奥普兰", + "code": "NOR_5" + }, + { + "name": "布斯克吕", + "code": "NOR_6" + }, + { + "name": "西福尔", + "code": "NOR_7" + }, + { + "name": "泰勒马克", + "code": "NOR_8" + }, + { + "name": "东阿格德尔", + "code": "NOR_9" + }, + { + "name": "西阿格德尔", + "code": "NOR_10" + }, + { + "name": "罗加兰", + "code": "NOR_11" + }, + { + "name": "霍达兰", + "code": "NOR_12" + }, + { + "name": "松恩-菲尤拉讷", + "code": "NOR_14" + }, + { + "name": "默勒-鲁姆斯达尔", + "code": "NOR_15" + }, + { + "name": "南特伦德拉格", + "code": "NOR_16" + }, + { + "name": "北特伦德拉格", + "code": "NOR_17" + }, + { + "name": "诺尔兰", + "code": "NOR_18" + }, + { + "name": "特罗姆斯", + "code": "NOR_19" + }, + { + "name": "芬马克", + "code": "NOR_20" + } + ] + }, + { + "name": "瑙鲁", + "code": "NRU" + }, + { + "name": "新西兰", + "code": "NZL", + "children": [ + { + "name": "奥克兰", + "code": "NZL_AUK" + }, + { + "name": "北岸", + "code": "NZL_NSH" + }, + { + "name": "北帕默斯顿", + "code": "NZL_PMR" + }, + { + "name": "北远", + "code": "NZL_FNR" + }, + { + "name": "布莱尼姆", + "code": "NZL_BHE" + }, + { + "name": "达尼丁", + "code": "NZL_DUD" + }, + { + "name": "格雷茅斯", + "code": "NZL_GMN" + }, + { + "name": "哈密尔顿", + "code": "NZL_HLZ" + }, + { + "name": "黑斯廷斯", + "code": "NZL_HAS" + }, + { + "name": "怀塔科拉", + "code": "NZL_WAE" + }, + { + "name": "吉斯伯恩", + "code": "NZL_GIS" + }, + { + "name": "凯帕拉", + "code": "NZL_KAI" + }, + { + "name": "克赖斯特彻奇", + "code": "NZL_CHC" + }, + { + "name": "里士满", + "code": "NZL_RMD" + }, + { + "name": "马努考", + "code": "NZL_MNK" + }, + { + "name": "纳尔逊", + "code": "NZL_NSN" + }, + { + "name": "内皮尔", + "code": "NZL_NPE" + }, + { + "name": "斯特拉特福德", + "code": "NZL_STR" + }, + { + "name": "陶马鲁努伊", + "code": "NZL_TAU" + }, + { + "name": "瓦卡塔尼", + "code": "NZL_WHK" + }, + { + "name": "旺阿雷", + "code": "NZL_WRE" + }, + { + "name": "旺格努伊", + "code": "NZL_WAG" + }, + { + "name": "新普利茅斯", + "code": "NZL_NPL" + }, + { + "name": "因弗卡吉尔", + "code": "NZL_IVC" + } + ] + }, + { + "name": "阿曼", + "code": "OMA" + }, + { + "name": "巴基斯坦", + "code": "PAK", + "children": [ + { + "name": "白沙瓦", + "code": "PAK_PEW" + }, + { + "name": "费萨拉巴德", + "code": "PAK_LYP" + }, + { + "name": "故吉软瓦拉", + "code": "PAK_GUJ" + }, + { + "name": "海德拉巴", + "code": "PAK_HDD" + }, + { + "name": "卡拉奇", + "code": "PAK_KCT" + }, + { + "name": "拉合尔", + "code": "PAK_LHE" + }, + { + "name": "拉瓦尔品第", + "code": "PAK_RWP" + }, + { + "name": "木尔坦", + "code": "PAK_MUX" + }, + { + "name": "伊斯兰堡", + "code": "PAK_ISB" + } + ] + }, + { + "name": "巴拿马", + "code": "PAN" + }, + { + "name": "巴拉圭", + "code": "PAR" + }, + { + "name": "皮特凯恩", + "code": "PCN" + }, + { + "name": "菲律宾", + "code": "PHI" + }, + { + "name": "巴勒斯坦", + "code": "PLE" + }, + { + "name": "帕劳群岛", + "code": "PLW" + }, + { + "name": "巴布亚新几内亚", + "code": "PNG", + "children": [ + { + "name": "北部", + "code": "PNG_NO" + }, + { + "name": "布干维尔", + "code": "PNG_BV" + }, + { + "name": "东部高地", + "code": "PNG_EH" + }, + { + "name": "东塞皮克", + "code": "PNG_ES" + }, + { + "name": "东新不列颠", + "code": "PNG_EB" + }, + { + "name": "恩加", + "code": "PNG_EN" + }, + { + "name": "海湾", + "code": "PNG_GU" + }, + { + "name": "马当", + "code": "PNG_MD" + }, + { + "name": "马努斯", + "code": "PNG_MN" + }, + { + "name": "米尔恩湾", + "code": "PNG_MB" + }, + { + "name": "莫尔兹比港", + "code": "PNG_NC" + }, + { + "name": "莫罗贝", + "code": "PNG_MR" + }, + { + "name": "南部高地", + "code": "PNG_SH" + }, + { + "name": "钦布", + "code": "PNG_SI" + }, + { + "name": "桑道恩", + "code": "PNG_SA" + }, + { + "name": "西部", + "code": "PNG_WE" + }, + { + "name": "西部高地", + "code": "PNG_WH" + }, + { + "name": "西新不列颠", + "code": "PNG_WB" + }, + { + "name": "新爱尔兰", + "code": "PNG_NI" + } + ] + }, + { + "name": "波兰", + "code": "POL", + "children": [ + { + "name": "埃尔布隆格", + "code": "POL_ELB" + }, + { + "name": "奥尔什丁", + "code": "POL_OLS" + }, + { + "name": "奥斯特罗文卡", + "code": "POL_OSS" + }, + { + "name": "比得哥什", + "code": "POL_BZG" + }, + { + "name": "彼得库夫", + "code": "POL_PIO" + }, + { + "name": "比托姆", + "code": "POL_BYT" + }, + { + "name": "比亚瓦波德拉斯卡", + "code": "POL_BAP" + }, + { + "name": "比亚维斯托克", + "code": "POL_BIA" + }, + { + "name": "波莱", + "code": "POL_OPO" + }, + { + "name": "波兹南", + "code": "POL_POZ" + }, + { + "name": "达布罗瓦戈尼察", + "code": "POL_DAB" + }, + { + "name": "大波兰地区戈茹夫", + "code": "POL_GOW" + }, + { + "name": "弗罗茨瓦夫", + "code": "POL_WRO" + }, + { + "name": "弗沃茨瓦韦克", + "code": "POL_WLO" + }, + { + "name": "格但斯克", + "code": "POL_GDN" + }, + { + "name": "格丁尼亚", + "code": "POL_GDY" + }, + { + "name": "格利维采", + "code": "POL_GWC" + }, + { + "name": "格鲁琼兹", + "code": "POL_GRU" + }, + { + "name": "海乌姆", + "code": "POL_CHO" + }, + { + "name": "华沙", + "code": "POL_WAW" + }, + { + "name": "霍茹夫", + "code": "POL_CHZ" + }, + { + "name": "卡利什", + "code": "POL_KAL" + }, + { + "name": "卡托维兹", + "code": "POL_KTW" + }, + { + "name": "凯尔采", + "code": "POL_KLC" + }, + { + "name": "科宁", + "code": "POL_KON" + }, + { + "name": "科沙林", + "code": "POL_OSZ" + }, + { + "name": "克拉科夫", + "code": "POL_KRK" + }, + { + "name": "克罗斯诺", + "code": "POL_KRO" + }, + { + "name": "拉多姆", + "code": "POL_RDM" + }, + { + "name": "莱格尼察", + "code": "POL_LEG" + }, + { + "name": "莱什诺", + "code": "POL_LEZ" + }, + { + "name": "卢布林", + "code": "POL_LUL" + }, + { + "name": "鲁达", + "code": "POL_RDS" + }, + { + "name": "罗兹", + "code": "POL_LOD" + }, + { + "name": "绿山城", + "code": "POL_IEG" + }, + { + "name": "米什洛维采", + "code": "POL_MYL" + }, + { + "name": "皮瓦", + "code": "POL_PIL" + }, + { + "name": "普热梅希尔", + "code": "POL_PRZ" + }, + { + "name": "普沃茨克", + "code": "POL_PLO" + }, + { + "name": "切哈努夫", + "code": "POL_CIE" + }, + { + "name": "热舒夫", + "code": "POL_RZE" + }, + { + "name": "什切青", + "code": "POL_SZZ" + }, + { + "name": "斯凯尔涅维采", + "code": "POL_SKI" + }, + { + "name": "斯武普斯克", + "code": "POL_SLP" + }, + { + "name": "苏瓦乌基", + "code": "POL_SWL" + }, + { + "name": "索波特", + "code": "POL_SOP" + }, + { + "name": "索斯诺维茨", + "code": "POL_SWC" + }, + { + "name": "塔尔努夫", + "code": "POL_TAR" + }, + { + "name": "塔尔诺布热格", + "code": "POL_QEP" + }, + { + "name": "特切", + "code": "POL_TYY" + }, + { + "name": "托伦", + "code": "POL_TOR" + }, + { + "name": "瓦乌布日赫", + "code": "POL_WZH" + }, + { + "name": "沃姆扎", + "code": "POL_QOY" + }, + { + "name": "希米亚诺维采", + "code": "POL_SOW" + }, + { + "name": "希维诺乌伊希切", + "code": "POL_SWI" + }, + { + "name": "希维托赫洛维采", + "code": "POL_SWT" + }, + { + "name": "谢德尔采", + "code": "POL_SDC" + }, + { + "name": "谢拉兹", + "code": "POL_SIR" + }, + { + "name": "新松奇", + "code": "POL_NOW" + }, + { + "name": "雅沃兹诺", + "code": "POL_JAW" + }, + { + "name": "耶莱尼亚古拉", + "code": "POL_JEG" + }, + { + "name": "扎布热", + "code": "POL_ZAB" + }, + { + "name": "扎莫希奇", + "code": "POL_ZAM" + } + ] + }, + { + "name": "葡萄牙", + "code": "POR" + }, + { + "name": "朝鲜", + "code": "PRK", + "children": [ + { + "name": "海州", + "code": "PRK_HAE" + }, + { + "name": "惠山", + "code": "PRK_HYE" + }, + { + "name": "江界", + "code": "PRK_KAN" + }, + { + "name": "开城", + "code": "PRK_KSN" + }, + { + "name": "罗先", + "code": "PRK_NAS" + }, + { + "name": "南浦", + "code": "PRK_NAM" + }, + { + "name": "平壤", + "code": "PRK_FNJ" + }, + { + "name": "清津", + "code": "PRK_CHO" + }, + { + "name": "沙里院", + "code": "PRK_SAR" + }, + { + "name": "咸兴", + "code": "PRK_HAM" + }, + { + "name": "新义州", + "code": "PRK_SII" + }, + { + "name": "元山", + "code": "PRK_WON" + } + ] + }, + { + "name": "波多黎各", + "code": "PUR" + }, + { + "name": "法属波利尼西亚", + "code": "PYF" + }, + { + "name": "卡塔尔", + "code": "QAT", + "children": [ + { + "name": "北部", + "code": "QAT_MS" + }, + { + "name": "多哈", + "code": "QAT_DW" + }, + { + "name": "古韦里耶", + "code": "QAT_GW" + }, + { + "name": "豪尔", + "code": "QAT_KR" + }, + { + "name": "杰里扬拜特奈", + "code": "QAT_JB" + }, + { + "name": "赖扬", + "code": "QAT_RN" + }, + { + "name": "沃克拉", + "code": "QAT_WK" + }, + { + "name": "乌姆锡拉勒", + "code": "QAT_UL" + }, + { + "name": "朱迈利耶", + "code": "QAT_JM" + } + ] + }, + { + "name": "留尼旺岛", + "code": "REU" + }, + { + "name": "马绍尔群岛", + "code": "RMI" + }, + { + "name": "罗马尼亚", + "code": "ROM" + }, + { + "name": "南非", + "code": "RSA" + }, + { + "name": "俄罗斯", + "code": "RUS", + "children": [ + { + "name": "阿巴坎", + "code": "RUS_ABA" + }, + { + "name": "阿尔汉格尔斯克", + "code": "RUS_ARK" + }, + { + "name": "阿金斯科耶", + "code": "RUS_AGI" + }, + { + "name": "阿纳德尔", + "code": "RUS_DYR" + }, + { + "name": "阿斯特拉罕", + "code": "RUS_AST" + }, + { + "name": "埃利斯塔", + "code": "RUS_ESL" + }, + { + "name": "奥廖尔", + "code": "RUS_ORL" + }, + { + "name": "奥伦堡", + "code": "RUS_ORE" + }, + { + "name": "巴尔瑙尔", + "code": "RUS_BAX" + }, + { + "name": "奔萨", + "code": "RUS_PNZ" + }, + { + "name": "彼得罗巴甫洛夫斯克", + "code": "RUS_PKC" + }, + { + "name": "彼得罗扎沃茨克", + "code": "RUS_PES" + }, + { + "name": "比罗比詹", + "code": "RUS_BBZ" + }, + { + "name": "别尔哥罗德", + "code": "RUS_BEL" + }, + { + "name": "伯力", + "code": "RUS_COK" + }, + { + "name": "布拉戈维申斯克", + "code": "RUS_BQS" + }, + { + "name": "布良斯克", + "code": "RUS_BRY" + }, + { + "name": "赤塔", + "code": "RUS_CHI" + }, + { + "name": "顿河畔罗斯托夫", + "code": "RUS_ROS" + }, + { + "name": "鄂木斯克", + "code": "RUS_OMS" + }, + { + "name": "伏尔加格勒", + "code": "RUS_VOG" + }, + { + "name": "弗拉基米尔", + "code": "RUS_VMR" + }, + { + "name": "弗拉季高加索", + "code": "RUS_VLA" + }, + { + "name": "戈尔诺-阿尔泰斯克", + "code": "RUS_GOA" + }, + { + "name": "格罗兹尼", + "code": "RUS_GRV" + }, + { + "name": "海参崴", + "code": "RUS_VVO" + }, + { + "name": "汉特-曼西斯克", + "code": "RUS_KHM" + }, + { + "name": "基洛夫", + "code": "RUS_KIR" + }, + { + "name": "加里宁格勒", + "code": "RUS_KGD" + }, + { + "name": "喀山", + "code": "RUS_KZN" + }, + { + "name": "卡卢加", + "code": "RUS_KLF" + }, + { + "name": "科斯特罗马", + "code": "RUS_KOS" + }, + { + "name": "克拉斯诺达尔", + "code": "RUS_KRR" + }, + { + "name": "克拉斯诺亚尔斯克", + "code": "RUS_KYA" + }, + { + "name": "克麦罗沃", + "code": "RUS_KEM" + }, + { + "name": "克孜勒", + "code": "RUS_KYZ" + }, + { + "name": "库德姆卡尔", + "code": "RUS_KUD" + }, + { + "name": "库尔干", + "code": "RUS_KRO" + }, + { + "name": "库尔斯克", + "code": "RUS_URS" + }, + { + "name": "利佩茨克", + "code": "RUS_LIP" + }, + { + "name": "梁赞", + "code": "RUS_RYA" + }, + { + "name": "马哈奇卡拉", + "code": "RUS_MCX" + }, + { + "name": "马加丹", + "code": "RUS_MAG" + }, + { + "name": "马加斯", + "code": "RUS_IN" + }, + { + "name": "迈科普", + "code": "RUS_MAY" + }, + { + "name": "摩尔曼斯克", + "code": "RUS_MMK" + }, + { + "name": "莫斯科", + "code": "RUS_MOW" + }, + { + "name": "纳尔奇克", + "code": "RUS_NAL" + }, + { + "name": "纳里扬马尔", + "code": "RUS_NNM" + }, + { + "name": "南萨哈林斯克", + "code": "RUS_JSA" + }, + { + "name": "诺夫哥罗德", + "code": "RUS_VUS" + }, + { + "name": "帕拉纳", + "code": "RUS_PAL" + }, + { + "name": "普斯科夫", + "code": "RUS_PSK" + }, + { + "name": "切博克萨雷", + "code": "RUS_CSY" + }, + { + "name": "切尔克斯克", + "code": "RUS_CKS" + }, + { + "name": "秋明", + "code": "RUS_TYU" + }, + { + "name": "萨拉托夫", + "code": "RUS_SAR" + }, + { + "name": "萨兰斯克", + "code": "RUS_SKX" + }, + { + "name": "萨列哈尔德", + "code": "RUS_SLY" + }, + { + "name": "萨马拉", + "code": "RUS_SAM" + }, + { + "name": "瑟克特夫卡尔", + "code": "RUS_SCW" + }, + { + "name": "圣彼得堡", + "code": "RUS_SPE" + }, + { + "name": "斯摩棱斯克", + "code": "RUS_LNX" + }, + { + "name": "斯塔夫罗波尔", + "code": "RUS_STA" + }, + { + "name": "坦波夫", + "code": "RUS_TAM" + }, + { + "name": "特维尔", + "code": "RUS_TVE" + }, + { + "name": "图拉", + "code": "RUS_TUL" + }, + { + "name": "托木斯克", + "code": "RUS_TOM" + }, + { + "name": "沃罗涅什", + "code": "RUS_VOR" + }, + { + "name": "沃洛格达", + "code": "RUS_VLG" + }, + { + "name": "乌法", + "code": "RUS_UFA" + }, + { + "name": "乌兰乌德", + "code": "RUS_UUD" + }, + { + "name": "乌里扬诺夫斯克", + "code": "RUS_ULY" + }, + { + "name": "乌斯季奥尔登斯基", + "code": "RUS_UOB" + }, + { + "name": "下诺夫哥罗德", + "code": "RUS_GOJ" + }, + { + "name": "新西伯利亚", + "code": "RUS_NVS" + }, + { + "name": "雅库茨克", + "code": "RUS_JAK" + }, + { + "name": "雅罗斯拉夫尔", + "code": "RUS_JAR" + }, + { + "name": "叶卡捷林堡", + "code": "RUS_JEK" + }, + { + "name": "伊尔库茨克", + "code": "RUS_IKT" + }, + { + "name": "伊热夫斯克", + "code": "RUS_IJK" + }, + { + "name": "伊万诺沃", + "code": "RUS_IVO" + }, + { + "name": "约什卡尔奥拉", + "code": "RUS_YOL" + } + ] + }, + { + "name": "卢旺达", + "code": "RWA", + "children": [ + { + "name": "比温巴", + "code": "RWA_BY" + }, + { + "name": "布塔雷", + "code": "RWA_BU" + }, + { + "name": "恩延扎", + "code": "RWA_NY" + }, + { + "name": "基本古", + "code": "RWA_KG" + }, + { + "name": "基布耶", + "code": "RWA_KY" + }, + { + "name": "基加利-恩加利", + "code": "RWA_KR" + }, + { + "name": "基加利市", + "code": "RWA_KV" + }, + { + "name": "吉孔戈罗", + "code": "RWA_GK" + }, + { + "name": "吉塞尼", + "code": "RWA_GS" + }, + { + "name": "吉塔拉马", + "code": "RWA_GT" + }, + { + "name": "卡布加", + "code": "RWA_KA" + }, + { + "name": "卢瓦马加纳", + "code": "RWA_RW" + }, + { + "name": "鲁汉戈", + "code": "RWA_RH" + }, + { + "name": "鲁亨盖里", + "code": "RWA_RU" + }, + { + "name": "尚古古", + "code": "RWA_CY" + }, + { + "name": "乌姆塔拉", + "code": "RWA_UM" + } + ] + }, + { + "name": "萨摩亚", + "code": "SAM" + }, + { + "name": "塞尔维亚和黑山", + "code": "SCG", + "children": [ + { + "name": "贝尔格莱德", + "code": "SCG_BEG" + }, + { + "name": "波德戈里察", + "code": "SCG_POD" + }, + { + "name": "克拉古涅瓦茨", + "code": "SCG_KGV" + }, + { + "name": "尼什", + "code": "SCG_INI" + }, + { + "name": "诺维萨德", + "code": "SCG_NVS" + }, + { + "name": "普里什蒂纳", + "code": "SCG_PRN" + }, + { + "name": "苏博蒂察", + "code": "SCG_SUB" + }, + { + "name": "泽蒙", + "code": "SCG_ZEM" + } + ] + }, + { + "name": "塞内加尔", + "code": "SEN", + "children": [ + { + "name": "达喀尔", + "code": "SEN_DA" + }, + { + "name": "法蒂克", + "code": "SEN_FA" + }, + { + "name": "济金绍尔", + "code": "SEN_ZI" + }, + { + "name": "捷斯", + "code": "SEN_TH" + }, + { + "name": "久尔贝勒", + "code": "SEN_DI" + }, + { + "name": "考拉克", + "code": "SEN_KA" + }, + { + "name": "科尔达", + "code": "SEN_KO" + }, + { + "name": "卢加", + "code": "SEN_LO" + }, + { + "name": "马塔姆", + "code": "SEN_MA" + }, + { + "name": "圣路易", + "code": "SEN_SL" + }, + { + "name": "坦巴昆达", + "code": "SEN_TA" + } + ] + }, + { + "name": "塞舌尔", + "code": "SEY" + }, + { + "name": "新加坡", + "code": "SGP" + }, + { + "name": "南乔治亚和南桑德威奇群岛", + "code": "SGS" + }, + { + "name": "圣赫勒拿", + "code": "SHN" + }, + { + "name": "斯瓦尔巴和扬马廷", + "code": "SJM" + }, + { + "name": "圣基茨和尼维斯", + "code": "SKN" + }, + { + "name": "塞拉利昂", + "code": "SLE", + "children": [ + { + "name": "北部", + "code": "SLE_N" + }, + { + "name": "东部", + "code": "SLE_E" + }, + { + "name": "南部", + "code": "SLE_S" + }, + { + "name": "西部区", + "code": "SLE_W" + } + ] + }, + { + "name": "斯洛文尼亚", + "code": "SLO" + }, + { + "name": "圣马力诺", + "code": "SMR" + }, + { + "name": "所罗门群岛", + "code": "SOL" + }, + { + "name": "索马里", + "code": "SOM" + }, + { + "name": "圣皮埃尔和米克隆群岛", + "code": "SPM" + }, + { + "name": "塞尔维亚", + "code": "SRB" + }, + { + "name": "斯里兰卡", + "code": "SRI" + }, + { + "name": "圣多美和普林西比", + "code": "STP" + }, + { + "name": "苏丹", + "code": "SUD" + }, + { + "name": "瑞士", + "code": "SUI" + }, + { + "name": "苏里南", + "code": "SUR", + "children": [ + { + "name": "布罗科蓬多", + "code": "SUR_BR" + }, + { + "name": "科罗尼", + "code": "SUR_CR" + }, + { + "name": "科默韦讷", + "code": "SUR_CM" + }, + { + "name": "马罗韦讷", + "code": "SUR_MA" + }, + { + "name": "尼克里", + "code": "SUR_NI" + }, + { + "name": "帕拉", + "code": "SUR_PA" + }, + { + "name": "帕拉马里博", + "code": "SUR_PM" + }, + { + "name": "萨拉马卡", + "code": "SUR_SA" + }, + { + "name": "瓦尼卡", + "code": "SUR_WA" + }, + { + "name": "西帕里韦尼", + "code": "SUR_SI" + } + ] + }, + { + "name": "斯洛伐克", + "code": "SVK", + "children": [ + { + "name": "班斯卡-比斯特里察", + "code": "SVK_BBY" + }, + { + "name": "布拉迪斯拉发", + "code": "SVK_BTS" + }, + { + "name": "科希策", + "code": "SVK_KOR" + }, + { + "name": "尼特拉", + "code": "SVK_NRA" + }, + { + "name": "普雷绍夫", + "code": "SVK_POV" + }, + { + "name": "日利纳", + "code": "SVK_RIL" + }, + { + "name": "特尔纳瓦", + "code": "SVK_TNA" + }, + { + "name": "特伦钦", + "code": "SVK_TRE" + } + ] + }, + { + "name": "瑞典", + "code": "SWE", + "children": [ + { + "name": "北博滕", + "code": "SWE_BD" + }, + { + "name": "布莱金厄", + "code": "SWE_K" + }, + { + "name": "达拉纳", + "code": "SWE_DLN" + }, + { + "name": "东约特兰", + "code": "SWE_UGL" + }, + { + "name": "厄勒布鲁", + "code": "SWE_T" + }, + { + "name": "哥得兰", + "code": "SWE_I" + }, + { + "name": "哈兰", + "code": "SWE_N" + }, + { + "name": "卡尔马", + "code": "SWE_H" + }, + { + "name": "克鲁努贝里", + "code": "SWE_G" + }, + { + "name": "南曼兰", + "code": "SWE_D" + }, + { + "name": "斯德哥尔摩", + "code": "SWE_AB" + }, + { + "name": "斯科耐", + "code": "SWE_M" + }, + { + "name": "韦姆兰", + "code": "SWE_S" + }, + { + "name": "乌普萨拉", + "code": "SWE_C" + }, + { + "name": "西博滕", + "code": "SWE_AC" + }, + { + "name": "西曼兰", + "code": "SWE_U" + }, + { + "name": "西诺尔兰", + "code": "SWE_Y" + }, + { + "name": "西约特兰", + "code": "SWE_O" + }, + { + "name": "延雪平", + "code": "SWE_F" + }, + { + "name": "耶夫勒堡", + "code": "SWE_X" + }, + { + "name": "耶姆特兰", + "code": "SWE_Z" + } + ] + }, + { + "name": "斯威士兰", + "code": "SWZ" + }, + { + "name": "叙利亚", + "code": "SYR", + "children": [ + { + "name": "阿勒颇", + "code": "SYR_HL" + }, + { + "name": "大马士革", + "code": "SYR_RD" + }, + { + "name": "大马士革市", + "code": "SYR_DI" + }, + { + "name": "代尔祖尔", + "code": "SYR_DZ" + }, + { + "name": "德拉", + "code": "SYR_DA" + }, + { + "name": "哈马", + "code": "SYR_HM" + }, + { + "name": "哈塞克", + "code": "SYR_HA" + }, + { + "name": "霍姆斯", + "code": "SYR_HI" + }, + { + "name": "加布", + "code": "SYR_GH" + }, + { + "name": "卡米什利", + "code": "SYR_QA" + }, + { + "name": "库奈特拉", + "code": "SYR_QU" + }, + { + "name": "拉卡", + "code": "SYR_RQ" + }, + { + "name": "拉塔基亚", + "code": "SYR_LA" + }, + { + "name": "苏韦达", + "code": "SYR_SU" + }, + { + "name": "塔尔图斯", + "code": "SYR_TA" + }, + { + "name": "伊德利卜", + "code": "SYR_ID" + } + ] + }, + { + "name": "特里斯坦达昆哈", + "code": "TAA" + }, + { + "name": "坦桑尼亚", + "code": "TAN" + }, + { + "name": "特克斯和凯克特斯群岛", + "code": "TCA" + }, + { + "name": "汤加", + "code": "TGA" + }, + { + "name": "泰国", + "code": "THA", + "children": [ + { + "name": "曼谷", + "code": "THA_10" + }, + { + "name": "北揽", + "code": "THA_11" + }, + { + "name": "暖武里", + "code": "THA_12" + }, + { + "name": "巴吞他尼", + "code": "THA_13" + }, + { + "name": "大城", + "code": "THA_14" + }, + { + "name": "红统", + "code": "THA_15" + }, + { + "name": "华富里", + "code": "THA_16" + }, + { + "name": "信武里", + "code": "THA_17" + }, + { + "name": "猜那", + "code": "THA_18" + }, + { + "name": "北标", + "code": "THA_19" + }, + { + "name": "春武里", + "code": "THA_20" + }, + { + "name": "拉农", + "code": "THA_21" + }, + { + "name": "尖竹汶", + "code": "THA_22" + }, + { + "name": "达叻", + "code": "THA_23" + }, + { + "name": "北柳", + "code": "THA_24" + }, + { + "name": "巴真", + "code": "THA_25" + }, + { + "name": "那空那育", + "code": "THA_26" + }, + { + "name": "沙缴", + "code": "THA_27" + }, + { + "name": "武里南", + "code": "THA_31" + }, + { + "name": "素林", + "code": "THA_32" + }, + { + "name": "四色菊", + "code": "THA_33" + }, + { + "name": "乌汶", + "code": "THA_34" + }, + { + "name": "耶梭通", + "code": "THA_35" + }, + { + "name": "猜也奔", + "code": "THA_36" + }, + { + "name": "安纳乍能", + "code": "THA_37" + }, + { + "name": "廊莫那浦", + "code": "THA_39" + }, + { + "name": "孔敬", + "code": "THA_40" + }, + { + "name": "乌隆", + "code": "THA_41" + }, + { + "name": "黎", + "code": "THA_42" + }, + { + "name": "廊开", + "code": "THA_43" + }, + { + "name": "玛哈沙拉堪", + "code": "THA_44" + }, + { + "name": "黎逸", + "code": "THA_45" + }, + { + "name": "加拉信", + "code": "THA_46" + }, + { + "name": "色军", + "code": "THA_47" + }, + { + "name": "那空帕农", + "code": "THA_48" + }, + { + "name": "莫达汉", + "code": "THA_49" + }, + { + "name": "清迈", + "code": "THA_50" + }, + { + "name": "南奔", + "code": "THA_51" + }, + { + "name": "程逸", + "code": "THA_53" + }, + { + "name": "帕", + "code": "THA_54" + }, + { + "name": "难", + "code": "THA_55" + }, + { + "name": "帕尧", + "code": "THA_56" + }, + { + "name": "清莱", + "code": "THA_57" + }, + { + "name": "夜丰颂", + "code": "THA_58" + }, + { + "name": "北榄坡", + "code": "THA_60" + }, + { + "name": "乌泰他尼", + "code": "THA_61" + }, + { + "name": "甘烹碧", + "code": "THA_62" + }, + { + "name": "达", + "code": "THA_63" + }, + { + "name": "素可泰", + "code": "THA_64" + }, + { + "name": "彭世洛", + "code": "THA_65" + }, + { + "name": "披集", + "code": "THA_66" + }, + { + "name": "叻丕", + "code": "THA_70" + }, + { + "name": "北碧", + "code": "THA_71" + }, + { + "name": "素攀武里", + "code": "THA_72" + }, + { + "name": "佛统", + "code": "THA_73" + }, + { + "name": "龙仔厝", + "code": "THA_74" + }, + { + "name": "夜功", + "code": "THA_75" + }, + { + "name": "碧差汶", + "code": "THA_76" + }, + { + "name": "巴蜀", + "code": "THA_77" + }, + { + "name": "佛丕", + "code": "THA_78" + }, + { + "name": "洛坤", + "code": "THA_80" + }, + { + "name": "甲米", + "code": "THA_81" + }, + { + "name": "攀牙", + "code": "THA_82" + }, + { + "name": "普吉", + "code": "THA_83" + }, + { + "name": "素叻", + "code": "THA_84" + }, + { + "name": "罗勇", + "code": "THA_85" + }, + { + "name": "春蓬", + "code": "THA_86" + }, + { + "name": "宋卡", + "code": "THA_90" + }, + { + "name": "沙敦", + "code": "THA_91" + }, + { + "name": "董里", + "code": "THA_92" + }, + { + "name": "博达伦", + "code": "THA_93" + }, + { + "name": "北大年", + "code": "THA_94" + }, + { + "name": "也拉", + "code": "THA_95" + }, + { + "name": "陶公", + "code": "THA_96" + } + ] + }, + { + "name": "塔吉克斯坦", + "code": "TJK", + "children": [ + { + "name": "杜尚别", + "code": "TJK_DYU" + }, + { + "name": "霍罗格", + "code": "TJK_KHO" + }, + { + "name": "卡尼巴达姆", + "code": "TJK_KAN" + }, + { + "name": "科法尔尼洪", + "code": "TJK_KOF" + }, + { + "name": "苦盏", + "code": "TJK_KHU" + }, + { + "name": "库尔干-秋别", + "code": "TJK_KTJ" + }, + { + "name": "库洛布", + "code": "TJK_KLB" + }, + { + "name": "洛贡", + "code": "TJK_RGU" + }, + { + "name": "努雷克", + "code": "TJK_NUR" + }, + { + "name": "彭吉肯特", + "code": "TJK_PJK" + }, + { + "name": "萨班特", + "code": "TJK_SBA" + }, + { + "name": "塔博沙尔", + "code": "TJK_TBS" + }, + { + "name": "图尔孙扎德", + "code": "TJK_TSZ" + }, + { + "name": "乌拉秋别", + "code": "TJK_UTJ" + }, + { + "name": "伊斯法拉", + "code": "TJK_ISF" + } + ] + }, + { + "name": "托克劳", + "code": "TKL" + }, + { + "name": "土库曼斯坦", + "code": "TKM", + "children": [ + { + "name": "阿哈尔", + "code": "TKM_A" + }, + { + "name": "阿什哈巴德市", + "code": "TKM_ASB" + }, + { + "name": "巴尔坎", + "code": "TKM_B" + }, + { + "name": "达沙古兹", + "code": "TKM_D" + }, + { + "name": "列巴普", + "code": "TKM_L" + }, + { + "name": "马雷", + "code": "TKM_M" + }, + { + "name": "涅比特达格", + "code": "TKM_NEB" + } + ] + }, + { + "name": "东帝汶", + "code": "TLS", + "children": [ + { + "name": "阿伊莱乌", + "code": "TLS_AL" + }, + { + "name": "阿伊纳罗", + "code": "TLS_AN" + }, + { + "name": "埃尔梅拉", + "code": "TLS_ER" + }, + { + "name": "安贝诺", + "code": "TLS_AM" + }, + { + "name": "包考", + "code": "TLS_BA" + }, + { + "name": "博博纳罗", + "code": "TLS_BO" + }, + { + "name": "帝力", + "code": "TLS_DI" + }, + { + "name": "科瓦利马", + "code": "TLS_KO" + }, + { + "name": "劳滕", + "code": "TLS_LA" + }, + { + "name": "利基卡", + "code": "TLS_LI" + }, + { + "name": "马纳图托", + "code": "TLS_MT" + }, + { + "name": "马努法伊", + "code": "TLS_MF" + }, + { + "name": "维克克", + "code": "TLS_VI" + } + ] + }, + { + "name": "多哥", + "code": "TOG" + }, + { + "name": "特立尼达和多巴哥", + "code": "TRI" + }, + { + "name": "土耳其", + "code": "TUR", + "children": [ + { + "name": "阿达纳", + "code": "TUR_ADA" + }, + { + "name": "阿德亚曼", + "code": "TUR_ADI" + }, + { + "name": "阿尔达罕", + "code": "TUR_ARD" + }, + { + "name": "阿尔特温", + "code": "TUR_ART" + }, + { + "name": "阿菲永", + "code": "TUR_AFY" + }, + { + "name": "阿克萨赖", + "code": "TUR_AKS" + }, + { + "name": "阿勒", + "code": "TUR_AGR" + }, + { + "name": "阿马西亚", + "code": "TUR_AMA" + }, + { + "name": "埃迪尔内", + "code": "TUR_EDI" + }, + { + "name": "埃尔津詹", + "code": "TUR_EZC" + }, + { + "name": "埃尔祖鲁姆", + "code": "TUR_EZR" + }, + { + "name": "埃拉泽", + "code": "TUR_ELA" + }, + { + "name": "埃斯基谢希尔", + "code": "TUR_ESK" + }, + { + "name": "艾登", + "code": "TUR_AYI" + }, + { + "name": "安卡拉", + "code": "TUR_ANK" + }, + { + "name": "安塔利亚", + "code": "TUR_ANT" + }, + { + "name": "奥尔杜", + "code": "TUR_ORD" + }, + { + "name": "巴尔腾", + "code": "TUR_BAR" + }, + { + "name": "巴勒克埃西尔", + "code": "TUR_BAL" + }, + { + "name": "巴特曼", + "code": "TUR_BAT" + }, + { + "name": "巴伊布尔特", + "code": "TUR_BAY" + }, + { + "name": "比莱吉克", + "code": "TUR_BIL" + }, + { + "name": "比特利斯", + "code": "TUR_BIT" + }, + { + "name": "宾格尔", + "code": "TUR_BIN" + }, + { + "name": "博卢", + "code": "TUR_BOL" + }, + { + "name": "布尔杜尔", + "code": "TUR_BRD" + }, + { + "name": "布尔萨", + "code": "TUR_BRS" + }, + { + "name": "昌克勒", + "code": "TUR_CKR" + }, + { + "name": "代尼兹利", + "code": "TUR_DEN" + }, + { + "name": "迪亚巴克尔", + "code": "TUR_DIY" + }, + { + "name": "凡", + "code": "TUR_VAN" + }, + { + "name": "哈卡里", + "code": "TUR_HKR" + }, + { + "name": "哈塔伊", + "code": "TUR_HTY" + }, + { + "name": "基利斯", + "code": "TUR_KLS" + }, + { + "name": "吉雷松", + "code": "TUR_GIR" + }, + { + "name": "加济安泰普", + "code": "TUR_GAZ" + }, + { + "name": "居米什哈内", + "code": "TUR_GMS" + }, + { + "name": "卡尔斯", + "code": "TUR_KRS" + }, + { + "name": "卡赫拉曼马拉什", + "code": "TUR_KAH" + }, + { + "name": "卡拉比克", + "code": "TUR_KRB" + }, + { + "name": "卡拉曼", + "code": "TUR_KRM" + }, + { + "name": "卡斯塔莫努", + "code": "TUR_KAS" + }, + { + "name": "开塞利", + "code": "TUR_KAY" + }, + { + "name": "科贾埃利", + "code": "TUR_KOC" + }, + { + "name": "柯克拉雷利", + "code": "TUR_KLR" + }, + { + "name": "科尼亚", + "code": "TUR_KON" + }, + { + "name": "克尔谢希尔", + "code": "TUR_KRH" + }, + { + "name": "克勒克卡莱", + "code": "TUR_KRK" + }, + { + "name": "拉飞", + "code": "TUR_URF" + }, + { + "name": "里泽", + "code": "TUR_RIZ" + }, + { + "name": "马尔丁", + "code": "TUR_MAR" + }, + { + "name": "马拉蒂亚", + "code": "TUR_MAL" + }, + { + "name": "马尼萨", + "code": "TUR_MAN" + }, + { + "name": "穆拉", + "code": "TUR_MUG" + }, + { + "name": "穆什", + "code": "TUR_MUS" + }, + { + "name": "内夫谢希尔", + "code": "TUR_NEV" + }, + { + "name": "尼代", + "code": "TUR_NIG" + }, + { + "name": "恰纳卡莱", + "code": "TUR_CKL" + }, + { + "name": "乔鲁姆", + "code": "TUR_COR" + }, + { + "name": "屈塔希亚", + "code": "TUR_KUT" + }, + { + "name": "萨卡里亚", + "code": "TUR_SAK" + }, + { + "name": "萨姆松", + "code": "TUR_SAM" + }, + { + "name": "泰基尔达", + "code": "TUR_TEL" + }, + { + "name": "特拉布宗", + "code": "TUR_TRA" + }, + { + "name": "托卡特", + "code": "TUR_TOK" + }, + { + "name": "乌萨克", + "code": "TUR_USK" + }, + { + "name": "锡尔纳克", + "code": "TUR_SIR" + }, + { + "name": "锡尔特", + "code": "TUR_SII" + }, + { + "name": "锡诺普", + "code": "TUR_SIN" + }, + { + "name": "锡瓦斯", + "code": "TUR_SIV" + }, + { + "name": "伊迪尔", + "code": "TUR_IGD" + }, + { + "name": "伊切尔", + "code": "TUR_ICE" + }, + { + "name": "伊斯帕尔塔", + "code": "TUR_ISP" + }, + { + "name": "伊斯坦布尔", + "code": "TUR_IST" + }, + { + "name": "伊兹密尔", + "code": "TUR_IZM" + }, + { + "name": "约兹加特", + "code": "TUR_YOZ" + }, + { + "name": "宗古尔达克", + "code": "TUR_ZON" + } + ] + }, + { + "name": "图瓦卢", + "code": "TUV" + }, + { + "name": "阿联酋", + "code": "UAE" + }, + { + "name": "乌干达", + "code": "UGA", + "children": [ + { + "name": "阿鲁阿", + "code": "UGA_ARU" + }, + { + "name": "阿帕克", + "code": "UGA_APC" + }, + { + "name": "阿朱马尼", + "code": "UGA_ADJ" + }, + { + "name": "本迪布焦", + "code": "UGA_BUN" + }, + { + "name": "布吉里", + "code": "UGA_BUG" + }, + { + "name": "布西亚", + "code": "UGA_BUS" + }, + { + "name": "布谢尼", + "code": "UGA_BSH" + }, + { + "name": "恩通加莫", + "code": "UGA_NTU" + }, + { + "name": "古卢", + "code": "UGA_GUL" + }, + { + "name": "霍伊马", + "code": "UGA_HOI" + }, + { + "name": "基巴莱", + "code": "UGA_KBA" + }, + { + "name": "基博加", + "code": "UGA_KIB" + }, + { + "name": "基恩乔乔", + "code": "UGA_KYE" + }, + { + "name": "基索罗", + "code": "UGA_KIS" + }, + { + "name": "基特古姆", + "code": "UGA_KIT" + }, + { + "name": "金贾", + "code": "UGA_JIN" + }, + { + "name": "卡巴莱", + "code": "UGA_KBL" + }, + { + "name": "卡巴罗莱", + "code": "UGA_KAR" + }, + { + "name": "卡贝拉马伊多", + "code": "UGA_KAB" + }, + { + "name": "卡兰加拉", + "code": "UGA_KAL" + }, + { + "name": "卡姆文盖", + "code": "UGA_KAM" + }, + { + "name": "卡穆利", + "code": "UGA_KML" + }, + { + "name": "卡农古", + "code": "UGA_KAN" + }, + { + "name": "卡普乔鲁瓦", + "code": "UGA_KPC" + }, + { + "name": "卡塞塞", + "code": "UGA_KAS" + }, + { + "name": "卡塔奎", + "code": "UGA_KTK" + }, + { + "name": "卡永加", + "code": "UGA_KAY" + }, + { + "name": "坎帕拉", + "code": "UGA_KMP" + }, + { + "name": "科蒂多", + "code": "UGA_KOT" + }, + { + "name": "库米", + "code": "UGA_KUM" + }, + { + "name": "拉卡伊", + "code": "UGA_RAK" + }, + { + "name": "利拉", + "code": "UGA_LIR" + }, + { + "name": "卢韦罗", + "code": "UGA_LUW" + }, + { + "name": "鲁昆吉里", + "code": "UGA_RUK" + }, + { + "name": "马萨卡", + "code": "UGA_MAS" + }, + { + "name": "马辛迪", + "code": "UGA_MSN" + }, + { + "name": "马尤盖", + "code": "UGA_MAY" + }, + { + "name": "莫罗托", + "code": "UGA_MRT" + }, + { + "name": "莫约", + "code": "UGA_MOY" + }, + { + "name": "姆巴拉拉", + "code": "UGA_MBR" + }, + { + "name": "姆巴莱", + "code": "UGA_MBA" + }, + { + "name": "姆皮吉", + "code": "UGA_MPI" + }, + { + "name": "穆本德", + "code": "UGA_MUB" + }, + { + "name": "穆科诺", + "code": "UGA_MUK" + }, + { + "name": "纳卡皮里皮里特", + "code": "UGA_NAK" + }, + { + "name": "纳卡松戈拉", + "code": "UGA_NKS" + }, + { + "name": "内比", + "code": "UGA_NEB" + }, + { + "name": "帕德尔", + "code": "UGA_PAD" + }, + { + "name": "帕利萨", + "code": "UGA_PAL" + }, + { + "name": "森巴布莱", + "code": "UGA_SEM" + }, + { + "name": "索罗提", + "code": "UGA_SOR" + }, + { + "name": "托罗罗", + "code": "UGA_TOR" + }, + { + "name": "瓦基索", + "code": "UGA_WAK" + }, + { + "name": "锡龙科", + "code": "UGA_SIR" + }, + { + "name": "伊甘加", + "code": "UGA_IGA" + }, + { + "name": "永贝", + "code": "UGA_YUM" + } + ] + }, + { + "name": "乌克兰", + "code": "UKR", + "children": [ + { + "name": "文尼察", + "code": "UKR_5" + }, + { + "name": "沃伦", + "code": "UKR_7" + }, + { + "name": "卢甘斯克", + "code": "UKR_9" + }, + { + "name": "第聂伯罗波得罗夫斯克", + "code": "UKR_12" + }, + { + "name": "顿涅茨克", + "code": "UKR_14" + }, + { + "name": "日托米尔", + "code": "UKR_18" + }, + { + "name": "外喀尔巴阡", + "code": "UKR_21" + }, + { + "name": "扎波罗热", + "code": "UKR_23" + }, + { + "name": "伊万-弗兰科夫州", + "code": "UKR_26" + }, + { + "name": "基辅", + "code": "UKR_30" + }, + { + "name": "基洛夫格勒", + "code": "UKR_35" + }, + { + "name": "克里米亚自治共和国", + "code": "UKR_43" + }, + { + "name": "利沃夫", + "code": "UKR_46" + }, + { + "name": "尼古拉耶夫", + "code": "UKR_48" + }, + { + "name": "敖德萨", + "code": "UKR_51" + }, + { + "name": "波尔塔瓦", + "code": "UKR_53" + }, + { + "name": "罗夫诺", + "code": "UKR_56" + }, + { + "name": "苏梅", + "code": "UKR_59" + }, + { + "name": "捷尔诺波尔", + "code": "UKR_61" + }, + { + "name": "哈尔科夫", + "code": "UKR_63" + }, + { + "name": "赫尔松州", + "code": "UKR_65" + }, + { + "name": "赫梅利尼茨基", + "code": "UKR_68" + }, + { + "name": "切尔卡瑟", + "code": "UKR_71" + }, + { + "name": "切尔尼戈夫", + "code": "UKR_74" + }, + { + "name": "切尔诺夫策", + "code": "UKR_77" + } + ] + }, + { + "name": "美属外岛", + "code": "UMI" + }, + { + "name": "乌拉圭", + "code": "URU" + }, + { + "name": "美国", + "code": "USA", + "children": [ + { + "name": "阿肯色", + "code": "USA_AR" + }, + { + "name": "阿拉巴马", + "code": "USA_AL" + }, + { + "name": "阿拉斯加", + "code": "USA_AK" + }, + { + "name": "爱达荷", + "code": "USA_ID" + }, + { + "name": "爱荷华", + "code": "USA_IA" + }, + { + "name": "北达科他", + "code": "USA_ND" + }, + { + "name": "北卡罗来纳", + "code": "USA_NC" + }, + { + "name": "宾夕法尼亚", + "code": "USA_PA" + }, + { + "name": "德克萨斯", + "code": "USA_TX" + }, + { + "name": "俄亥俄", + "code": "USA_OH" + }, + { + "name": "俄克拉荷马", + "code": "USA_OK" + }, + { + "name": "俄勒冈", + "code": "USA_OR" + }, + { + "name": "佛罗里达", + "code": "USA_FL" + }, + { + "name": "佛蒙特", + "code": "USA_VT" + }, + { + "name": "哥伦比亚特区", + "code": "USA_DC" + }, + { + "name": "华盛顿", + "code": "USA_WA" + }, + { + "name": "怀俄明", + "code": "USA_WY" + }, + { + "name": "加利福尼亚", + "code": "USA_CA" + }, + { + "name": "堪萨斯", + "code": "USA_KS" + }, + { + "name": "康涅狄格", + "code": "USA_CT" + }, + { + "name": "科罗拉多", + "code": "USA_CO" + }, + { + "name": "肯塔基", + "code": "USA_KY" + }, + { + "name": "路易斯安那", + "code": "USA_LA" + }, + { + "name": "罗德岛", + "code": "USA_RI" + }, + { + "name": "马里兰", + "code": "USA_MD" + }, + { + "name": "马萨诸塞", + "code": "USA_MA" + }, + { + "name": "蒙大拿", + "code": "USA_MT" + }, + { + "name": "密苏里", + "code": "USA_MO" + }, + { + "name": "密西西比", + "code": "USA_MS" + }, + { + "name": "密歇根", + "code": "USA_MI" + }, + { + "name": "缅因", + "code": "USA_ME" + }, + { + "name": "明尼苏达", + "code": "USA_MN" + }, + { + "name": "南达科他", + "code": "USA_SD" + }, + { + "name": "南卡罗来纳", + "code": "USA_SC" + }, + { + "name": "内布拉斯加", + "code": "USA_NE" + }, + { + "name": "内华达", + "code": "USA_NV" + }, + { + "name": "纽约", + "code": "USA_NY" + }, + { + "name": "特拉华", + "code": "USA_DE" + }, + { + "name": "田纳西", + "code": "USA_TN" + }, + { + "name": "威斯康星", + "code": "USA_WI" + }, + { + "name": "维吉尼亚", + "code": "USA_VA" + }, + { + "name": "西佛吉尼亚", + "code": "USA_WV" + }, + { + "name": "夏威夷", + "code": "USA_HI" + }, + { + "name": "新罕布什尔", + "code": "USA_NH" + }, + { + "name": "新墨西哥", + "code": "USA_NM" + }, + { + "name": "新泽西", + "code": "USA_NJ" + }, + { + "name": "亚利桑那", + "code": "USA_AZ" + }, + { + "name": "伊利诺斯", + "code": "USA_IL" + }, + { + "name": "印第安那", + "code": "USA_IN" + }, + { + "name": "犹他", + "code": "USA_UT" + }, + { + "name": "佐治亚", + "code": "USA_GA" + } + ] + }, + { + "name": "乌兹别克斯坦", + "code": "UZB", + "children": [ + { + "name": "安集延", + "code": "UZB_AN" + }, + { + "name": "布哈拉", + "code": "UZB_BU" + }, + { + "name": "费尔干纳", + "code": "UZB_FA" + }, + { + "name": "花拉子模", + "code": "UZB_XO" + }, + { + "name": "吉扎克", + "code": "UZB_JI" + }, + { + "name": "卡拉卡尔帕克斯坦共和国", + "code": "UZB_QR" + }, + { + "name": "卡什卡达里亚", + "code": "UZB_QA" + }, + { + "name": "纳曼干", + "code": "UZB_NG" + }, + { + "name": "纳沃伊", + "code": "UZB_NW" + }, + { + "name": "撒马尔罕", + "code": "UZB_SA" + }, + { + "name": "苏尔汉河", + "code": "UZB_SU" + }, + { + "name": "塔什干", + "code": "UZB_TK" + }, + { + "name": "塔什干市", + "code": "UZB_TO" + }, + { + "name": "锡尔河", + "code": "UZB_SI" + } + ] + }, + { + "name": "瓦努阿图", + "code": "VAN" + }, + { + "name": "梵蒂冈", + "code": "VAT" + }, + { + "name": "委内瑞拉", + "code": "VEN", + "children": [ + { + "name": "阿拉瓜", + "code": "VEN_D" + }, + { + "name": "阿马库罗三角洲", + "code": "VEN_Y" + }, + { + "name": "阿普雷", + "code": "VEN_C" + }, + { + "name": "安索阿特吉", + "code": "VEN_B" + }, + { + "name": "巴里纳斯", + "code": "VEN_E" + }, + { + "name": "玻利瓦尔", + "code": "VEN_F" + }, + { + "name": "波图格萨", + "code": "VEN_P" + }, + { + "name": "法尔孔", + "code": "VEN_I" + }, + { + "name": "瓜里科", + "code": "VEN_J" + }, + { + "name": "加拉加斯", + "code": "VEN_A" + }, + { + "name": "卡拉沃沃", + "code": "VEN_G" + }, + { + "name": "科赫德斯", + "code": "VEN_H" + }, + { + "name": "拉腊", + "code": "VEN_K" + }, + { + "name": "联邦属地", + "code": "VEN_W" + }, + { + "name": "梅里达", + "code": "VEN_L" + }, + { + "name": "米兰达", + "code": "VEN_M" + }, + { + "name": "莫纳加斯", + "code": "VEN_N" + }, + { + "name": "苏克雷", + "code": "VEN_R" + }, + { + "name": "苏利亚", + "code": "VEN_V" + }, + { + "name": "塔奇拉", + "code": "VEN_S" + }, + { + "name": "特鲁希略", + "code": "VEN_T" + }, + { + "name": "新埃斯帕塔", + "code": "VEN_O" + }, + { + "name": "亚拉奎", + "code": "VEN_U" + }, + { + "name": "亚马孙", + "code": "VEN_Z" + } + ] + }, + { + "name": "维尔京群岛", + "code": "VGB" + }, + { + "name": "越南", + "code": "VIE" + }, + { + "name": "圣文森特和格林纳丁斯", + "code": "VIN" + }, + { + "name": "维尔京群岛", + "code": "VIR" + }, + { + "name": "瓦利斯和福图纳", + "code": "WLF" + }, + { + "name": "也门", + "code": "YEM", + "children": [ + { + "name": "阿比扬", + "code": "YEM_AB" + }, + { + "name": "阿姆兰", + "code": "YEM_AM" + }, + { + "name": "贝达", + "code": "YEM_BA" + }, + { + "name": "达利", + "code": "YEM_DA" + }, + { + "name": "哈德拉毛", + "code": "YEM_HD" + }, + { + "name": "哈杰", + "code": "YEM_HJ" + }, + { + "name": "荷台达", + "code": "YEM_HU" + }, + { + "name": "焦夫", + "code": "YEM_JA" + }, + { + "name": "拉赫季", + "code": "YEM_LA" + }, + { + "name": "马里卜", + "code": "YEM_MA" + }, + { + "name": "迈赫拉", + "code": "YEM_MR" + }, + { + "name": "迈赫维特", + "code": "YEM_MW" + }, + { + "name": "萨达", + "code": "YEM_SD" + }, + { + "name": "萨那", + "code": "YEM_SN" + }, + { + "name": "赛文", + "code": "YEM_GXF" + }, + { + "name": "舍卜沃", + "code": "YEM_SH" + }, + { + "name": "塔伊兹", + "code": "YEM_TA" + }, + { + "name": "希赫尔", + "code": "YEM_ASR" + }, + { + "name": "亚丁", + "code": "YEM_AD" + }, + { + "name": "伊卜", + "code": "YEM_IB" + }, + { + "name": "扎玛尔", + "code": "YEM_DH" + } + ] + }, + { + "name": "赞比亚", + "code": "ZAM" + }, + { + "name": "津巴布韦", + "code": "ZIM" + }, + { + "name": "突尼斯", + "code": "TUN" + }, + { + "name": "秘鲁", + "code": "PER" + } +] \ No newline at end of file diff --git a/backend/crm/src/main/resources/task/message_task.json b/backend/crm/src/main/resources/task/message_task.json new file mode 100644 index 0000000..18c1161 --- /dev/null +++ b/backend/crm/src/main/resources/task/message_task.json @@ -0,0 +1,282 @@ +[ + { + "organizationId": "", + "module": "CUSTOMER", + "moduleName": "", + "messageTaskDetailDTOList": [ + { + "event": "CUSTOMER_ADD", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CUSTOMER_CONCAT_ADD", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CUSTOMER_COLLABORATION_ADD", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CUSTOMER_TRANSFERRED_CUSTOMER", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CUSTOMER_AUTOMATIC_MOVE_HIGH_SEAS", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CUSTOMER_MOVED_HIGH_SEAS", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CUSTOMER_DELETED", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "HIGH_SEAS_CUSTOMER_DISTRIBUTED", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CUSTOMER_FOLLOW_UP_PLAN_DUE", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + } + ] + }, + { + "organizationId": "", + "module": "CLUE", + "moduleName": "", + "messageTaskDetailDTOList": [ + { + "event": "CLUE_ADD", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CLUE_AUTOMATIC_MOVE_POOL", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CLUE_MOVED_POOL", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CLUE_CONVERT_CUSTOMER", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CLUE_CONVERT_BUSINESS", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "TRANSFER_CLUE", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CLUE_DELETED", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CLUE_DISTRIBUTED", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CLUE_FOLLOW_UP_PLAN_DUE", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + } + ] + }, + { + "organizationId": "", + "module": "OPPORTUNITY", + "moduleName": "", + "messageTaskDetailDTOList": [ + { + "event": "BUSINESS_ADD", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "BUSINESS_DELETED", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "BUSINESS_TRANSFER", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "BUSINESS_FOLLOW_UP_PLAN_DUE", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "BUSINESS_QUOTATION_APPROVAL", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "BUSINESS_QUOTATION_DELETED", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "BUSINESS_QUOTATION_EXPIRED", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "BUSINESS_QUOTATION_EXPIRING", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + } + ] + }, + { + "organizationId": "", + "module": "ORDER", + "moduleName": "", + "messageTaskDetailDTOList": [ + { + "event": "ORDER_APPROVAL", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + } + ] + }, + { + "organizationId": "", + "module": "CONTRACT", + "moduleName": "", + "messageTaskDetailDTOList": [ + { + "event": "CONTRACT_ARCHIVED", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CONTRACT_VOID", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CONTRACT_EXPIRED", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CONTRACT_EXPIRING", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CONTRACT_PAYMENT_EXPIRED", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CONTRACT_PAYMENT_EXPIRING", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "CONTRACT_APPROVAL", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + }, + { + "event": "INVOICE_APPROVAL", + "eventName": "", + "emailEnable": false, + "sysEnable": false, + "weComEnable": false + } + ] + } +] diff --git a/backend/crm/src/test/java/cn/cordys/crm/TestAppListener.java b/backend/crm/src/test/java/cn/cordys/crm/TestAppListener.java new file mode 100644 index 0000000..a8fa859 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/TestAppListener.java @@ -0,0 +1,93 @@ +package cn.cordys.crm; + +import cn.cordys.common.service.DataInitService; +import cn.cordys.common.uid.impl.DefaultUidGenerator; +import cn.cordys.common.util.HikariCPUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.rsa.RsaKey; +import cn.cordys.common.util.rsa.RsaUtils; +import cn.cordys.crm.system.service.ExtScheduleService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +class TestAppListener implements ApplicationRunner { + @Resource + private DefaultUidGenerator uidGenerator; + + @Resource + private ExtScheduleService extScheduleService; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + @Resource + private DataInitService dataInitService; + + /** + * 应用启动后执行的初始化方法。 + *

+ * 此方法会依次初始化唯一 ID 生成器、MinIO 配置和 RSA 配置。 + *

+ * + * @param args 启动参数 + */ + @Override + public void run(ApplicationArguments args) { + log.info("===== 开始初始化配置 ====="); + + // 初始化唯一ID生成器 + uidGenerator.init(); + + // 初始化RSA配置 + log.info("初始化RSA配置"); + initializeRsaConfiguration(); + + log.info("初始化定时任务"); + extScheduleService.startEnableSchedules(); + + log.info("初始化默认组织数据"); + dataInitService.initOneTime(); + + HikariCPUtils.printHikariCPStatus(); + + log.info("===== 完成初始化配置 ====="); + } + + /** + * 初始化 RSA 配置。 + *

+ * 此方法首先尝试加载现有的 RSA 密钥。如果不存在,则生成新的 RSA 密钥并保存到文件系统。 + *

+ */ + private void initializeRsaConfiguration() { + String redisKey = "rsa:key"; + try { + // 从 Redis 获取 RSA 密钥 + String rsaStr = stringRedisTemplate.opsForValue().get(redisKey); + if (StringUtils.isNotBlank(rsaStr)) { + // 如果 RSA 密钥存在,反序列化并设置密钥 + RsaKey rsaKey = JSON.parseObject(rsaStr, RsaKey.class); + RsaUtils.setRsaKey(rsaKey); + return; + } + } catch (Exception e) { + log.error("从 Redis 获取 RSA 配置失败", e); + } + + try { + // 如果 Redis 中没有密钥,生成新的 RSA 密钥并保存到 Redis + RsaKey rsaKey = RsaUtils.getRsaKey(); + stringRedisTemplate.opsForValue().set(redisKey, JSON.toJSONString(rsaKey)); + RsaUtils.setRsaKey(rsaKey); + } catch (Exception e) { + log.error("初始化 RSA 配置失败", e); + } + } +} \ No newline at end of file diff --git a/backend/crm/src/test/java/cn/cordys/crm/TestApplication.java b/backend/crm/src/test/java/cn/cordys/crm/TestApplication.java new file mode 100644 index 0000000..c6bb925 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/TestApplication.java @@ -0,0 +1,25 @@ +package cn.cordys.crm; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.ldap.LdapAutoConfiguration; +import org.springframework.boot.autoconfigure.neo4j.Neo4jAutoConfiguration; +import org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration; +import org.springframework.boot.web.servlet.ServletComponentScan; +import org.springframework.context.annotation.ComponentScan; + +@SpringBootApplication(exclude = { + QuartzAutoConfiguration.class, + LdapAutoConfiguration.class, + Neo4jAutoConfiguration.class +}) +@ServletComponentScan +@ComponentScan( + basePackages = {"cn.cordys"} +) +public class TestApplication { + + public static void main(String[] args) { + SpringApplication.run(TestApplication.class, args); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/agent/AgentControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/agent/AgentControllerTests.java new file mode 100644 index 0000000..20007ce --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/agent/AgentControllerTests.java @@ -0,0 +1,127 @@ +package cn.cordys.crm.agent; + +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.integration.agent.domain.Agent; +import cn.cordys.crm.integration.agent.dto.request.AgentAddRequest; +import cn.cordys.crm.integration.agent.dto.request.AgentPageRequest; +import cn.cordys.crm.integration.agent.dto.request.AgentRenameRequest; +import cn.cordys.crm.integration.agent.dto.request.AgentUpdateRequest; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class AgentControllerTests extends BaseTest { + + protected static final String DETAIL = "detail/{0}"; + protected static final String RENAME = "rename"; + protected static final String COLLECT = "collect/{0}"; + protected static final String UN_COLLECT = "un-collect/{0}"; + protected static final String COLLECT_PAGE = "collect/page"; + private static final String BASE_PATH = "/agent/"; + private static Agent addAgent; + @Resource + private BaseMapper agentMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Sql(scripts = {"/dml/init_agent_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Test + @Order(1) + void testAdd() throws Exception { + AgentAddRequest request = new AgentAddRequest(); + request.setName("wx_智能体"); + request.setAgentModuleId("wx_123"); + request.setScopeIds(List.of("1", "2", "3")); + request.setScript(""); + request.setType("SCRIPT"); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + Opportunity resultData = getResultData(mvcResult, Opportunity.class); + addAgent = agentMapper.selectByPrimaryKey(resultData.getId()); + } + + + @Test + @Order(2) + void testGetDetail() throws Exception { + this.requestGet(DETAIL, addAgent.getId()); + } + + @Test + @Order(3) + void testUpdate() throws Exception { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setId(addAgent.getId()); + request.setName("智能体更新"); + this.requestPost(DEFAULT_UPDATE, request); + } + + @Test + @Order(4) + void testRename() throws Exception { + AgentRenameRequest request = new AgentRenameRequest(); + request.setId(addAgent.getId()); + request.setName("重命名"); + request.setAgentModuleId("wx_123"); + this.requestPost(RENAME, request); + } + + + @Test + @Order(5) + void testList() throws Exception { + AgentPageRequest request = new AgentPageRequest(); + request.setAgentModuleIds(List.of("wx_123")); + request.setCurrent(1); + request.setPageSize(30); + this.requestPost(DEFAULT_PAGE, request); + } + + + @Test + @Order(6) + void testCollect() throws Exception { + this.requestGet(COLLECT, addAgent.getId()); + } + + @Test + @Order(7) + void testUnCollect() throws Exception { + this.requestGet(UN_COLLECT, addAgent.getId()); + } + + @Test + @Order(8) + void testCollectPage() throws Exception { + BasePageRequest request = new BasePageRequest(); + request.setCurrent(1); + request.setPageSize(30); + this.requestPost(COLLECT_PAGE, addAgent.getId()); + } + + + @Test + @Order(12) + void testDelete() throws Exception { + this.requestGet(DEFAULT_DELETE, addAgent.getId()); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/approval/ApprovalFlowControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/approval/ApprovalFlowControllerTests.java new file mode 100644 index 0000000..299f846 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/approval/ApprovalFlowControllerTests.java @@ -0,0 +1,734 @@ +package cn.cordys.crm.approval; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModel; +import cn.cordys.crm.approval.constants.*; +import cn.cordys.crm.approval.domain.*; +import cn.cordys.crm.approval.dto.StatusPermissionDTO; +import cn.cordys.crm.approval.dto.request.*; +import cn.cordys.crm.approval.dto.response.ApprovalFlowByFormTypeResponse; +import cn.cordys.crm.approval.dto.response.ApprovalFlowDetailResponse; +import cn.cordys.crm.approval.dto.response.ApprovalFlowListResponse; +import cn.cordys.crm.approval.dto.response.StatusPermissionSettingResponse; +import cn.cordys.crm.approval.service.ApprovalFlowService; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.domain.Department; +import cn.cordys.crm.system.domain.DepartmentCommander; +import cn.cordys.crm.system.domain.OrganizationUser; +import cn.cordys.crm.system.domain.User; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.ArrayList; +import java.util.List; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ApprovalFlowControllerTests extends BaseTest { + private static final String BASE_PATH = "/approval-flow/"; + private static final String ENABLE = "enable/{0}"; + private static final String STATUS_PERMISSION_SETTING = "status-permission/setting/{0}"; + private static final String GET_BY_FORM_TYPE = "get-by-form-type/{0}"; + + /** + * 记录创建的审批流 + */ + private static ApprovalFlow addApprovalFlow; + private static ApprovalFlow anotherApprovalFlow; + + @Resource + private BaseMapper approvalFlowMapper; + @Resource + private BaseMapper approvalFlowVersionMapper; + @Resource + private BaseMapper approvalNodeMapper; + @Resource + private BaseMapper approvalNodeApproverMapper; + @Resource + private BaseMapper approvalNodeLinkMapper; + @Resource + private ApprovalFlowService approvalFlowService; + @Resource + private BaseMapper departmentMapper; + @Resource + private BaseMapper departmentCommanderMapper; + @Resource + private BaseMapper organizationUserMapper; + @Resource + private BaseMapper userMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + /** + * 构建简单的审批人节点请求 + */ + private ApprovalNodeApproverRequest buildApproverNodeRequest(String id, String name) { + ApprovalNodeApproverRequest node = new ApprovalNodeApproverRequest(); + node.setId(id); + node.setName(name); + node.setNodeType(ApprovalNodeTypeEnum.APPROVER.name()); + node.setApprovalType(ApprovalTypeEnum.MANUAL.name()); + node.setMultiApproverMode(MultiApproverModeEnum.ALL.name()); + + // 配置审批人 + node.setApproverType(ApproverTypeEnum.ROLE.name()); + node.setApproverList(List.of("sales_manager")); + + // 配置抄送 + node.setCcType(ApproverTypeEnum.ROLE.name()); + node.setCcList(List.of("org_admin")); + + return node; + } + + /** + * 构建开始节点请求 + */ + private ApprovalNodeRequest buildStartNodeRequest(String id) { + ApprovalNodeRequest node = new ApprovalNodeRequest(); + node.setId(id); + node.setName("开始"); + node.setNodeType(ApprovalNodeTypeEnum.START.name()); + return node; + } + + /** + * 构建结束节点请求 + */ + private ApprovalNodeRequest buildEndNodeRequest(String id) { + ApprovalNodeRequest node = new ApprovalNodeRequest(); + node.setId(id); + node.setName("结束"); + node.setNodeType(ApprovalNodeTypeEnum.END.name()); + return node; + } + + /** + * 构建节点连接请求 + */ + private ApprovalNodeLinkRequest buildLinkRequest(String fromNodeId, String toNodeId) { + ApprovalNodeLinkRequest link = new ApprovalNodeLinkRequest(); + link.setFromNodeId(fromNodeId); + link.setToNodeId(toNodeId); + return link; + } + + /** + * 构建状态权限配置 + */ + private List buildStatusPermissions() { + List statusPermissions = new ArrayList<>(); + StatusPermissionDTO p1 = new StatusPermissionDTO(); + p1.setApprovalStatus(ApprovalState.APPROVED.getId()); + p1.setPermission("view"); + p1.setEnabled(true); + statusPermissions.add(p1); + + StatusPermissionDTO p2 = new StatusPermissionDTO(); + p2.setApprovalStatus(ApprovalState.UNAPPROVED.getId()); + p2.setPermission("edit"); + p2.setEnabled(false); + statusPermissions.add(p2); + + return statusPermissions; + } + + /** + * 构建新增请求 + */ + private ApprovalFlowAddRequest buildAddRequest(String name, ApprovalFormTypeEnum formType, boolean enable) { + ApprovalFlowAddRequest request = new ApprovalFlowAddRequest(); + request.setName(name); + request.setFormType(formType.getValue()); + request.setEnable(enable); + request.setDescription("测试审批流描述"); + request.setSubmitterCanRevoke(true); + request.setAllowBatchProcess(false); + request.setAllowWithdraw(true); + request.setAllowAddSign(false); + request.setDuplicateApproverRule(DuplicateApproverRuleEnum.EACH.name()); + request.setRequireComment(false); + request.setCreateExecute(false); + request.setUpdateExecute(true); + request.setStatusPermissions(buildStatusPermissions()); + + // 构建节点配置: 开始 -> 审批人 -> 结束 + String startNodeId = "start_" + System.currentTimeMillis(); + String approverNodeId = "approver_" + System.currentTimeMillis(); + String endNodeId = "end_" + System.currentTimeMillis(); + + List nodes = new ArrayList<>(); + nodes.add(buildStartNodeRequest(startNodeId)); + nodes.add(buildApproverNodeRequest(approverNodeId, "主管审批")); + nodes.add(buildEndNodeRequest(endNodeId)); + request.setUpdateNodeConfig(new ApprovalFlowNodeConfigRequest()); + request.getUpdateNodeConfig().setNodes(nodes); + + // 构建连接配置: 开始 -> 审批人 -> 结束 + List links = new ArrayList<>(); + links.add(buildLinkRequest(startNodeId, approverNodeId)); + links.add(buildLinkRequest(approverNodeId, endNodeId)); + request.getUpdateNodeConfig().setLinks(links); + + return request; + } + + @Test + @Order(0) + void testPageEmpty() throws Exception { + ApprovalFlowPageRequest request = new ApprovalFlowPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + + this.requestPostWithOkAndReturn(DEFAULT_PAGE, request); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.PROCESS_SETTING_READ, DEFAULT_PAGE, request); + } + + @Test + @Order(1) + void testAdd() throws Exception { + // 请求成功 - 创建启用的审批流 + ApprovalFlowAddRequest request = buildAddRequest("报价审批流", ApprovalFormTypeEnum.QUOTATION, true); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + ApprovalFlowDetailResponse resultData = getResultData(mvcResult, ApprovalFlowDetailResponse.class); + ApprovalFlow flow = approvalFlowMapper.selectByPrimaryKey(resultData.getId()); + + // 校验请求成功数据 + addApprovalFlow = flow; + Assertions.assertEquals(request.getName(), flow.getName()); + Assertions.assertEquals(request.getFormType(), flow.getFormType()); + Assertions.assertEquals(request.getEnable(), flow.getEnable()); + Assertions.assertEquals(flow.getOrganizationId(), DEFAULT_ORGANIZATION_ID); + Assertions.assertNotNull(flow.getNumber()); + Assertions.assertEquals(request.getDescription(), flow.getDescription()); + + // 校验配置字段存储在主表 + Assertions.assertEquals(request.getSubmitterCanRevoke(), flow.getSubmitterCanRevoke()); + Assertions.assertEquals(request.getAllowBatchProcess(), flow.getAllowBatchProcess()); + Assertions.assertEquals(request.getAllowWithdraw(), flow.getAllowWithdraw()); + Assertions.assertEquals(request.getAllowAddSign(), flow.getAllowAddSign()); + Assertions.assertEquals(request.getDuplicateApproverRule(), flow.getDuplicateApproverRule()); + Assertions.assertEquals(request.getRequireComment(), flow.getRequireComment()); + Assertions.assertEquals(request.getCreateExecute(), flow.getCreateExecute()); + Assertions.assertEquals(request.getUpdateExecute(), flow.getUpdateExecute()); + + // 校验版本表存在 + ApprovalFlowVersion version = approvalFlowVersionMapper.selectByPrimaryKey(flow.getCurrentVersionId()); + Assertions.assertNotNull(version); + + // 校验节点配置 + List nodes = getNodesByFlowVersionId(flow.getCurrentVersionId()); + Assertions.assertEquals(3, nodes.size()); + + // 校验审批人节点配置 + ApprovalNode approverNode = nodes.stream() + .filter(n -> ApprovalNodeTypeEnum.APPROVER.name().equals(n.getNodeType())) + .findFirst() + .orElse(null); + Assertions.assertNotNull(approverNode); + ApprovalNodeApprover approverConfig = approvalNodeApproverMapper.selectByPrimaryKey(approverNode.getId()); + Assertions.assertNotNull(approverConfig); + + // 校验节点连接配置 + List links = getLinksByFlowVersionId(flow.getCurrentVersionId()); + Assertions.assertEquals(2, links.size()); + + // 添加另一条数据,不同表单类型 + ApprovalFlowAddRequest anotherRequest = buildAddRequest("合同审批流", ApprovalFormTypeEnum.CONTRACT, true); + mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, anotherRequest); + anotherApprovalFlow = approvalFlowMapper.selectByPrimaryKey(getResultData(mvcResult, ApprovalFlowDetailResponse.class).getId()); + + // 校验创建禁用的审批流 + ApprovalFlowAddRequest disabledRequest = buildAddRequest("禁用的发票审批流", ApprovalFormTypeEnum.INVOICE, false); + mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, disabledRequest); + ApprovalFlow disabledFlow = approvalFlowMapper.selectByPrimaryKey(getResultData(mvcResult, ApprovalFlowDetailResponse.class).getId()); + Assertions.assertFalse(disabledFlow.getEnable()); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.PROCESS_SETTING_ADD, DEFAULT_ADD, request); + } + + @Test + @Order(2) + void testAddDuplicateType() throws Exception { + // 测试重复创建同一表单类型的审批流应该失败 + ApprovalFlowAddRequest duplicateRequest = buildAddRequest("重复的报价审批流", ApprovalFormTypeEnum.QUOTATION, true); + MvcResult result = this.requestPost(DEFAULT_ADD, duplicateRequest).andReturn(); + // 应该返回错误状态码而不是成功 + String response = result.getResponse().getContentAsString(); + Assertions.assertTrue(response.contains("该表单类型的审批流已存在") || response.contains("already exists")); + } + + @Test + @Order(3) + void testUpdate() throws Exception { + // 请求成功 + ApprovalFlowUpdateRequest request = new ApprovalFlowUpdateRequest(); + request.setId(addApprovalFlow.getId()); + request.setName("更新后的报价审批流"); + request.setDescription("更新后的描述"); + request.setSubmitterCanRevoke(false); + request.setAllowBatchProcess(true); + request.setAllowWithdraw(false); + request.setAllowAddSign(true); + request.setDuplicateApproverRule(DuplicateApproverRuleEnum.FIRST_ONLY.name()); + request.setRequireComment(true); + request.setCreateExecute(true); + request.setUpdateExecute(false); + request.setStatusPermissions(buildStatusPermissions()); + + // 更新节点配置 + String startNodeId = "start_update_" + System.currentTimeMillis(); + String approverNodeId = "approver_update_" + System.currentTimeMillis(); + String endNodeId = "end_update_" + System.currentTimeMillis(); + + List nodes = new ArrayList<>(); + nodes.add(buildStartNodeRequest(startNodeId)); + ApprovalNodeApproverRequest approverNode = buildApproverNodeRequest(approverNodeId, "经理审批"); + approverNode.setApprovalType(ApprovalTypeEnum.AUTO_PASS.name()); + nodes.add(approverNode); + nodes.add(buildEndNodeRequest(endNodeId)); + request.setCreateNodeConfig(new ApprovalFlowNodeConfigRequest()); + request.getCreateNodeConfig().setNodes(nodes); + + // 构建连接配置 + List links = new ArrayList<>(); + links.add(buildLinkRequest(startNodeId, approverNodeId)); + links.add(buildLinkRequest(approverNodeId, endNodeId)); + request.getCreateNodeConfig().setLinks(links); + + this.requestPostWithOk(DEFAULT_UPDATE, request); + + // 校验请求成功数据 + ApprovalFlow updatedFlow = approvalFlowMapper.selectByPrimaryKey(request.getId()); + Assertions.assertEquals(request.getName(), updatedFlow.getName()); + Assertions.assertEquals(request.getDescription(), updatedFlow.getDescription()); + + // 校验配置字段存储在主表 + Assertions.assertEquals(request.getSubmitterCanRevoke(), updatedFlow.getSubmitterCanRevoke()); + Assertions.assertEquals(request.getAllowBatchProcess(), updatedFlow.getAllowBatchProcess()); + Assertions.assertEquals(request.getAllowWithdraw(), updatedFlow.getAllowWithdraw()); + Assertions.assertEquals(request.getAllowAddSign(), updatedFlow.getAllowAddSign()); + Assertions.assertEquals(request.getDuplicateApproverRule(), updatedFlow.getDuplicateApproverRule()); + Assertions.assertEquals(request.getRequireComment(), updatedFlow.getRequireComment()); + + // 校验更新后产生了新版本 + Assertions.assertNotEquals(addApprovalFlow.getCurrentVersionId(), updatedFlow.getCurrentVersionId()); + + // 校验节点配置已更新(删除旧节点,插入新节点) + List updatedNodes = getNodesByFlowVersionId(updatedFlow.getCurrentVersionId()); + Assertions.assertEquals(3, updatedNodes.size()); + + // 不修改信息 + ApprovalFlowUpdateRequest emptyRequest = new ApprovalFlowUpdateRequest(); + emptyRequest.setId(addApprovalFlow.getId()); + this.requestPostWithOk(DEFAULT_UPDATE, emptyRequest); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.PROCESS_SETTING_UPDATE, DEFAULT_UPDATE, request); + } + + @Test + @Order(5) + void testPage() throws Exception { + ApprovalFlowPageRequest request = new ApprovalFlowPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + + // 请求成功 + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_PAGE, request); + List pageResult = getPageResult(mvcResult, ApprovalFlowListResponse.class).getList(); + + // 校验数据 + Assertions.assertFalse(pageResult.isEmpty()); + + // 按名称筛选 + ApprovalFlowPageRequest nameFilterRequest = new ApprovalFlowPageRequest(); + nameFilterRequest.setCurrent(1); + nameFilterRequest.setPageSize(10); + nameFilterRequest.setName("更新后的报价审批流"); + MvcResult nameMvcResult = this.requestPostWithOkAndReturn(DEFAULT_PAGE, nameFilterRequest); + List namePageResult = getPageResult(nameMvcResult, ApprovalFlowListResponse.class).getList(); + + // 校验筛选结果 + Assertions.assertEquals(1, namePageResult.size()); + Assertions.assertEquals("更新后的报价审批流", namePageResult.get(0).getName()); + + // 按表单类型筛选 + ApprovalFlowPageRequest formTypeFilterRequest = new ApprovalFlowPageRequest(); + formTypeFilterRequest.setCurrent(1); + formTypeFilterRequest.setPageSize(10); + formTypeFilterRequest.setFormType(ApprovalFormTypeEnum.QUOTATION.getValue()); + MvcResult formTypeMvcResult = this.requestPostWithOkAndReturn(DEFAULT_PAGE, formTypeFilterRequest); + List formTypePageResult = getPageResult(formTypeMvcResult, ApprovalFlowListResponse.class).getList(); + + // 校验筛选结果 + Assertions.assertFalse(formTypePageResult.isEmpty()); + formTypePageResult.forEach(flow -> Assertions.assertEquals(ApprovalFormTypeEnum.QUOTATION.getValue(), flow.getFormType())); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.PROCESS_SETTING_READ, DEFAULT_PAGE, request); + } + + @Test + @Order(6) + void testGet() throws Exception { + // 请求成功 + MvcResult mvcResult = this.requestGetWithOkAndReturn(DEFAULT_GET, addApprovalFlow.getId()); + ApprovalFlowDetailResponse response = getResultData(mvcResult, ApprovalFlowDetailResponse.class); + + ApprovalFlow approvalFlow = approvalFlowMapper.selectByPrimaryKey(addApprovalFlow.getId()); + + // 校验基本信息 + Assertions.assertEquals(approvalFlow.getId(), response.getId()); + Assertions.assertEquals(approvalFlow.getName(), response.getName()); + Assertions.assertEquals(approvalFlow.getFormType(), response.getFormType()); + Assertions.assertEquals(approvalFlow.getNumber(), response.getNumber()); + Assertions.assertEquals(approvalFlow.getEnable(), response.getEnable()); + Assertions.assertEquals(approvalFlow.getDescription(), response.getDescription()); + + // 校验配置字段从主表获取 + Assertions.assertEquals(approvalFlow.getSubmitterCanRevoke(), response.getSubmitterCanRevoke()); + Assertions.assertEquals(approvalFlow.getAllowBatchProcess(), response.getAllowBatchProcess()); + Assertions.assertEquals(approvalFlow.getAllowWithdraw(), response.getAllowWithdraw()); + Assertions.assertEquals(approvalFlow.getAllowAddSign(), response.getAllowAddSign()); + Assertions.assertEquals(approvalFlow.getDuplicateApproverRule(), response.getDuplicateApproverRule()); + Assertions.assertEquals(approvalFlow.getRequireComment(), response.getRequireComment()); + + // 校验节点配置 + Assertions.assertFalse(CollectionUtils.isEmpty(response.getCreateNodeConfig().getNodes())); + + // 校验连接配置 + Assertions.assertFalse(CollectionUtils.isEmpty(response.getCreateNodeConfig().getLinks())); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.PROCESS_SETTING_READ, DEFAULT_GET, addApprovalFlow.getId()); + } + + @Test + @Order(7) + void testEnable() throws Exception { + // 启用之前创建的禁用审批流 + ApprovalFlow disabledFlow = getDisabledFlow(); + Assertions.assertNotNull(disabledFlow); + Assertions.assertFalse(disabledFlow.getEnable()); + + // 启用 + String enableUrl = ENABLE + "?enable=true"; + this.requestGetWithOk(enableUrl, disabledFlow.getId()); + ApprovalFlow enabledFlow = approvalFlowMapper.selectByPrimaryKey(disabledFlow.getId()); + Assertions.assertTrue(enabledFlow.getEnable()); + + // 禁用 + String disableUrl = ENABLE + "?enable=false"; + this.requestGetWithOk(disableUrl, enabledFlow.getId()); + ApprovalFlow disabledAgainFlow = approvalFlowMapper.selectByPrimaryKey(disabledFlow.getId()); + Assertions.assertFalse(disabledAgainFlow.getEnable()); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.PROCESS_SETTING_UPDATE, enableUrl, disabledFlow.getId()); + } + + @Test + @Order(8) + void testPageWithEnableFilter() throws Exception { + // 筛选启用的审批流 + ApprovalFlowPageRequest request = new ApprovalFlowPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + request.setEnable(true); + + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_PAGE, request); + List pageResult = getPageResult(mvcResult, ApprovalFlowListResponse.class).getList(); + + // 校验结果都是启用的 + pageResult.forEach(flow -> Assertions.assertTrue(flow.getEnable())); + + // 筛选禁用的审批流 + ApprovalFlowPageRequest disabledRequest = new ApprovalFlowPageRequest(); + disabledRequest.setCurrent(1); + disabledRequest.setPageSize(10); + disabledRequest.setEnable(false); + + MvcResult disabledMvcResult = this.requestPostWithOkAndReturn(DEFAULT_PAGE, disabledRequest); + List disabledPageResult = getPageResult(disabledMvcResult, ApprovalFlowListResponse.class).getList(); + + // 校验结果都是禁用的 + disabledPageResult.forEach(flow -> Assertions.assertFalse(flow.getEnable())); + } + + @Test + @Order(20) + void testDelete() throws Exception { + // 删除第一个创建的审批流 + this.requestGetWithOk(DEFAULT_DELETE, addApprovalFlow.getId()); + + // 校验软删除:记录仍存在但 deleted = true + ApprovalFlow deletedFlow = approvalFlowMapper.selectByPrimaryKey(addApprovalFlow.getId()); + Assertions.assertNotNull(deletedFlow); + Assertions.assertTrue(deletedFlow.getDeleted()); + + // 删除另一条创建的审批流 + this.requestGetWithOk(DEFAULT_DELETE, anotherApprovalFlow.getId()); + ApprovalFlow deletedAnother = approvalFlowMapper.selectByPrimaryKey(anotherApprovalFlow.getId()); + Assertions.assertNotNull(deletedAnother); + Assertions.assertTrue(deletedAnother.getDeleted()); + + // 删除禁用的审批流 + ApprovalFlow disabledFlow = getDisabledFlow(); + if (disabledFlow != null) { + this.requestGetWithOk(DEFAULT_DELETE, disabledFlow.getId()); + ApprovalFlow deletedDisabled = approvalFlowMapper.selectByPrimaryKey(disabledFlow.getId()); + Assertions.assertNotNull(deletedDisabled); + Assertions.assertTrue(deletedDisabled.getDeleted()); + } + + // 校验权限 + requestGetPermissionTest(PermissionConstants.PROCESS_SETTING_DELETE, DEFAULT_DELETE, addApprovalFlow.getId()); + } + + /** + * 获取版本对应的节点列表 + */ + private List getNodesByFlowVersionId(String flowVersionId) { + ApprovalNode criteria = new ApprovalNode(); + criteria.setFlowVersionId(flowVersionId); + return approvalNodeMapper.select(criteria); + } + + /** + * 获取版本对应的节点连接列表 + */ + private List getLinksByFlowVersionId(String flowVersionId) { + ApprovalNodeLink criteria = new ApprovalNodeLink(); + criteria.setFlowVersionId(flowVersionId); + return approvalNodeLinkMapper.select(criteria); + } + + /** + * 获取之前创建的禁用审批流 + */ + private ApprovalFlow getDisabledFlow() { + ApprovalFlow criteria = new ApprovalFlow(); + criteria.setFormType(ApprovalFormTypeEnum.INVOICE.getValue()); + criteria.setOrganizationId(DEFAULT_ORGANIZATION_ID); + criteria.setEnable(false); + List flows = approvalFlowMapper.select(criteria); + return flows.isEmpty() ? null : flows.get(0); + } + + @Test + @Order(9) + void testGetStatusPermissionSetting() throws Exception { + // 请求成功 - 获取报价审批流的状态权限配置 + MvcResult mvcResult = this.requestGetWithOkAndReturn(STATUS_PERMISSION_SETTING, ApprovalFormTypeEnum.QUOTATION.getValue()); + StatusPermissionSettingResponse response = getResultData(mvcResult, StatusPermissionSettingResponse.class); + + // 校验返回数据不为空 + Assertions.assertNotNull(response); + Assertions.assertNotNull(response.getPermissions()); + Assertions.assertNotNull(response.getStatusPermissions()); + + // 校验权限列表不为空 + Assertions.assertFalse(response.getPermissions().isEmpty()); + + // 校验权限数据结构正确 + response.getPermissions().forEach(p -> { + Assertions.assertNotNull(p.getId()); + Assertions.assertNotNull(p.getName()); + }); + + // 校验状态权限数据结构正确 + response.getStatusPermissions().forEach(p -> { + Assertions.assertNotNull(p.getApprovalStatus()); + Assertions.assertNotNull(p.getPermission()); + Assertions.assertNotNull(p.getEnabled()); + }); + } + + @Test + @Order(10) + void testGetByFormType() throws Exception { + // 请求成功 - 根据表单类型获取审批流信息 + MvcResult mvcResult = this.requestGetWithOkAndReturn(GET_BY_FORM_TYPE, ApprovalFormTypeEnum.QUOTATION.getValue()); + ApprovalFlowByFormTypeResponse response = getResultData(mvcResult, ApprovalFlowByFormTypeResponse.class); + + // 校验基本信息 + Assertions.assertNotNull(response); + Assertions.assertNotNull(response.getId()); + Assertions.assertNotNull(response.getNumber()); + Assertions.assertNotNull(response.getName()); + Assertions.assertEquals(ApprovalFormTypeEnum.QUOTATION.getValue(), response.getFormType()); + Assertions.assertTrue(response.getEnable()); + Assertions.assertNotNull(response.getDescription()); + + // 校验配置字段从主表获取 + ApprovalFlow flow = approvalFlowMapper.selectByPrimaryKey(response.getId()); + Assertions.assertNotNull(flow); + Assertions.assertEquals(flow.getSubmitterCanRevoke(), response.getSubmitterCanRevoke()); + Assertions.assertEquals(flow.getAllowBatchProcess(), response.getAllowBatchProcess()); + Assertions.assertEquals(flow.getAllowWithdraw(), response.getAllowWithdraw()); + Assertions.assertEquals(flow.getAllowAddSign(), response.getAllowAddSign()); + Assertions.assertEquals(flow.getDuplicateApproverRule(), response.getDuplicateApproverRule()); + Assertions.assertEquals(flow.getRequireComment(), response.getRequireComment()); + + // 校验权限列表 + Assertions.assertNotNull(response.getPermissions()); + Assertions.assertFalse(response.getPermissions().isEmpty()); + + // 校验状态权限配置 + Assertions.assertNotNull(response.getStatusPermissions()); + + // 校验不包含节点配置(ApprovalFlowByFormTypeResponse没有nodes字段) + Assertions.assertThrows(NoSuchMethodException.class, + () -> response.getClass().getMethod("getNodes"), + "Response should not have nodes field"); + + // 请求不存在的表单类型,应返回 null + mvcResult = this.requestGetWithOkAndReturn(GET_BY_FORM_TYPE, "non_existent_form_type"); + ApprovalFlowByFormTypeResponse response2 = getResultData(mvcResult, ApprovalFlowByFormTypeResponse.class); + + Assertions.assertNull(response2); + } + + @Test + @Order(11) + void testResolveMultipleDeptHeadApproversWithDirection() { + prepareMultipleDeptHeadData(); + + List bottomUpApprovers = approvalFlowService.resolveApprovers( + "amd_submit_user", + DEFAULT_ORGANIZATION_ID, + ApproverTypeEnum.MULTIPLE_DEPT_HEAD, + List.of("2"), + ApproverDirectionEnum.BOTTOM_UP + ); + Assertions.assertEquals(List.of("amd_child_head", "amd_parent_head"), + bottomUpApprovers.stream().map(User::getId).toList()); + + List topDownApprovers = approvalFlowService.resolveApprovers( + "amd_submit_user", + DEFAULT_ORGANIZATION_ID, + ApproverTypeEnum.MULTIPLE_DEPT_HEAD, + List.of("2"), + ApproverDirectionEnum.TOP_DOWN + ); + Assertions.assertEquals(List.of("amd_root_head", "amd_parent_head"), + topDownApprovers.stream().map(User::getId).toList()); + } + + @Test + @Order(12) + void testResolveMultipleDeptHeadApproversShouldKeepEmptyDepartmentLevel() { + prepareMultipleDeptHeadDataWithoutChildCommander(); + + List approvers = approvalFlowService.resolveApprovers( + "amd2_submit_user", + DEFAULT_ORGANIZATION_ID, + ApproverTypeEnum.MULTIPLE_DEPT_HEAD, + List.of("2"), + ApproverDirectionEnum.BOTTOM_UP + ); + + Assertions.assertEquals(List.of("amd2_parent_head"), approvers.stream().map(User::getId).toList()); + } + + private void prepareMultipleDeptHeadData() { + insertDepartment("amd_root", "审批测试根部门", "0"); + insertDepartment("amd_parent", "审批测试父部门", "amd_root"); + insertDepartment("amd_child", "审批测试子部门", "amd_parent"); + + insertUser("amd_child_head", "子部门负责人"); + insertUser("amd_parent_head", "父部门负责人"); + insertUser("amd_root_head", "根部门负责人"); + insertUser("amd_submit_user", "部门负责人提交人"); + + insertOrganizationUser("amd_child_head-org", "amd_child_head", "amd_child"); + insertOrganizationUser("amd_parent_head-org", "amd_parent_head", "amd_parent"); + insertOrganizationUser("amd_root_head-org", "amd_root_head", "amd_root"); + insertOrganizationUser("amd_submit_org", "amd_submit_user", "amd_child"); + + insertDepartmentCommander("amd_child-commander", "amd_child", "amd_child_head"); + insertDepartmentCommander("amd_parent-commander", "amd_parent", "amd_parent_head"); + insertDepartmentCommander("amd_root-commander", "amd_root", "amd_root_head"); + } + + private void prepareMultipleDeptHeadDataWithoutChildCommander() { + insertDepartment("amd2_root", "审批测试根部门2", "0"); + insertDepartment("amd2_parent", "审批测试父部门2", "amd2_root"); + insertDepartment("amd2_child", "审批测试子部门2", "amd2_parent"); + + insertUser("amd2_parent_head", "父部门负责人2"); + insertUser("amd2_root_head", "根部门负责人2"); + insertUser("amd2_submit_user", "部门负责人提交人2"); + + insertOrganizationUser("amd2_parent_head-org", "amd2_parent_head", "amd2_parent"); + insertOrganizationUser("amd2_root_head-org", "amd2_root_head", "amd2_root"); + insertOrganizationUser("amd2_submit_org", "amd2_submit_user", "amd2_child"); + + insertDepartmentCommander("amd2_parent-cmd", "amd2_parent", "amd2_parent_head"); + insertDepartmentCommander("amd2_root-cmd", "amd2_root", "amd2_root_head"); + } + + private void insertDepartment(String id, String name, String parentId) { + Department department = new Department(); + department.setId(id); + department.setName(name); + department.setOrganizationId(DEFAULT_ORGANIZATION_ID); + department.setParentId(parentId); + department.setPos(1L); + department.setResource("TEST"); + department.setResourceId(id + "-resource"); + setAuditFields(department); + departmentMapper.insert(department); + } + + private void insertDepartmentCommander(String id, String departmentId, String userId) { + DepartmentCommander commander = new DepartmentCommander(); + commander.setId(id); + commander.setDepartmentId(departmentId); + commander.setUserId(userId); + setAuditFields(commander); + departmentCommanderMapper.insert(commander); + } + + private void insertOrganizationUser(String id, String userId, String departmentId) { + OrganizationUser organizationUser = new OrganizationUser(); + organizationUser.setId(id); + organizationUser.setOrganizationId(DEFAULT_ORGANIZATION_ID); + organizationUser.setDepartmentId(departmentId); + organizationUser.setUserId(userId); + organizationUser.setEnable(true); + setAuditFields(organizationUser); + organizationUserMapper.insert(organizationUser); + } + + private void insertUser(String id, String name) { + User user = new User(); + user.setId(id); + user.setName(name); + user.setPassword("123456"); + user.setGender(false); + setAuditFields(user); + userMapper.insert(user); + } + + private void setAuditFields(BaseModel model) { + long now = System.currentTimeMillis(); + model.setCreateUser("admin"); + model.setUpdateUser("admin"); + model.setCreateTime(now); + model.setUpdateTime(now); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/approval/controller/ApprovalResourceControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/approval/controller/ApprovalResourceControllerTests.java new file mode 100644 index 0000000..5b61b3e --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/approval/controller/ApprovalResourceControllerTests.java @@ -0,0 +1,50 @@ +package cn.cordys.crm.approval.controller; + +import cn.cordys.crm.approval.dto.response.ResourceApprovalResponse; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.contract.constants.ContractApprovalStatus; +import cn.cordys.security.UserApprovalDTO; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +class ApprovalResourceControllerTests extends BaseTest { + + private static final String SIMPLE_DETAIL = "/simple-detail/{0}"; + private static final String RESOURCE_ID = "approval_resource_test_001"; + private static final String CURRENT_APPROVER_ID = "appr_user_curr"; + + @Override + protected String getBasePath() { + return "/approval-resource"; + } + + @Sql( + scripts = {"/dml/init_resource_approval_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD + ) + @Test + @Order(1) + void testResourceDetailOnlyCurrentNodeTasks() throws Exception { + MvcResult mvcResult = requestGetWithOkAndReturn(SIMPLE_DETAIL, RESOURCE_ID); + ResourceApprovalResponse response = getResultData(mvcResult, ResourceApprovalResponse.class); + + Assertions.assertNotNull(response); + Assertions.assertEquals(RESOURCE_ID, response.getResourceId()); + Assertions.assertEquals(ContractApprovalStatus.APPROVING.name(), response.getApproveStatus()); + + List approveUserList = response.getApproveUserList(); + Assertions.assertNotNull(approveUserList); + Assertions.assertEquals(0, approveUserList.size()); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/approval/controller/ApprovalTodoControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/approval/controller/ApprovalTodoControllerTests.java new file mode 100644 index 0000000..d2f65d3 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/approval/controller/ApprovalTodoControllerTests.java @@ -0,0 +1,199 @@ +package cn.cordys.crm.approval.controller; + +import cn.cordys.common.pager.Pager; +import cn.cordys.crm.approval.domain.ApprovalTask; +import cn.cordys.crm.approval.dto.response.ApprovalTodoCountResponse; +import cn.cordys.crm.approval.dto.response.ApprovalTodoItemResponse; +import cn.cordys.crm.approval.service.ApprovalTodoService; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +class ApprovalTodoControllerTests extends BaseTest { + + private static final String TODO_LIST = "/pending/page"; + private static final String PROCESSED_PAGE = "/processed/page"; + private static final String INITIATED_PAGE = "/initiated/page"; + private static final String CC_PAGE = "/cc/page"; + private static final String PENDING_COUNT = "/pending/count"; + + @Resource + private ApprovalTodoService approvalTodoService; + @Resource + private BaseMapper approvalTaskMapper; + + @Override + protected String getBasePath() { + return "/approval-todo"; + } + + @Sql( + scripts = {"/dml/init_approval_todo_list_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD + ) + + @Test + @Order(1) + void testTodoListPageWithAllType() throws Exception { + Map request = new HashMap<>(); + request.put("current", 1); + request.put("pageSize", 10); + request.put("resourceType", "ALL"); + + MvcResult mvcResult = requestPostWithOkAndReturn(TODO_LIST, request); + Pager> pager = getPageResult(mvcResult, ApprovalTodoItemResponse.class); + Assertions.assertNotNull(pager); + Assertions.assertEquals(3, pager.getTotal()); + Assertions.assertEquals(3, pager.getList().size()); + Assertions.assertTrue(pager.getList().stream().allMatch(item -> StringUtils.equals("APPROVING", item.getApprovalOperation()))); + } + + @Sql( + scripts = {"/dml/init_approval_todo_list_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD + ) + @Test + @Order(2) + void testTodoListPageWithContractType() throws Exception { + Map request = new HashMap<>(); + request.put("current", 1); + request.put("pageSize", 10); + request.put("resourceType", "CONTRACT"); + + MvcResult mvcResult = requestPostWithOkAndReturn(TODO_LIST, request); + Pager> pager = getPageResult(mvcResult, ApprovalTodoItemResponse.class); + + Assertions.assertNotNull(pager); + Assertions.assertEquals(2, pager.getTotal()); + Assertions.assertEquals(2, pager.getList().size()); + Assertions.assertEquals("CONTRACT", pager.getList().getFirst().getResourceType()); + } + + @Sql( + scripts = {"/dml/init_approval_todo_processed_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD + ) + @Test + @Order(3) + void testProcessedPage() throws Exception { + Map request = new HashMap<>(); + request.put("current", 1); + request.put("pageSize", 10); + + MvcResult mvcResult = requestPostWithOkAndReturn(PROCESSED_PAGE, request); + Pager> pager = getPageResult(mvcResult, ApprovalTodoItemResponse.class); + + Assertions.assertNotNull(pager); + Assertions.assertEquals(4, pager.getTotal()); + Assertions.assertEquals(4, pager.getList().size()); + Assertions.assertTrue(pager.getList().stream().anyMatch(item -> StringUtils.equals("todo_processed_resource_001", item.getResourceId()))); + } + + @Sql( + scripts = {"/dml/init_approval_todo_cc_initiated_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD + ) + @Test + @Order(4) + void testInitiatedPage() throws Exception { + Map request = new HashMap<>(); + request.put("current", 1); + request.put("pageSize", 10); + + MvcResult mvcResult = requestPostWithOkAndReturn(INITIATED_PAGE, request); + Pager> pager = getPageResult(mvcResult, ApprovalTodoItemResponse.class); + + Assertions.assertNotNull(pager); + } + + @Sql( + scripts = {"/dml/init_approval_todo_cc_initiated_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD + ) + @Test + @Order(5) + void testCcPage() throws Exception { + Map request = new HashMap<>(); + request.put("current", 1); + request.put("pageSize", 10); + + MvcResult mvcResult = requestPostWithOkAndReturn(CC_PAGE, request); + Pager> pager = getPageResult(mvcResult, ApprovalTodoItemResponse.class); + + Assertions.assertNotNull(pager); + Assertions.assertEquals(2, pager.getTotal()); + Assertions.assertEquals(2, pager.getList().size()); + Assertions.assertTrue(pager.getList().stream().allMatch(item -> StringUtils.equals("READ", item.getApprovalOperation()))); + } + + @Sql( + scripts = {"/dml/init_approval_todo_list_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD + ) + @Test + @Order(6) + void testPendingCount() throws Exception { + MvcResult mvcResult = requestGetWithOkAndReturn(PENDING_COUNT); + ApprovalTodoCountResponse response = getResultData(mvcResult, ApprovalTodoCountResponse.class); + + Assertions.assertNotNull(response); + Assertions.assertTrue(response.getTotal() >= 0); + Assertions.assertTrue(response.getQuotation() >= 0); + Assertions.assertTrue(response.getContract() >= 0); + Assertions.assertTrue(response.getOrder() >= 0); + Assertions.assertTrue(response.getInvoice() >= 0); + Assertions.assertEquals(response.getTotal(), response.getQuotation() + response.getContract() + response.getOrder() + response.getInvoice()); + } + + @Sql( + scripts = {"/dml/init_approval_todo_list_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD + ) + @Test + @Order(7) + void testDeleteApprovalTaskByInstanceId() { + approvalTodoService.deleteApprovalTaskByInstanceId("todo_list_inst_contract"); + + List remain = approvalTaskMapper.selectListByLambda(new LambdaQueryWrapper() + .eq(ApprovalTask::getInstanceId, "todo_list_inst_contract")); + Assertions.assertTrue(remain.isEmpty()); + } + + @Sql( + scripts = {"/dml/init_approval_todo_list_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD + ) + @Test + @Order(8) + void testDeleteApprovalTaskByInstanceIds() { + approvalTodoService.deleteApprovalTaskByInstanceIds(List.of("todo_list_inst_contract", "todo_list_inst_quote")); + + List remain = approvalTaskMapper.selectListByLambda(new LambdaQueryWrapper() + .in(ApprovalTask::getInstanceId, List.of("todo_list_inst_contract", "todo_list_inst_quote"))); + Assertions.assertTrue(remain.isEmpty()); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/base/BaseTest.java b/backend/crm/src/test/java/cn/cordys/crm/base/BaseTest.java new file mode 100644 index 0000000..a2250a9 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/base/BaseTest.java @@ -0,0 +1,443 @@ +package cn.cordys.crm.base; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.exception.IResultCode; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.permission.PermissionCache; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.rsa.RsaKey; +import cn.cordys.common.util.rsa.RsaUtils; +import cn.cordys.crm.system.domain.RolePermission; +import cn.cordys.crm.system.domain.User; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.security.SessionConstants; +import com.jayway.jsonpath.JsonPath; +import jakarta.annotation.Resource; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MultiValueMap; + +import java.io.File; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@Slf4j +public abstract class BaseTest { + public static final String DEFAULT_ORGANIZATION_ID = "100001"; + protected static final String DEFAULT_USER_PASSWORD = "CordysCRM"; + protected static final String DEFAULT_PLATFORM = "mobile"; + protected static final String DEFAULT_PAGE = "page"; + protected static final String DEFAULT_LIST = "list"; + protected static final String DEFAULT_GET = "get/{0}"; + protected static final String DEFAULT_ADD = "add"; + protected static final String DEFAULT_UPDATE = "update"; + protected static final String DEFAULT_DELETE = "delete/{0}"; + protected static final String DEFAULT_BATCH_DELETE = "batch/delete"; + public static String PERMISSION_USER_NAME = "permission_test"; + protected static AuthInfo adminAuthInfo; + protected static AuthInfo permissionAuthInfo; + @Resource + protected MockMvc mockMvc; + @Resource + protected PermissionCache permissionCache; + @Resource + private BaseMapper rolePermissionMapper; + @Resource + private BaseMapper userMapper; + + protected static Map parseResponse(MvcResult mvcResult) throws UnsupportedEncodingException { + return JSON.parseMap(mvcResult.getResponse().getContentAsString(Charset.defaultCharset())); + } + + private static MockMultipartFile getMockMultipartFile(String key, Object value) throws IOException { + MockMultipartFile multipartFile; + if (value instanceof File file) { + multipartFile = new MockMultipartFile(key, file.getName(), + MediaType.APPLICATION_OCTET_STREAM_VALUE, Files.readAllBytes(file.toPath())); + } else if (value instanceof MockMultipartFile) { + multipartFile = (MockMultipartFile) value; + // 有些地方的参数 name 写的是文件名,这里统一处理成参数名 key + multipartFile = new MockMultipartFile(key, multipartFile.getOriginalFilename(), + MediaType.APPLICATION_OCTET_STREAM_VALUE, multipartFile.getBytes()); + } else { + multipartFile = new MockMultipartFile(key, key, + MediaType.APPLICATION_JSON_VALUE, value.toString().getBytes()); + } + return multipartFile; + } + + protected String getBasePath() { + return StringUtils.EMPTY; + } + + @BeforeEach + public void login() throws Exception { + if (adminAuthInfo == null) { + adminAuthInfo = initAuthInfo(InternalUser.ADMIN.getValue(), DEFAULT_USER_PASSWORD); + } + + User permissionUser = userMapper.selectByPrimaryKey(PERMISSION_USER_NAME); + // 有对应用户才初始化认证信息 + if (permissionUser != null && permissionAuthInfo == null) { + permissionAuthInfo = initAuthInfo(PERMISSION_USER_NAME, DEFAULT_USER_PASSWORD); + } + } + + private AuthInfo initAuthInfo(String username, String password) throws Exception { + RsaKey rsaKey = RsaUtils.getRsaKey(); + password = RsaUtils.publicEncrypt(password, rsaKey.getPublicKey()); + username = RsaUtils.publicEncrypt(username, rsaKey.getPublicKey()); + + MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post("/login") + .content(String.format("{\"username\":\"%s\",\"password\":\"%s\",\"platform\":\"%s\"}", username, password, DEFAULT_PLATFORM)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andReturn(); + + String sessionId = JsonPath.read(mvcResult.getResponse().getContentAsString(), "$.data.sessionId"); + String csrfToken = JsonPath.read(mvcResult.getResponse().getContentAsString(), "$.data.csrfToken"); + return new AuthInfo(sessionId, csrfToken); + } + + protected MockHttpServletRequestBuilder getPostRequestBuilder(String url, Object param, Object... uriVariables) { + return setRequestBuilderHeader(MockMvcRequestBuilders.post(getBasePath() + url, uriVariables), adminAuthInfo) + .content(JSON.toJSONString(param)) + .contentType(MediaType.APPLICATION_JSON); + } + + private MockHttpServletRequestBuilder setRequestBuilderHeader(MockHttpServletRequestBuilder requestBuilder, AuthInfo authInfo) { + return requestBuilder + .header(SessionConstants.HEADER_TOKEN, authInfo.getSessionId()) + .header(SessionConstants.CSRF_TOKEN, authInfo.getCsrfToken()) + .header("Organization-Id", DEFAULT_ORGANIZATION_ID) //TODO 暂时加上默认的组织ID + .header(HttpHeaders.ACCEPT_LANGUAGE, "zh-CN"); + } + + protected ResultActions requestPost(String url, Object param, Object... uriVariables) throws Exception { + return mockMvc.perform(getPostRequestBuilder(url, param, uriVariables)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)); + } + + protected MvcResult requestPostWithOkAndReturn(String url, Object param, Object... uriVariables) throws Exception { + return this.requestPostWithOk(url, param, uriVariables).andReturn(); + } + + protected ResultActions requestPostWithOk(String url, Object param, Object... uriVariables) throws Exception { + return mockMvc.perform(getPostRequestBuilder(url, param, uriVariables)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + protected MockHttpServletRequestBuilder getRequestBuilder(String url, Object... uriVariables) { + try { + MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get(getBasePath() + url, uriVariables); + return setRequestBuilderHeader(requestBuilder, adminAuthInfo); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + return null; + } + + protected ResultActions requestGetWithOk(String url, Object... uriVariables) throws Exception { + return mockMvc.perform(getRequestBuilder(url, uriVariables)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + protected ResultActions requestGetStreamWithOk(String url, Object... uriVariables) throws Exception { + return mockMvc.perform(getRequestBuilder(url, uriVariables)) + .andExpect(content().contentType(MediaType.APPLICATION_OCTET_STREAM)) + .andExpect(status().isOk()); + } + + protected MvcResult requestGetWithOkAndReturn(String url, Object... uriVariables) throws Exception { + return this.requestGetWithOk(url, uriVariables).andReturn(); + } + + protected List getResultDataArray(MvcResult mvcResult, Class clazz) throws Exception { + Object data = parseResponse(mvcResult).get("data"); + return JSON.parseArray(JSON.toJSONString(data), clazz); + } + + protected T getResultData(MvcResult mvcResult, Class clazz) throws Exception { + Object data = parseResponse(mvcResult).get("data"); + return JSON.parseObject(JSON.toJSONString(data), clazz); + } + + protected Pager> getPageResult(MvcResult mvcResult, Class clazz) throws Exception { + Map pagerResult = (Map) parseResponse(mvcResult).get("data"); + List list = JSON.parseArray(JSON.toJSONString(pagerResult.get("list")), clazz); + Pager pager = new Pager(); + pager.setPageSize(Long.valueOf(pagerResult.get("pageSize").toString())); + pager.setCurrent(Long.valueOf(pagerResult.get("current").toString())); + pager.setTotal(Long.valueOf(pagerResult.get("total").toString())); + pager.setList(list); + return pager; + } + + protected ResultActions requestGet(String url, Object... uriVariables) throws Exception { + return mockMvc.perform(getRequestBuilder(url, uriVariables)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)); + } + + protected void requestPostPermissionTest(String permissionId, String url, Object param, Object... uriVariables) throws Exception { + requestPermissionTest(permissionId, url, () -> getPermissionPostRequestBuilder(url, param, uriVariables)); + } + + protected void requestGetPermissionTest(String permissionId, String url, Object... uriVariables) throws Exception { + requestPermissionTest(permissionId, url, () -> getPermissionRequestBuilder(url, uriVariables)); + } + + protected void requestPostPermissionsTest(List permissionIds, String url, Object param, Object... uriVariables) throws Exception { + requestPermissionsTest(permissionIds, url, () -> getPermissionPostRequestBuilder(url, param, uriVariables), mockMvc); + } + + protected void requestGetPermissionsTest(List permissionIds, String url, Object... uriVariables) throws Exception { + requestPermissionsTest(permissionIds, url, () -> getPermissionRequestBuilder(url, uriVariables), mockMvc); + } + + private MockHttpServletRequestBuilder getPermissionRequestBuilder(String url, Object... uriVariables) { + MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get(getBasePath() + url, uriVariables); + return setRequestBuilderHeader(requestBuilder, permissionAuthInfo); + } + + protected MockHttpServletRequestBuilder getPermissionPostRequestBuilder(String url, Object param, Object... uriVariables) { + return setRequestBuilderHeader(MockMvcRequestBuilders.post(getBasePath() + url, uriVariables), permissionAuthInfo) + .content(JSON.toJSONString(param)) + .contentType(MediaType.APPLICATION_JSON); + } + + /** + * 校验多个权限(同级别权限: 列如都是SYSTEM) + * + * @param permissionIds 多个权限 + * @param url 请求url + * @param requestBuilderGetFunc 请求构造器 + * + * @throws Exception 请求抛出异常 + */ + public void requestPermissionsTest(List permissionIds, String url, Supplier requestBuilderGetFunc, MockMvc mockMvc) throws Exception { + for (String permissionId : permissionIds) { + initUserRolePermission(permissionId); + } + + // 刷新用户 + refreshUserPermission(); + + int status = mockMvc.perform(requestBuilderGetFunc.get()) + .andReturn() + .getResponse() + .getStatus(); + + // 校验是否有权限 + if (status == HttpStatus.FORBIDDEN.value()) { + throw new GenericException(String.format("接口 %s 权限校验失败 %s", url, permissionIds)); + } + + // 删除权限 + RolePermission example = new RolePermission(); + example.setRoleId(PERMISSION_USER_NAME); + rolePermissionMapper.delete(example); + + // 删除后刷新下权限 + refreshUserPermission(); + + // 删除权限后,调用接口,校验是否没有权限 + status = mockMvc.perform(requestBuilderGetFunc.get()) + .andReturn() + .getResponse() + .getStatus(); + + if (status != HttpStatus.FORBIDDEN.value()) { + throw new GenericException(String.format("接口 %s 没有设置权限 %s", url, permissionIds)); + } + } + + /** + * 校验权限 + * 实现步骤 + * 1. 在 application.properties 配置权限的初始化 sql + * spring.sql.init.mode=always + * spring.sql.init.schema-locations=classpath*:dml/init_permission_test.sql + * 2. 在 init_permission_test.sql 中配置权限, + * 3. 向该用户组中添加权限测试是否生效,删除权限测试是否可以访问 + * + * @param permissionId + * @param url + * @param requestBuilderGetFunc 请求构造器,一个 builder 只能使用一次,需要重新生成 + * + * @throws Exception + */ + public void requestPermissionTest(String permissionId, String url, Supplier requestBuilderGetFunc) throws Exception { + // 先给初始化的用户组添加权限 + RolePermission userRolePermission = initUserRolePermission(permissionId); + + // 添加后刷新下权限 + refreshUserPermission(); + + int status = mockMvc.perform(requestBuilderGetFunc.get()) + .andReturn() + .getResponse() + .getStatus(); + + // 校验是否有权限 + if (status == HttpStatus.FORBIDDEN.value()) { + throw new GenericException(String.format("接口 %s 权限校验失败 %s", url, permissionId)); + } + + // 删除权限 + rolePermissionMapper.deleteByPrimaryKey(userRolePermission.getId()); + + // 删除后刷新下权限 + refreshUserPermission(); + + // 删除权限后,调用接口,校验是否没有权限 + status = mockMvc.perform(requestBuilderGetFunc.get()) + .andReturn() + .getResponse() + .getStatus(); + if (status != HttpStatus.FORBIDDEN.value()) { + throw new GenericException(String.format("接口 %s 没有设置权限 %s", url, permissionId)); + } + } + + /** + * 校验错误响应码 + */ + protected void assertErrorCode(ResultActions resultActions, IResultCode resultCode) throws Exception { + resultActions + .andExpect( + jsonPath("$.code") + .value(resultCode.getCode()) + ); + } + + /** + * 给用户组绑定对应权限 + * + * @param permissionId + * + * @return + */ + private RolePermission initUserRolePermission(String permissionId) { + RolePermission rolePermission = new RolePermission(); + rolePermission.setRoleId(PERMISSION_USER_NAME); + rolePermission.setId(IDGenerator.nextStr()); + rolePermission.setPermissionId(permissionId); + rolePermissionMapper.insert(rolePermission); + return rolePermission; + } + + /** + * 调用 is-login 接口刷新权限 + * + * @throws Exception + */ + private void refreshUserPermission() { + permissionCache.clearCache(PERMISSION_USER_NAME, DEFAULT_ORGANIZATION_ID); + } + + protected ResultActions requestMultipart(String url, MultiValueMap paramMap, Object... uriVariables) throws Exception { + MockHttpServletRequestBuilder requestBuilder = getMultipartRequestBuilder(url, paramMap, uriVariables); + return mockMvc.perform(requestBuilder) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)); + } + + + private MockHttpServletRequestBuilder getMultipartRequestBuilder(String url, + MultiValueMap paramMap, + Object[] uriVariables) { + MockMultipartHttpServletRequestBuilder requestBuilder = getMultipartRequestBuilderWithParam(url, paramMap, uriVariables); + return setRequestBuilderHeader(requestBuilder, adminAuthInfo) + .header(SessionConstants.HEADER_TOKEN, adminAuthInfo.getSessionId()) + .header(SessionConstants.CSRF_TOKEN, adminAuthInfo.getCsrfToken()); + } + + /** + * 构建 multipart 带参数的请求 + * + * @param url + * @param paramMap + * @param uriVariables + * + * @return + */ + private MockMultipartHttpServletRequestBuilder getMultipartRequestBuilderWithParam(String url, MultiValueMap paramMap, Object[] uriVariables) { + MockMultipartHttpServletRequestBuilder requestBuilder = + MockMvcRequestBuilders.multipart(getBasePath() + url, uriVariables); + paramMap.forEach((key, value) -> { + List list = value; + for (Object o : list) { + try { + if (o == null) { + continue; + } + MockMultipartFile multipartFile; + if (o instanceof List listObject) { + if (CollectionUtils.isEmpty(listObject)) { + continue; + } + if (listObject.getFirst() instanceof File || listObject.getFirst() instanceof MockMultipartFile) { + // 参数是多个文件时,设置多个文件 + for (Object subObject : listObject) { + multipartFile = getMockMultipartFile(key, subObject); + requestBuilder.file(multipartFile); + } + } else { + multipartFile = getMockMultipartFile(key, o); + requestBuilder.file(multipartFile); + } + } else { + multipartFile = getMockMultipartFile(key, o); + requestBuilder.file(multipartFile); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + + } + }); + return requestBuilder; + } + + @Data + public static class AuthInfo { + private String sessionId; + private String csrfToken; + + public AuthInfo(String sessionId, String csrfToken) { + this.sessionId = sessionId; + this.csrfToken = csrfToken; + } + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueCapacityControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueCapacityControllerTests.java new file mode 100644 index 0000000..03b227f --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueCapacityControllerTests.java @@ -0,0 +1,65 @@ +package cn.cordys.crm.clue.controller; + +import cn.cordys.common.util.Translator; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.clue.domain.ClueCapacity; +import cn.cordys.crm.system.dto.request.CapacityAddRequest; +import cn.cordys.crm.system.dto.request.CapacityUpdateRequest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class ClueCapacityControllerTests extends BaseTest { + + private static String CAPACITY_ID; + + @Test + @Order(1) + void add() throws Exception { + CapacityAddRequest request = new CapacityAddRequest(); + request.setScopeIds(List.of("admin")); + request.setCapacity(10); + this.requestPostWithOk("/lead-capacity/add", request); + MvcResult mvcResult = this.requestPost("/lead-capacity/add", request).andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult.getResponse().getContentAsString().contains(Translator.get("capacity.scope.duplicate")); + } + + @Test + @Order(2) + void page() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn("/lead-capacity/get"); + List result = getResultDataArray(mvcResult, ClueCapacity.class); + assert result.size() == 1; + CAPACITY_ID = result.getFirst().getId(); + } + + @Test + @Order(3) + void update() throws Exception { + CapacityUpdateRequest request = new CapacityUpdateRequest(); + request.setId("not-exist"); + request.setScopeIds(List.of("admin")); + request.setCapacity(100); + MvcResult mvcResult = this.requestPost("/lead-capacity/update", request).andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult.getResponse().getContentAsString().contains(Translator.get("capacity.not.exist")); + request.setId(CAPACITY_ID); + this.requestPost("/lead-capacity/update", request); + } + + @Test + @Order(4) + void delete() throws Exception { + this.requestGetWithOk("/lead-capacity/delete/" + CAPACITY_ID); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueControllerTests.java new file mode 100644 index 0000000..a84df7b --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueControllerTests.java @@ -0,0 +1,370 @@ +package cn.cordys.crm.clue.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.InternalUserView; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.ExportHeadDTO; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.clue.constants.ClueStatus; +import cn.cordys.crm.clue.domain.Clue; +import cn.cordys.crm.clue.domain.ClueField; +import cn.cordys.crm.clue.dto.request.*; +import cn.cordys.crm.clue.dto.response.ClueGetResponse; +import cn.cordys.crm.clue.dto.response.ClueListResponse; +import cn.cordys.crm.system.domain.ModuleField; +import cn.cordys.crm.system.domain.ModuleForm; +import cn.cordys.crm.system.dto.request.BatchPoolReasonRequest; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ClueControllerTests extends BaseTest { + protected static final String MODULE_FORM = "module/form"; + protected static final String STATUS_UPDATE = "status/update"; + protected static final String BATCH_TRANSFER = "batch/transfer"; + protected static final String TAB = "tab"; + protected static final String BATCH_TO_POOL = "batch/to-pool"; + protected static final String TRANSITION_CUSTOMER = "transition/account"; + protected static final String EXPORT = "export"; + protected static final String EXPORT_SELECT = "export-select"; + private static final String BASE_PATH = "/lead/"; + private static final List batchIds = new ArrayList<>(); + private static Clue addClue; + private static Clue anotherClue; + @Resource + private BaseMapper clueMapper; + + @Resource + private BaseMapper clueFieldMapper; + + @Resource + private BaseMapper moduleFormMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(0) + void testModuleField() throws Exception { + this.requestGetWithOkAndReturn(MODULE_FORM); + + // 校验权限 + requestGetPermissionsTest(List.of(PermissionConstants.CLUE_MANAGEMENT_READ, PermissionConstants.CLUE_MANAGEMENT_POOL_READ), MODULE_FORM); + } + + @Test + @Order(0) + void testPageEmpty() throws Exception { + CluePageRequest request = new CluePageRequest(); + request.setCurrent(1); + request.setPageSize(10); + + this.requestPostWithOkAndReturn(DEFAULT_PAGE, request); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CLUE_MANAGEMENT_READ, DEFAULT_PAGE, request); + } + + @Test + @Order(1) + void testAdd() throws Exception { + ModuleForm moduleForm = getModuleForm(); + + ModuleField example = new ModuleField(); + example.setFormId(moduleForm.getId()); +// ModuleField moduleField = moduleFieldMapper.select(example) +// .stream() +// .filter(field -> Strings.CS.equals(field.getInternalKey(), "clueSource")) +// .findFirst().orElse(null); + + // 请求成功 + ClueAddRequest request = new ClueAddRequest(); + request.setName("aa"); + request.setOwner("bb"); + request.setContact("test111"); + request.setPhone("18750920048"); + request.setProducts(List.of("cc")); +// request.setModuleFields(List.of(new BaseModuleFieldValue(moduleField.getId(), "1"))); + + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + Clue resultData = getResultData(mvcResult, Clue.class); + Clue clue = clueMapper.selectByPrimaryKey(resultData.getId()); + Assertions.assertEquals(request.getName(), clue.getName()); + Assertions.assertEquals(request.getOwner(), clue.getOwner()); + + // 校验字段 +// List fieldValues = getClueFields(clue.getId()) +// .stream().map(clueField -> { +// BaseModuleFieldValue baseModuleFieldValue = BeanUtils.copyBean(new BaseModuleFieldValue(), clueField); +// baseModuleFieldValue.setFieldValue(clueField.getFieldValue()); +// return baseModuleFieldValue; +// }) +// .toList(); +// Assertions.assertEquals(request.getModuleFields(), fieldValues); + + + // 创建另一个客户 + request.setName("another"); + request.setPhone("18750920049"); + request.setOwner(InternalUser.ADMIN.getValue()); + mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + resultData = getResultData(mvcResult, Clue.class); + anotherClue = clueMapper.selectByPrimaryKey(resultData.getId()); + + // 校验请求成功数据 + addClue = clue; + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CLUE_MANAGEMENT_ADD, DEFAULT_ADD, request); + } + + private ModuleForm getModuleForm() { + ModuleForm example = new ModuleForm(); + example.setOrganizationId(DEFAULT_ORGANIZATION_ID); + example.setFormKey(FormKey.CLUE.getKey()); + return moduleFormMapper.selectOne(example); + } + + @Test + @Order(2) + void testUpdate() throws Exception { + // 请求成功 + ClueUpdateRequest request = new ClueUpdateRequest(); + request.setId(addClue.getId()); + request.setName("aa11"); + request.setOwner(InternalUser.ADMIN.getValue()); + request.setProducts(List.of("cc", "dd")); + this.requestPostWithOk(DEFAULT_UPDATE, request); + // 校验请求成功数据 + Clue clueResult = clueMapper.selectByPrimaryKey(request.getId()); + Assertions.assertEquals(request.getName(), clueResult.getName()); + Assertions.assertEquals(request.getOwner(), clueResult.getOwner()); + + // 不修改信息 + ClueUpdateRequest emptyRequest = new ClueUpdateRequest(); + emptyRequest.setId(addClue.getId()); + emptyRequest.setProducts(List.of("cc", "dd")); + this.requestPostWithOk(DEFAULT_UPDATE, emptyRequest); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CLUE_MANAGEMENT_UPDATE, DEFAULT_UPDATE, request); + } + + @Test + @Order(2) + void testUpdateStatus() throws Exception { + // 请求成功 + ClueStatusUpdateRequest request = new ClueStatusUpdateRequest(); + request.setId(addClue.getId()); + request.setStage(ClueStatus.INTERESTED.name()); + this.requestPostWithOk(STATUS_UPDATE, request); + // 校验请求成功数据 + Clue clueResult = clueMapper.selectByPrimaryKey(request.getId()); + Assertions.assertEquals(request.getStage(), clueResult.getStage()); + Assertions.assertEquals(addClue.getStage(), clueResult.getLastStage()); + addClue.setStage(request.getStage()); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CLUE_MANAGEMENT_UPDATE, STATUS_UPDATE, request); + } + + @Test + @Order(3) + void testGet() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(DEFAULT_GET, addClue.getId()); + ClueGetResponse getResponse = getResultData(mvcResult, ClueGetResponse.class); + + // 校验请求成功数据 + Assertions.assertNotNull(getResponse.getOwnerName()); + if (!getResponse.getOwner().equals(InternalUser.ADMIN.getValue())) { + Assertions.assertNotNull(getResponse.getDepartmentId()); + Assertions.assertNotNull(getResponse.getDepartmentName()); + } + Assertions.assertNotNull(getResponse.getOptionMap().get("owner")); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CLUE_MANAGEMENT_READ, DEFAULT_GET, addClue.getId()); + } + + + @Test + @Order(3) + void testPage() throws Exception { + CluePageRequest request = new CluePageRequest(); + request.setCurrent(1); + request.setPageSize(10); + + request.setViewId(InternalUserView.ALL.name()); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_PAGE, request); + Pager> pageResult = getPageResult(mvcResult, ClueListResponse.class); + List clueList = pageResult.getList(); + + Clue example = new Clue(); + example.setOrganizationId(DEFAULT_ORGANIZATION_ID); + example.setInSharedPool(false); + Map clueMap = clueMapper.select(example) + .stream() + .collect(Collectors.toMap(Clue::getId, Function.identity())); + + clueList.forEach(clueListResponse -> { + Clue clue = clueMap.get(clueListResponse.getId()); + Clue responseClue = BeanUtils.copyBean(new Clue(), clueListResponse); + responseClue.setOrganizationId(DEFAULT_ORGANIZATION_ID); + responseClue.setInSharedPool(false); + Assertions.assertEquals(clue, responseClue); + Assertions.assertNotNull(clueListResponse.getOwnerName()); + if (!responseClue.getOwner().equals(InternalUser.ADMIN.getValue())) { + Assertions.assertNotNull(clueListResponse.getDepartmentId()); + Assertions.assertNotNull(clueListResponse.getDepartmentName()); + } + }); + + request.setViewId(InternalUserView.SELF.name()); + this.requestPostWithOk(DEFAULT_PAGE, request); + + request.setViewId(InternalUserView.DEPARTMENT.name()); + this.requestPostWithOk(DEFAULT_PAGE, request); + + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CLUE_MANAGEMENT_READ, DEFAULT_PAGE, request); + } + + @Test + @Order(4) + void testTransfer() throws Exception { + ClueBatchTransferRequest request = new ClueBatchTransferRequest(); + request.setIds(List.of(addClue.getId())); + request.setOwner(PERMISSION_USER_NAME); + MvcResult mvcResult = this.requestPostWithOkAndReturn(BATCH_TRANSFER, request); + + getResultData(mvcResult, ClueGetResponse.class); + + // 校验请求成功数据 + Clue clue = clueMapper.selectByPrimaryKey(addClue.getId()); + Assertions.assertEquals(PERMISSION_USER_NAME, clue.getOwner()); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CLUE_MANAGEMENT_TRANSFER, BATCH_TRANSFER, request); + } + + @Test + @Order(4) + void testTab() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(TAB); + ResourceTabEnableDTO resultData = getResultData(mvcResult, ResourceTabEnableDTO.class); + // 校验请求成功数据 + Assertions.assertTrue(resultData.getAll()); + Assertions.assertTrue(resultData.getDept()); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CLUE_MANAGEMENT_READ, TAB); + } + + @Test + @Order(5) + void transitionCustomer() throws Exception { + ClueTransitionCustomerRequest request = new ClueTransitionCustomerRequest(); + request.setClueId(addClue.getId()); + request.setName("cc"); + request.setOwner("admin"); + this.requestPostWithOk(TRANSITION_CUSTOMER, request); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_ADD, TRANSITION_CUSTOMER, request); + } + + @Test + @Order(10) + void testDelete() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, addClue.getId()); + Assertions.assertNull(clueMapper.selectByPrimaryKey(addClue.getId())); + + List fields = getClueFields(addClue.getId()); + Assumptions.assumeTrue(CollectionUtils.isEmpty(fields)); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CLUE_MANAGEMENT_DELETE, DEFAULT_DELETE, addClue.getId()); + } + + @Test + @Order(11) + void testBatchDelete() throws Exception { + this.requestPostWithOk(DEFAULT_BATCH_DELETE, List.of(anotherClue.getId())); + Assertions.assertNull(clueMapper.selectByPrimaryKey(anotherClue.getId())); + + List fields = getClueFields(anotherClue.getId()); + Assumptions.assumeTrue(CollectionUtils.isEmpty(fields)); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CLUE_MANAGEMENT_DELETE, DEFAULT_BATCH_DELETE, List.of(anotherClue.getId())); + } + + @Test + @Order(12) + @Sql(scripts = {"/dml/init_clue_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = {"/dml/cleanup_clue_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void testPageReservedDay() throws Exception { + ClueAddRequest addRequest = new ClueAddRequest(); + addRequest.setName("aa"); + addRequest.setOwner("admin"); + addRequest.setContact("test"); + addRequest.setPhone("11111111111"); + addRequest.setProducts(List.of("cc")); + this.requestPostWithOk(DEFAULT_ADD, addRequest); + CluePageRequest request = new CluePageRequest(); + request.setCurrent(1); + request.setPageSize(10); + request.setViewId(InternalUserView.ALL.name()); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_PAGE, request); + Pager> pageResult = getPageResult(mvcResult, ClueListResponse.class); + List clueList = pageResult.getList(); + clueList.forEach(clue -> { + batchIds.add(clue.getId()); + }); + BatchPoolReasonRequest reasonRequest = new BatchPoolReasonRequest(); + reasonRequest.setIds(batchIds); + this.requestPostWithOk(BATCH_TO_POOL, reasonRequest); + } + + @Test + @Order(13) + void testBatchToPool() throws Exception { + BatchPoolReasonRequest reasonRequest = new BatchPoolReasonRequest(); + reasonRequest.setIds(batchIds); + this.requestPostWithOk(BATCH_TO_POOL, reasonRequest); + // 校验权限 + requestPostPermissionTest(PermissionConstants.CLUE_MANAGEMENT_RECYCLE, BATCH_TO_POOL, reasonRequest); + } + + private List getClueFields(String clueId) { + ClueField example = new ClueField(); + example.setResourceId(clueId); + return clueFieldMapper.select(example); + } +} \ No newline at end of file diff --git a/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueFollowPlanControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueFollowPlanControllerTests.java new file mode 100644 index 0000000..d32f983 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueFollowPlanControllerTests.java @@ -0,0 +1,120 @@ +package cn.cordys.crm.clue.controller; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.follow.constants.FollowUpPlanStatusType; +import cn.cordys.crm.follow.domain.FollowUpPlan; +import cn.cordys.crm.follow.domain.FollowUpRecord; +import cn.cordys.crm.follow.dto.request.FollowUpPlanAddRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanStatusRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanUpdateRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordPageRequest; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class ClueFollowPlanControllerTests extends BaseTest { + + private static final String BASE_PATH = "/lead/follow/plan/"; + + private static final String CANCEL_PLAN = "cancel/{0}"; + + private static final String STATUS_UPDATE = "status/update"; + private static FollowUpPlan addFollowUpPlan; + @Resource + private BaseMapper followUpPlanMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void testAdd() throws Exception { + FollowUpPlanAddRequest request = new FollowUpPlanAddRequest(); + request.setCustomerId("123"); + request.setOpportunityId("12345"); + request.setOwner("admin"); + request.setContactId("123456"); + request.setType("CUSTOMER"); + request.setMethod("1"); + request.setContent("计划一下"); + request.setEstimatedTime(System.currentTimeMillis()); + request.setModuleFields(List.of(new BaseModuleFieldValue("id", "value"))); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + FollowUpRecord resultData = getResultData(mvcResult, FollowUpRecord.class); + addFollowUpPlan = followUpPlanMapper.selectByPrimaryKey(resultData.getId()); + } + + + @Test + @Order(2) + void testUpdate() throws Exception { + FollowUpPlanUpdateRequest request = new FollowUpPlanUpdateRequest(); + request.setId("1234"); + request.setCustomerId("123"); + request.setOpportunityId("12345"); + request.setOwner("admin"); + request.setContactId("1234567"); + request.setMethod("2"); + request.setType("CUSTOMER"); + request.setContent("计划两下"); + request.setEstimatedTime(System.currentTimeMillis()); + request.setConverted(false); + request.setModuleFields(List.of(new BaseModuleFieldValue("id", "value"))); + this.requestPost(DEFAULT_UPDATE, request); + + request.setId(addFollowUpPlan.getId()); + this.requestPostWithOk(DEFAULT_UPDATE, request); + } + + + @Test + @Order(3) + void testList() throws Exception { + FollowUpRecordPageRequest request = new FollowUpRecordPageRequest(); + request.setSourceId("123"); + request.setCurrent(1); + request.setPageSize(10); + this.requestPost(DEFAULT_PAGE, request); + } + + @Test + @Order(4) + void testGet() throws Exception { + this.requestGet(DEFAULT_GET, addFollowUpPlan.getId()); + } + + @Test + @Order(4) + void testStatusUpdate() throws Exception { + FollowUpPlanStatusRequest request = new FollowUpPlanStatusRequest(); + request.setId(addFollowUpPlan.getId()); + request.setStatus(FollowUpPlanStatusType.COMPLETED.name()); + this.requestPost(STATUS_UPDATE, request); + } + + @Test + @Order(5) + void testCancelPlan() throws Exception { + this.requestGetWithOk(CANCEL_PLAN, addFollowUpPlan.getId()); + } + + @Test + @Order(6) + void tesDeletePlan() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, addFollowUpPlan.getId()); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueFollowRecordControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueFollowRecordControllerTests.java new file mode 100644 index 0000000..054e72d --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueFollowRecordControllerTests.java @@ -0,0 +1,111 @@ +package cn.cordys.crm.clue.controller; + + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.follow.domain.FollowUpRecord; +import cn.cordys.crm.follow.dto.request.FollowUpRecordAddRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordPageRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordUpdateRequest; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class ClueFollowRecordControllerTests extends BaseTest { + + private static final String BASE_PATH = "/lead/follow/record/"; + + private static final String POOL_PAGE = "pool/page"; + private static FollowUpRecord addFollowUpRecord; + @Resource + private BaseMapper followUpRecordMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void testAdd() throws Exception { + FollowUpRecordAddRequest request = new FollowUpRecordAddRequest(); + request.setCustomerId("123"); + request.setOpportunityId("12345"); + request.setClueId("123456"); + request.setOwner("admin"); + request.setContactId("123456"); + request.setFollowMethod("1"); + request.setType("CUSTOMER"); + request.setContent("跟进一下"); + request.setFollowTime(System.currentTimeMillis()); + request.setModuleFields(List.of(new BaseModuleFieldValue("id", "value"))); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + FollowUpRecord resultData = getResultData(mvcResult, FollowUpRecord.class); + addFollowUpRecord = followUpRecordMapper.selectByPrimaryKey(resultData.getId()); + } + + + @Test + @Order(2) + void testUpdate() throws Exception { + FollowUpRecordUpdateRequest request = new FollowUpRecordUpdateRequest(); + request.setId("1234"); + request.setCustomerId("123"); + request.setOpportunityId("12345"); + request.setClueId("123456"); + request.setOwner("admin"); + request.setFollowMethod("2"); + request.setContactId("1234567"); + request.setType("CUSTOMER"); + request.setContent("跟进两下"); + request.setModuleFields(List.of(new BaseModuleFieldValue("id", "value"))); + this.requestPost(DEFAULT_UPDATE, request); + + request.setId(addFollowUpRecord.getId()); + this.requestPostWithOk(DEFAULT_UPDATE, request); + } + + @Test + @Order(3) + void testList() throws Exception { + FollowUpRecordPageRequest request = new FollowUpRecordPageRequest(); + request.setSourceId("123456"); + request.setCurrent(1); + request.setPageSize(10); + this.requestPost(DEFAULT_PAGE, request); + } + + @Test + @Order(4) + void testGet() throws Exception { + this.requestGet(DEFAULT_GET, addFollowUpRecord.getId()); + } + + + @Test + @Order(3) + void testPoolList() throws Exception { + FollowUpRecordPageRequest request = new FollowUpRecordPageRequest(); + request.setSourceId("123456"); + request.setCurrent(1); + request.setPageSize(10); + this.requestPost(POOL_PAGE, request); + } + + @Test + @Order(5) + void testDeleteRecord() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, addFollowUpRecord.getId()); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueOwnerHistoryControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueOwnerHistoryControllerTests.java new file mode 100644 index 0000000..8b33774 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueOwnerHistoryControllerTests.java @@ -0,0 +1,114 @@ +package cn.cordys.crm.clue.controller; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.clue.domain.Clue; +import cn.cordys.crm.clue.domain.ClueOwner; +import cn.cordys.crm.clue.dto.request.ClueAddRequest; +import cn.cordys.crm.clue.dto.request.ClueBatchTransferRequest; +import cn.cordys.crm.clue.dto.response.ClueOwnerListResponse; +import cn.cordys.crm.clue.service.ClueOwnerHistoryService; +import cn.cordys.crm.clue.service.ClueService; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; +import java.util.UUID; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ClueOwnerHistoryControllerTests extends BaseTest { + protected static final String LIST = "list/{0}"; + private static final String BASE_PATH = "/lead/owner/history/"; + private static Clue addClue; + + @Resource + private ClueOwnerHistoryService clueOwnerHistoryService; + + @Resource + private ClueService clueService; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(0) + void testListEmpty() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(LIST, "111"); + List list = getResultDataArray(mvcResult, ClueOwnerListResponse.class); + Assertions.assertTrue(CollectionUtils.isEmpty(list)); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CLUE_MANAGEMENT_READ, LIST, "111"); + } + + @Test + @Order(1) + void testAdd() { + ClueAddRequest clueAddRequest = new ClueAddRequest(); + clueAddRequest.setName(UUID.randomUUID().toString()); + clueAddRequest.setOwner(InternalUser.ADMIN.getValue()); + clueAddRequest.setProducts(List.of("cc")); + addClue = clueService.add(clueAddRequest, InternalUser.ADMIN.getValue(), DEFAULT_ORGANIZATION_ID); + + ClueOwner clueOwner = clueOwnerHistoryService.add(addClue, InternalUser.ADMIN.getValue(), false); + // 校验成功数据 + Assertions.assertEquals(clueOwner.getClueId(), addClue.getId()); + Assertions.assertEquals(clueOwner.getOperator(), InternalUser.ADMIN.getValue()); + Assertions.assertEquals(clueOwner.getCollectionTime(), addClue.getCollectionTime()); + Assertions.assertEquals(clueOwner.getOwner(), addClue.getOwner()); + } + + @Test + @Order(2) + void testBatchAdd() { + ClueBatchTransferRequest transferRequest = new ClueBatchTransferRequest(); + transferRequest.setIds(List.of(addClue.getId())); + transferRequest.setOwner(PERMISSION_USER_NAME); + clueOwnerHistoryService.batchAdd(transferRequest, InternalUser.ADMIN.getValue()); + + List list = clueOwnerHistoryService.list(addClue.getId(), DEFAULT_ORGANIZATION_ID); + for (ClueOwnerListResponse clueOwner : list) { + // 校验成功数据 + Assertions.assertEquals(clueOwner.getClueId(), addClue.getId()); + Assertions.assertEquals(clueOwner.getOperator(), InternalUser.ADMIN.getValue()); + Assertions.assertEquals(clueOwner.getCollectionTime(), addClue.getCollectionTime()); + Assertions.assertEquals(clueOwner.getOwner(), addClue.getOwner()); + } + } + + @Test + @Order(3) + void testList() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(LIST, addClue.getId()); + List list = getResultDataArray(mvcResult, ClueOwnerListResponse.class); + list.forEach(clueOwner -> { + // 校验成功数据 + Assertions.assertEquals(clueOwner.getClueId(), addClue.getId()); + Assertions.assertEquals(clueOwner.getOperator(), InternalUser.ADMIN.getValue()); + Assertions.assertEquals(clueOwner.getCollectionTime(), addClue.getCollectionTime()); + Assertions.assertEquals(clueOwner.getOwner(), addClue.getOwner()); + Assertions.assertNotNull(clueOwner.getOwnerName()); + Assertions.assertNotNull(clueOwner.getOwnerName()); + }); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CLUE_MANAGEMENT_READ, LIST, "111"); + } + + @Test + @Order(10) + void batchDelete() { + clueOwnerHistoryService.deleteByClueIds(List.of(addClue.getId())); + List list = clueOwnerHistoryService.list(addClue.getId(), DEFAULT_ORGANIZATION_ID); + Assertions.assertTrue(list.isEmpty()); + } +} \ No newline at end of file diff --git a/backend/crm/src/test/java/cn/cordys/crm/clue/controller/CluePoolControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/CluePoolControllerTests.java new file mode 100644 index 0000000..638969a --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/CluePoolControllerTests.java @@ -0,0 +1,129 @@ +package cn.cordys.crm.clue.controller; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.SortRequest; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.clue.domain.CluePool; +import cn.cordys.crm.clue.dto.CluePoolDTO; +import cn.cordys.crm.clue.dto.CluePoolPickRuleDTO; +import cn.cordys.crm.clue.dto.CluePoolRecycleRuleDTO; +import cn.cordys.crm.clue.dto.request.CluePoolAddRequest; +import cn.cordys.crm.clue.dto.request.CluePoolUpdateRequest; +import cn.cordys.crm.system.dto.RuleConditionDTO; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; +import java.util.Set; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class CluePoolControllerTests extends BaseTest { + + private static CluePoolDTO testCluePool; + + @Test + @Order(1) + void emptyPage() throws Exception { + MvcResult mvcResult = this.requestPostWithOkAndReturn("/lead-pool/page", createPageRequest()); + Pager> result = getPageResult(mvcResult, CluePoolDTO.class); + assert result.getList().isEmpty(); + } + + @Test + @Order(2) + void add() throws Exception { + CluePool cluePool = createCluePool(); + CluePoolAddRequest request = new CluePoolAddRequest(); + BeanUtils.copyBean(request, cluePool); + request.setOwnerIds(List.of("cc")); + request.setScopeIds(List.of("cc")); + CluePoolPickRuleDTO pickRule = CluePoolPickRuleDTO.builder() + .pickNumber(1).limitOnNumber(true).pickIntervalDays(1).limitPreOwner(true).limitNew(false).build(); + request.setPickRule(pickRule); + RuleConditionDTO condition = new RuleConditionDTO(); + condition.setColumn("name"); + condition.setOperator("="); + condition.setValue("cc"); + CluePoolRecycleRuleDTO recycleRule = CluePoolRecycleRuleDTO.builder().conditions(List.of(condition)).build(); + request.setRecycleRule(recycleRule); + request.setHiddenFieldIds(Set.of(BusinessModuleField.CLUE_NAME.getKey())); + this.requestPostWithOk("/lead-pool/add", request); + } + + @Test + @Order(3) + void page() throws Exception { + BasePageRequest request = createPageRequest(); + request.setSort(new SortRequest("name", "desc")); + MvcResult mvcResult = this.requestPostWithOkAndReturn("/lead-pool/page", request); + Pager> result = getPageResult(mvcResult, CluePoolDTO.class); + assert result.getList().size() == 1; + testCluePool = result.getList().getFirst(); + } + + @Test + @Order(4) + void update() throws Exception { + CluePool cluePool = createCluePool(); + cluePool.setId(testCluePool.getId()); + CluePoolUpdateRequest request = new CluePoolUpdateRequest(); + BeanUtils.copyBean(request, cluePool); + request.setOwnerIds(List.of("cc")); + request.setScopeIds(List.of("cc")); + CluePoolPickRuleDTO pickRule = CluePoolPickRuleDTO.builder() + .pickNumber(1).limitOnNumber(true).pickIntervalDays(1).limitPreOwner(true).build(); + pickRule.setLimitNew(false); + request.setPickRule(pickRule); + CluePoolRecycleRuleDTO recycleRule = CluePoolRecycleRuleDTO.builder().build(); + request.setRecycleRule(recycleRule); + request.setHiddenFieldIds(Set.of(BusinessModuleField.CUSTOMER_OWNER.getKey())); + this.requestPostWithOk("/lead-pool/update", request); + } + + @Test + @Order(5) + void switchStatus() throws Exception { + MvcResult mvcResult = this.requestGet("/lead-pool/switch/default-pool").andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult.getResponse().getContentAsString().contains(Translator.get("clue_pool_not_exist")); + this.requestGetWithOk("/lead-pool/switch/" + testCluePool.getId()); + } + + @Test + @Order(6) + void delete() throws Exception { + MvcResult mvcResult = this.requestGet("/lead-pool/delete/default-pool").andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult.getResponse().getContentAsString().contains(Translator.get("clue_pool_not_exist")); + this.requestGetWithOk("/lead-pool/delete/" + testCluePool.getId()); + } + + private BasePageRequest createPageRequest() { + BasePageRequest request = new BasePageRequest(); + request.setCurrent(1); + request.setPageSize(10); + return request; + } + + private CluePool createCluePool() { + CluePool cluePool = new CluePool(); + cluePool.setName("default-clue-pool"); + cluePool.setScopeId(JSON.toJSONString(List.of("default-dp"))); + cluePool.setOrganizationId(DEFAULT_ORGANIZATION_ID); + cluePool.setEnable(true); + cluePool.setAuto(true); + return cluePool; + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueUserViewControllerTest.java b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueUserViewControllerTest.java new file mode 100644 index 0000000..076baf9 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/ClueUserViewControllerTest.java @@ -0,0 +1,134 @@ +package cn.cordys.crm.clue.controller; + +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.ArrayList; +import java.util.List; + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class ClueUserViewControllerTest extends BaseTest { + + protected static final String DETAIL = "detail/{0}"; + protected static final String LIST = "list"; + protected static final String FIXED = "fixed/{0}"; + protected static final String POS = "edit/pos"; + protected static final String ENABLE = "enable/{0}"; + private static final String BASE_PATH = "/lead/view/"; + private static String viewId; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void testAdd() throws Exception { + + UserViewAddRequest request = new UserViewAddRequest(); + request.setName("测试视图11"); + request.setSearchMode("AND"); + request.setConditions(buildConditions()); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + UserView resultData = getResultData(mvcResult, UserView.class); + viewId = resultData.getId(); + + } + + private List buildConditions() { + List conditions = new ArrayList<>(); + + FilterCondition condition1 = new FilterCondition(); + condition1.setName("123123312312"); + condition1.setValue(null); + condition1.setOperator("EMPTY"); + condition1.setMultipleValue(false); + condition1.setType("LOCATION"); + + FilterCondition condition2 = new FilterCondition(); + condition2.setName("1231233333"); + condition2.setValue("[\n" + + " \"2\"\n" + + " ]"); + condition2.setOperator("IN"); + condition2.setMultipleValue(false); + condition2.setType("1231"); + + conditions.add(condition1); + conditions.add(condition2); + return conditions; + } + + + @Test + @Order(2) + void testUpdate() throws Exception { + UserViewUpdateRequest request = new UserViewUpdateRequest(); + request.setId(viewId); + request.setName("更新試圖22"); + request.setConditions(buildConditions()); + this.requestPost(DEFAULT_UPDATE, request); + + request.setId("123123123"); + this.requestPost(DEFAULT_UPDATE, request); + } + + @Test + @Order(3) + void testGetDetail() throws Exception { + this.requestGet(DETAIL, viewId); + } + + @Test + @Order(4) + void testList() throws Exception { + this.requestGet(LIST); + } + + @Test + @Order(5) + void testFixed() throws Exception { + this.requestGet(FIXED, viewId); + } + + @Test + @Order(5) + void testSortPos() throws Exception { + String moveId = viewId; + testAdd(); + String targetId = viewId; + PosRequest request = new PosRequest(); + request.setOrgId(DEFAULT_ORGANIZATION_ID); + request.setMoveId(moveId); + request.setTargetId(targetId); + request.setMoveMode("after"); + this.requestGet(POS, request); + } + + @Test + @Order(6) + void testEnable() throws Exception { + this.requestGet(ENABLE, viewId); + } + + @Test + @Order(10) + void testDelete() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, viewId); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/clue/controller/PoolClueControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/PoolClueControllerTests.java new file mode 100644 index 0000000..7fe9ad0 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/PoolClueControllerTests.java @@ -0,0 +1,238 @@ +package cn.cordys.crm.clue.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.clue.domain.Clue; +import cn.cordys.crm.clue.domain.ClueCapacity; +import cn.cordys.crm.clue.domain.ClueOwner; +import cn.cordys.crm.clue.domain.CluePoolPickRule; +import cn.cordys.crm.clue.dto.request.CluePageRequest; +import cn.cordys.crm.clue.dto.request.PoolClueAssignRequest; +import cn.cordys.crm.clue.dto.request.PoolCluePickRequest; +import cn.cordys.crm.clue.dto.response.ClueListResponse; +import cn.cordys.crm.system.domain.ExportTask; +import cn.cordys.crm.system.dto.request.PoolBatchAssignRequest; +import cn.cordys.crm.system.dto.request.PoolBatchPickRequest; +import cn.cordys.crm.system.service.ExportTaskCenterService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class PoolClueControllerTests extends BaseTest { + + public static final String BASE_PATH = "/pool/lead"; + public static final String GET_OPTIONS = "/options"; + public static final String PAGE = "/page"; + public static final String PICK = "/pick"; + public static final String ASSIGN = "/assign"; + public static final String DELETE = "/delete/"; + public static final String GET_DETAIL = "/get/"; + public static final String BATCH_PICK = "/batch-pick"; + public static final String BATCH_ASSIGN = "/batch-assign"; + public static final String BATCH_DELETE = "/batch-delete"; + + public static String testDataId; + + @Resource + private BaseMapper clueMapper; + @Resource + private BaseMapper clueOwnerMapper; + @Resource + private BaseMapper clueCapacityMapper; + @Resource + private BaseMapper cluePoolPickRuleMapper; + @Resource + private BaseMapper exportTaskBaseMapper; + @Resource + private ExportTaskCenterService exportTaskCenterService; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void prepareTestData() { + Clue clue = createClue(); + testDataId = clue.getId(); + Clue ownClue = createClue(); + ClueCapacity capacity = createCapacity(); + ownClue.setInSharedPool(false); + ownClue.setOwner("admin"); + clueMapper.batchInsert(List.of(ownClue, clue)); + clueCapacityMapper.insert(capacity); + } + + @Test + @Order(2) + void getOptions() throws Exception { + this.requestGetWithOk(GET_OPTIONS); + requestGetPermissionTest(PermissionConstants.CLUE_MANAGEMENT_POOL_READ, GET_OPTIONS); + } + + @Test + @Order(3) + void page() throws Exception { + CluePageRequest request = new CluePageRequest(); + request.setPoolId("test-pool-id"); + request.setCurrent(1); + request.setPageSize(10); + MvcResult mvcResult = this.requestPostWithOkAndReturn(PAGE, request); + Pager> pageResult = getPageResult(mvcResult, ClueListResponse.class); + assert pageResult.getTotal() == 1; + requestPostPermissionTest(PermissionConstants.CLUE_MANAGEMENT_POOL_READ, PAGE, request); + } + + @Test + @Order(4) + void pickFailWithOverCapacity() throws Exception { + PoolCluePickRequest request = new PoolCluePickRequest(); + request.setClueId(testDataId); + request.setPoolId("test-pool-id"); + MvcResult mvcResult = this.requestPost(PICK, request).andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult.getResponse().getContentAsString().contains(Translator.getWithArgs("customer.capacity.over", 0)); + clueCapacityMapper.deleteByLambda(new LambdaQueryWrapper<>()); + CluePoolPickRule pickRule = createPickRule(); + pickRule.setLimitOnNumber(false); + pickRule.setPoolId("test-pool-id"); + cluePoolPickRuleMapper.insert(pickRule); + this.requestPost(PICK, request); + cluePoolPickRuleMapper.deleteByLambda(new LambdaQueryWrapper<>()); + requestPostPermissionTest(PermissionConstants.CLUE_MANAGEMENT_POOL_PICK, PICK, request); + } + + @Test + @Order(5) + void assignSuccess() throws Exception { + PoolClueAssignRequest request = new PoolClueAssignRequest(); + request.setClueId(testDataId); + request.setAssignUserId("aa"); + this.requestPostWithOk(ASSIGN, request); + requestPostPermissionTest(PermissionConstants.CLUE_MANAGEMENT_POOL_ASSIGN, ASSIGN, request); + } + + @Test + @Order(6) + void getDetail() throws Exception { + this.requestGetWithOk(GET_DETAIL + testDataId); + requestGetPermissionTest(PermissionConstants.CLUE_MANAGEMENT_POOL_READ, GET_DETAIL + testDataId); + } + + @Test + @Order(7) + void deleteSuccess() throws Exception { + this.requestGetWithOk(DELETE + testDataId); + requestGetPermissionTest(PermissionConstants.CLUE_MANAGEMENT_POOL_DELETE, DELETE + testDataId); + } + + @Test + @Order(8) + void batchPickFailWithOverDailyOrPreOwnerLimit() throws Exception { + Clue clue = createClue(); + clue.setOwner("admin"); + clue.setInSharedPool(false); + clueMapper.insert(clue); + CluePoolPickRule rule = createPickRule(); + rule.setLimitOnNumber(true); + rule.setPickNumber(1); + cluePoolPickRuleMapper.insert(rule); + clueCapacityMapper.deleteByLambda(new LambdaQueryWrapper<>()); + PoolBatchPickRequest request = new PoolBatchPickRequest(); + request.setBatchIds(List.of(testDataId)); + request.setPoolId("test-pool-id"); + this.requestPost(BATCH_PICK, request); + requestPostPermissionTest(PermissionConstants.CLUE_MANAGEMENT_POOL_PICK, BATCH_PICK, request); + } + + @Test + @Order(9) + void batchAssignFailWithNotExit() throws Exception { + PoolBatchAssignRequest request = new PoolBatchAssignRequest(); + request.setBatchIds(List.of("aaa")); + request.setAssignUserId("cc"); + MvcResult mvcResult = this.requestPost(BATCH_ASSIGN, request).andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult.getResponse().getContentAsString().contains(Translator.get("clue.not.exist")); + requestPostPermissionTest(PermissionConstants.CLUE_MANAGEMENT_POOL_ASSIGN, BATCH_ASSIGN, request); + } + + @Test + @Order(10) + void batchDeleteSuccess() throws Exception { + this.requestPostWithOk(BATCH_DELETE, List.of(testDataId)); + requestPostPermissionTest(PermissionConstants.CLUE_MANAGEMENT_POOL_DELETE, BATCH_DELETE, List.of(testDataId)); + } + + private Clue createClue() { + Clue clue = new Clue(); + clue.setId(IDGenerator.nextStr()); + clue.setStage("test"); + clue.setName("ct"); + clue.setOwner("cc"); + clue.setProducts(List.of("cc")); + clue.setCollectionTime(System.currentTimeMillis()); + clue.setPoolId("test-pool-id"); + clue.setInSharedPool(true); + clue.setOrganizationId(DEFAULT_ORGANIZATION_ID); + clue.setCreateTime(System.currentTimeMillis()); + clue.setCreateUser("admin"); + clue.setUpdateTime(System.currentTimeMillis()); + clue.setUpdateUser("admin"); + return clue; + } + + private ClueCapacity createCapacity() { + ClueCapacity capacity = new ClueCapacity(); + capacity.setId(IDGenerator.nextStr()); + capacity.setScopeId("admin"); + capacity.setOrganizationId(DEFAULT_ORGANIZATION_ID); + capacity.setCapacity(1); + capacity.setCreateTime(System.currentTimeMillis()); + capacity.setCreateUser("admin"); + capacity.setUpdateTime(System.currentTimeMillis()); + capacity.setUpdateUser("admin"); + return capacity; + } + + private CluePoolPickRule createPickRule() { + CluePoolPickRule rule = new CluePoolPickRule(); + rule.setId(IDGenerator.nextStr()); + rule.setPoolId("test-pool-id"); + rule.setLimitOnNumber(false); + rule.setLimitPreOwner(false); + rule.setLimitNew(false); + rule.setCreateTime(System.currentTimeMillis()); + rule.setCreateUser("admin"); + rule.setUpdateTime(System.currentTimeMillis()); + rule.setUpdateUser("admin"); + return rule; + } + + private void insertOwnerHis() { + ClueOwner clueOwner = new ClueOwner(); + clueOwner.setId(IDGenerator.nextStr()); + clueOwner.setClueId(testDataId); + clueOwner.setOwner("admin"); + clueOwner.setCollectionTime(System.currentTimeMillis()); + clueOwner.setOperator("admin"); + clueOwner.setEndTime(System.currentTimeMillis()); + clueOwnerMapper.insert(clueOwner); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/clue/controller/PoolClueUserViewControllerTest.java b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/PoolClueUserViewControllerTest.java new file mode 100644 index 0000000..74c0260 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/clue/controller/PoolClueUserViewControllerTest.java @@ -0,0 +1,134 @@ +package cn.cordys.crm.clue.controller; + +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.ArrayList; +import java.util.List; + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class PoolClueUserViewControllerTest extends BaseTest { + + protected static final String DETAIL = "detail/{0}"; + protected static final String LIST = "list"; + protected static final String FIXED = "fixed/{0}"; + protected static final String POS = "edit/pos"; + protected static final String ENABLE = "enable/{0}"; + private static final String BASE_PATH = "/pool/lead/view/"; + private static String viewId; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void testAdd() throws Exception { + + UserViewAddRequest request = new UserViewAddRequest(); + request.setName("测试视图11"); + request.setSearchMode("AND"); + request.setConditions(buildConditions()); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + UserView resultData = getResultData(mvcResult, UserView.class); + viewId = resultData.getId(); + + } + + private List buildConditions() { + List conditions = new ArrayList<>(); + + FilterCondition condition1 = new FilterCondition(); + condition1.setName("123123312312"); + condition1.setValue(null); + condition1.setOperator("EMPTY"); + condition1.setMultipleValue(false); + condition1.setType("LOCATION"); + + FilterCondition condition2 = new FilterCondition(); + condition2.setName("1231233333"); + condition2.setValue("[\n" + + " \"2\"\n" + + " ]"); + condition2.setOperator("IN"); + condition2.setMultipleValue(false); + condition2.setType("1231"); + + conditions.add(condition1); + conditions.add(condition2); + return conditions; + } + + + @Test + @Order(2) + void testUpdate() throws Exception { + UserViewUpdateRequest request = new UserViewUpdateRequest(); + request.setId(viewId); + request.setName("更新試圖22"); + request.setConditions(buildConditions()); + this.requestPost(DEFAULT_UPDATE, request); + + request.setId("123123123"); + this.requestPost(DEFAULT_UPDATE, request); + } + + @Test + @Order(3) + void testGetDetail() throws Exception { + this.requestGet(DETAIL, viewId); + } + + @Test + @Order(4) + void testList() throws Exception { + this.requestGet(LIST); + } + + @Test + @Order(5) + void testFixed() throws Exception { + this.requestGet(FIXED, viewId); + } + + @Test + @Order(5) + void testSortPos() throws Exception { + String moveId = viewId; + testAdd(); + String targetId = viewId; + PosRequest request = new PosRequest(); + request.setOrgId(DEFAULT_ORGANIZATION_ID); + request.setMoveId(moveId); + request.setTargetId(targetId); + request.setMoveMode("after"); + this.requestGet(POS, request); + } + + @Test + @Order(6) + void testEnable() throws Exception { + this.requestGet(ENABLE, viewId); + } + + @Test + @Order(10) + void testDelete() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, viewId); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/contract/ContractControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/contract/ContractControllerTests.java new file mode 100644 index 0000000..2fb209a --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/contract/ContractControllerTests.java @@ -0,0 +1,224 @@ +package cn.cordys.crm.contract; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.contract.domain.Contract; +import cn.cordys.crm.contract.dto.request.ContractAddRequest; +import cn.cordys.crm.contract.dto.request.ContractPageRequest; +import cn.cordys.crm.contract.service.ContractFieldService; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class ContractControllerTests extends BaseTest { + + protected static final String MODULE_FORM = "module/form"; + protected static final String BATCH_UPDATE = "batch/update"; + private static final String BASE_PATH = "/contract/"; + + private static Contract addContract; + private static ModuleFormConfigDTO moduleFormConfig; + + @Resource + private BaseMapper contractMapper; + + @Resource + private ContractFieldService contractFieldService; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(0) + void testModuleField() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(MODULE_FORM); + moduleFormConfig = getResultData(mvcResult, ModuleFormConfigDTO.class); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CONTRACT_READ, MODULE_FORM); + } + + @Sql(scripts = {"/dml/init_contract_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Test + @Order(1) + void testAdd() throws Exception { + + ContractAddRequest request = new ContractAddRequest(); + request.setName("SomebodyJIAN-测试合同1"+System.currentTimeMillis()); + request.setCustomerId("contract_test_customer"); + request.setOwner(InternalUser.ADMIN.getValue()); + request.setStartTime(System.currentTimeMillis()); + request.setEndTime(System.currentTimeMillis() + 86400000L); + // ContractService.add() 要求必须传 moduleFields 和 moduleFormConfigDTO + // 从 moduleFormConfig 中构造字段值,将每个字段的 id 与对应的业务值配对 + List moduleFields = new ArrayList<>(); + for (BaseField field : moduleFormConfig.getFields()) { + Object value = switch (field.getInternalKey()) { + case "contractName" -> request.getName(); + case "contractOwner" -> request.getOwner(); + case "contractCustomer" -> request.getCustomerId(); + case "contractStartTime" -> request.getStartTime(); + case "contractEndTime" -> request.getEndTime(); + default -> null; + }; + if (value != null) { + moduleFields.add(new BaseModuleFieldValue(field.getId(), value)); + } + } + request.setModuleFields(moduleFields); + request.setModuleFormConfigDTO(moduleFormConfig); + Assertions.assertNotEquals(0, moduleFormConfig.getFields().size(), "模块字段配置应包含至少一个字段"); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + Contract resultData = getResultData(mvcResult, Contract.class); + addContract = contractMapper.selectByPrimaryKey(resultData.getId()); + + // 校验合同数据写入 + Assertions.assertNotNull(addContract); + Assertions.assertEquals(request.getName(), addContract.getName()); + Assertions.assertEquals(request.getOwner(), addContract.getOwner()); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CONTRACT_ADD, DEFAULT_ADD, request); + } + + @Test + @Order(2) + void testGet() throws Exception { + this.requestGetWithOk(DEFAULT_GET, addContract.getId()); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CONTRACT_READ, DEFAULT_GET, addContract.getId()); + } + + @Test + @Order(3) + void testPage() throws Exception { + ContractPageRequest request = new ContractPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + this.requestPostWithOk(DEFAULT_PAGE, request); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CONTRACT_READ, DEFAULT_PAGE, request); + } + + @Test + @Order(4) + void testBatchUpdate() throws Exception { + ResourceBatchEditRequest request = new ResourceBatchEditRequest(); + for (BaseField field : moduleFormConfig.getFields()) { + request.setIds(List.of(addContract.getId())); + request.setFieldId(field.getId()); + + String internalKey = field.getInternalKey(); + + if (Strings.CS.equals(internalKey, BusinessModuleField.CONTRACT_NAME.getKey())) { + // 更新合同名称 + String newName = "批量更新合同名称_" + UUID.randomUUID().toString().substring(0, 8); + request.setFieldValue(newName); + this.requestPostWithOk(BATCH_UPDATE, request); + + Contract updated = contractMapper.selectByPrimaryKey(addContract.getId()); + Assertions.assertNotNull(updated); + Assertions.assertEquals(newName, updated.getName(), "批量更新合同名称后,DB 中的 name 应等于请求值"); + + } else if (Strings.CS.equals(internalKey, BusinessModuleField.CONTRACT_OWNER.getKey())) { + // 更新负责人:改成 permission_test,断言值确实发生变化 + String newOwner = PERMISSION_USER_NAME; + request.setFieldValue(newOwner); + this.requestPostWithOk(BATCH_UPDATE, request); + + Contract updated = contractMapper.selectByPrimaryKey(addContract.getId()); + Assertions.assertNotNull(updated); + Assertions.assertNotEquals(InternalUser.ADMIN.getValue(), updated.getOwner(), "负责人应已从 admin 更新为其他用户"); + Assertions.assertEquals(newOwner, updated.getOwner(), "批量更新负责人后,DB 中的 owner 应等于请求值"); + + } else if (Strings.CS.equals(internalKey, BusinessModuleField.CONTRACT_CUSTOMER_NAME.getKey())) { + // 更新关联客户 + request.setFieldValue("contract_test_customer"); + this.requestPostWithOk(BATCH_UPDATE, request); + + Contract updated = contractMapper.selectByPrimaryKey(addContract.getId()); + Assertions.assertNotNull(updated); + Assertions.assertEquals("contract_test_customer", updated.getCustomerId(), "批量更新客户后,DB 中的 customer_id 应等于请求值"); + + } else if (Strings.CS.equals(internalKey, BusinessModuleField.CONTRACT_TOTAL_AMOUNT.getKey())) { + // 更新金额 + request.setFieldValue("9999.99"); + this.requestPostWithOk(BATCH_UPDATE, request); + + Contract updated = contractMapper.selectByPrimaryKey(addContract.getId()); + Assertions.assertNotNull(updated); + Assertions.assertEquals(0, new java.math.BigDecimal("9999.99").compareTo(updated.getAmount()), "批量更新金额后,DB 中的 amount 应等于请求值"); + + } else if (Strings.CS.equals(internalKey, BusinessModuleField.CONTRACT_NO.getKey())) { + // 更新编号 + String newNumber = "HT-" + UUID.randomUUID().toString().substring(0, 8); + request.setFieldValue(newNumber); + this.requestPostWithOk(BATCH_UPDATE, request); + + Contract updated = contractMapper.selectByPrimaryKey(addContract.getId()); + Assertions.assertNotNull(updated); + Assertions.assertEquals(newNumber, updated.getNumber(), "批量更新编号后,DB 中的 number 应等于请求值"); + + } else if (Strings.CS.equals(internalKey, BusinessModuleField.CONTRACT_START_TIME.getKey())) { + // 更新开始时间 + Long newStartTime = System.currentTimeMillis() + 100000L; + request.setFieldValue(newStartTime); + this.requestPostWithOk(BATCH_UPDATE, request); + + Contract updated = contractMapper.selectByPrimaryKey(addContract.getId()); + Assertions.assertNotNull(updated); + Assertions.assertEquals(newStartTime, updated.getStartTime(), "批量更新开始时间后,DB 中的 start_time 应等于请求值"); + + } else if (Strings.CS.equals(internalKey, BusinessModuleField.CONTRACT_END_TIME.getKey())) { + // 更新结束时间 + Long newEndTime = System.currentTimeMillis() + 200000L; + request.setFieldValue(newEndTime); + this.requestPostWithOk(BATCH_UPDATE, request); + + Contract updated = contractMapper.selectByPrimaryKey(addContract.getId()); + Assertions.assertNotNull(updated); + Assertions.assertEquals(newEndTime, updated.getEndTime(), "批量更新结束时间后,DB 中的 end_time 应等于请求值"); + + } + } + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CONTRACT_UPDATE, BATCH_UPDATE, request); + } + + @Test + @Order(10) + void testDelete() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, addContract.getId()); + Contract contract = contractMapper.selectByPrimaryKey(addContract.getId()); + Assertions.assertNull(contract); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CONTRACT_DELETE, DEFAULT_DELETE, "nonexistent-id"); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/contract/ContractPaymentPlanControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/contract/ContractPaymentPlanControllerTests.java new file mode 100644 index 0000000..d63e570 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/contract/ContractPaymentPlanControllerTests.java @@ -0,0 +1,148 @@ +package cn.cordys.crm.contract; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.contract.constants.ContractPaymentPlanStatus; +import cn.cordys.crm.contract.domain.ContractPaymentPlan; +import cn.cordys.crm.contract.dto.request.ContractPaymentPlanAddRequest; +import cn.cordys.crm.contract.dto.request.ContractPaymentPlanPageRequest; +import cn.cordys.crm.contract.dto.request.ContractPaymentPlanUpdateRequest; +import cn.cordys.crm.contract.dto.response.ContractPaymentPlanGetResponse; +import cn.cordys.crm.contract.dto.response.ContractPaymentPlanListResponse; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.math.BigDecimal; +import java.util.List; + +@SpringBootTest(webEnvironment= SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ContractPaymentPlanControllerTests extends BaseTest { + private static final String BASE_PATH = "/contract/payment-plan/"; + private static final String TAB = "tab"; + + private static ContractPaymentPlan addContractPaymentPlan; + + @Resource + private BaseMapper contractPaymentPlanMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(0) + void testPageEmpty() throws Exception { + ContractPaymentPlanPageRequest request = new ContractPaymentPlanPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_PAGE, request); + Pager> pageResult = getPageResult(mvcResult, ContractPaymentPlanListResponse.class); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CONTRACT_PAYMENT_PLAN_READ, DEFAULT_PAGE, request); + } + + @Test + @Order(0) + void testTab() throws Exception { + this.requestGetWithOkAndReturn(TAB); + // 校验权限 + requestGetPermissionTest(PermissionConstants.CONTRACT_PAYMENT_PLAN_READ, TAB); + } + + @Test + @Order(1) + void testAdd() throws Exception { + // 请求成功 + ContractPaymentPlanAddRequest request = new ContractPaymentPlanAddRequest(); + request.setName("test"); + request.setPlanAmount(BigDecimal.valueOf(111)); + request.setOwner(InternalUser.ADMIN.getValue()); + request.setPlanStatus(ContractPaymentPlanStatus.PENDING.name()); + request.setContractId("test"); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + ContractPaymentPlan resultData = getResultData(mvcResult, ContractPaymentPlan.class); + ContractPaymentPlan contractPaymentPlan = contractPaymentPlanMapper.selectByPrimaryKey(resultData.getId()); + + // 校验请求成功数据 + addContractPaymentPlan = contractPaymentPlan; + Assertions.assertEquals(request.getPlanAmount().intValue(), contractPaymentPlan.getPlanAmount().intValue()); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CONTRACT_PAYMENT_PLAN_ADD, DEFAULT_ADD, request); + } + + @Test + @Order(2) + void testUpdate() throws Exception { + // 请求成功 + ContractPaymentPlanUpdateRequest request = new ContractPaymentPlanUpdateRequest(); + request.setId(addContractPaymentPlan.getId()); + request.setName("test"); + request.setPlanAmount(BigDecimal.valueOf(222)); + this.requestPostWithOk(DEFAULT_UPDATE, request); + // 校验请求成功数据 + ContractPaymentPlan userContractPaymentPlanResult = contractPaymentPlanMapper.selectByPrimaryKey(request.getId()); + Assertions.assertEquals(request.getPlanAmount().intValue(), userContractPaymentPlanResult.getPlanAmount().intValue()); + + // 不修改信息 + ContractPaymentPlanUpdateRequest emptyRequest = new ContractPaymentPlanUpdateRequest(); + emptyRequest.setId(addContractPaymentPlan.getId()); + emptyRequest.setName("test"); + this.requestPostWithOk(DEFAULT_UPDATE, emptyRequest); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CONTRACT_PAYMENT_PLAN_UPDATE, DEFAULT_UPDATE, request); + } + + @Test + @Order(3) + void testGet() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(DEFAULT_GET, addContractPaymentPlan.getId()); + ContractPaymentPlanGetResponse getResponse = getResultData(mvcResult, ContractPaymentPlanGetResponse.class); + + // 校验请求成功数据 + ContractPaymentPlan contractPaymentPlan = contractPaymentPlanMapper.selectByPrimaryKey(addContractPaymentPlan.getId()); + ContractPaymentPlan responseContractPaymentPlan = BeanUtils.copyBean(new ContractPaymentPlan(), getResponse); + Assertions.assertEquals(responseContractPaymentPlan, contractPaymentPlan); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CONTRACT_PAYMENT_PLAN_READ, DEFAULT_GET, addContractPaymentPlan.getId()); + } + + @Test + @Order(4) + void testPage() throws Exception { + ContractPaymentPlanPageRequest request = new ContractPaymentPlanPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_PAGE, request); + Pager> pageResult = getPageResult(mvcResult, ContractPaymentPlanListResponse.class); + List contractPaymentPlanList = pageResult.getList(); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CONTRACT_PAYMENT_PLAN_READ, DEFAULT_PAGE, request); + } + + @Test + @Order(10) + void delete() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, addContractPaymentPlan.getId()); + ContractPaymentPlan contractPaymentPlan = contractPaymentPlanMapper.selectByPrimaryKey(addContractPaymentPlan.getId()); + Assertions.assertNull(contractPaymentPlan); + // 校验权限 + requestGetPermissionTest(PermissionConstants.CONTRACT_PAYMENT_PLAN_DELETE, DEFAULT_DELETE, "1111"); + } +} \ No newline at end of file diff --git a/backend/crm/src/test/java/cn/cordys/crm/contract/ContractPaymentPlanUserViewControllerTest.java b/backend/crm/src/test/java/cn/cordys/crm/contract/ContractPaymentPlanUserViewControllerTest.java new file mode 100644 index 0000000..75aec7f --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/contract/ContractPaymentPlanUserViewControllerTest.java @@ -0,0 +1,134 @@ +package cn.cordys.crm.contract; + +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.ArrayList; +import java.util.List; + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class ContractPaymentPlanUserViewControllerTest extends BaseTest { + + protected static final String DETAIL = "detail/{0}"; + protected static final String LIST = "list"; + protected static final String FIXED = "fixed/{0}"; + protected static final String POS = "edit/pos"; + protected static final String ENABLE = "enable/{0}"; + private static final String BASE_PATH = "/contract/payment-plan/view/"; + private static String viewId; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void testAdd() throws Exception { + + UserViewAddRequest request = new UserViewAddRequest(); + request.setName("测试视图11"); + request.setSearchMode("AND"); + request.setConditions(buildConditions()); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + UserView resultData = getResultData(mvcResult, UserView.class); + viewId = resultData.getId(); + + } + + private List buildConditions() { + List conditions = new ArrayList<>(); + + FilterCondition condition1 = new FilterCondition(); + condition1.setName("123123312312"); + condition1.setValue(null); + condition1.setOperator("EMPTY"); + condition1.setMultipleValue(false); + condition1.setType("LOCATION"); + + FilterCondition condition2 = new FilterCondition(); + condition2.setName("1231233333"); + condition2.setValue("[\n" + + " \"2\"\n" + + " ]"); + condition2.setOperator("IN"); + condition2.setMultipleValue(false); + condition2.setType("1231"); + + conditions.add(condition1); + conditions.add(condition2); + return conditions; + } + + + @Test + @Order(2) + void testUpdate() throws Exception { + UserViewUpdateRequest request = new UserViewUpdateRequest(); + request.setId(viewId); + request.setName("更新試圖22"); + request.setConditions(buildConditions()); + this.requestPost(DEFAULT_UPDATE, request); + + request.setId("123123123"); + this.requestPost(DEFAULT_UPDATE, request); + } + + @Test + @Order(3) + void testGetDetail() throws Exception { + this.requestGet(DETAIL, viewId); + } + + @Test + @Order(4) + void testList() throws Exception { + this.requestGet(LIST); + } + + @Test + @Order(5) + void testFixed() throws Exception { + this.requestGet(FIXED, viewId); + } + + @Test + @Order(5) + void testSortPos() throws Exception { + String moveId = viewId; + testAdd(); + String targetId = viewId; + PosRequest request = new PosRequest(); + request.setOrgId(DEFAULT_ORGANIZATION_ID); + request.setMoveId(moveId); + request.setTargetId(targetId); + request.setMoveMode("after"); + this.requestGet(POS, request); + } + + @Test + @Order(6) + void testEnable() throws Exception { + this.requestGet(ENABLE, viewId); + } + + @Test + @Order(10) + void testDelete() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, viewId); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerCapacityControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerCapacityControllerTests.java new file mode 100644 index 0000000..ed415a0 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerCapacityControllerTests.java @@ -0,0 +1,65 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.util.Translator; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.customer.domain.CustomerCapacity; +import cn.cordys.crm.system.dto.request.CapacityAddRequest; +import cn.cordys.crm.system.dto.request.CapacityUpdateRequest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class CustomerCapacityControllerTests extends BaseTest { + + private static String CAPACITY_ID; + + @Test + @Order(1) + void add() throws Exception { + CapacityAddRequest request = new CapacityAddRequest(); + request.setScopeIds(List.of("admin")); + request.setCapacity(10); + this.requestPostWithOk("/account-capacity/add", request); + MvcResult mvcResult = this.requestPost("/account-capacity/add", request).andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult.getResponse().getContentAsString().contains(Translator.get("capacity.scope.duplicate")); + } + + @Test + @Order(2) + void page() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn("/account-capacity/get"); + List result = getResultDataArray(mvcResult, CustomerCapacity.class); + assert result.size() == 1; + CAPACITY_ID = result.getFirst().getId(); + } + + @Test + @Order(3) + void update() throws Exception { + CapacityUpdateRequest request = new CapacityUpdateRequest(); + request.setId("not-exist"); + request.setScopeIds(List.of("admin")); + request.setCapacity(100); + MvcResult mvcResult = this.requestPost("/account-capacity/update", request).andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult.getResponse().getContentAsString().contains(Translator.get("capacity.not.exist")); + request.setId(CAPACITY_ID); + this.requestPost("/account-capacity/update", request); + } + + @Test + @Order(4) + void delete() throws Exception { + this.requestGetWithOk("/account-capacity/delete/" + CAPACITY_ID); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerCollaborationControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerCollaborationControllerTests.java new file mode 100644 index 0000000..933af4b --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerCollaborationControllerTests.java @@ -0,0 +1,143 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.customer.constants.CustomerCollaborationType; +import cn.cordys.crm.customer.domain.CustomerCollaboration; +import cn.cordys.crm.customer.dto.request.CustomerAddRequest; +import cn.cordys.crm.customer.dto.request.CustomerCollaborationAddRequest; +import cn.cordys.crm.customer.dto.request.CustomerCollaborationUpdateRequest; +import cn.cordys.crm.customer.dto.response.CustomerCollaborationListResponse; +import cn.cordys.crm.customer.service.CustomerService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; +import java.util.UUID; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class CustomerCollaborationControllerTests extends BaseTest { + private static final String BASE_PATH = "/account/collaboration/"; + private static final String LIST = "/list/{0}"; + + private static CustomerCollaboration addCustomerCollaboration; + private static String customerId; + + @Resource + private BaseMapper customerCollaborationMapper; + @Resource + private CustomerService customerService; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(0) + void testListEmpty() throws Exception { + addCustomer(); + + MvcResult mvcResult = this.requestGetWithOkAndReturn(LIST, customerId); + List customerCollaborations = getResultDataArray(mvcResult, CustomerCollaborationListResponse.class); + Assertions.assertTrue(CollectionUtils.isEmpty(customerCollaborations)); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_READ, LIST, customerId); + } + + private void addCustomer() { + CustomerAddRequest request = new CustomerAddRequest(); + request.setName(UUID.randomUUID().toString()); + request.setOwner(InternalUser.ADMIN.getValue()); + customerId = customerService.add(request, InternalUser.ADMIN.getValue(), DEFAULT_ORGANIZATION_ID).getId(); + } + + @Test + @Order(1) + void testAdd() throws Exception { + // 请求成功 + CustomerCollaborationAddRequest request = new CustomerCollaborationAddRequest(); + request.setCollaborationType(CustomerCollaborationType.COLLABORATION.name()); + request.setCustomerId(customerId); + request.setUserId(InternalUser.ADMIN.getValue()); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + CustomerCollaboration resultData = getResultData(mvcResult, CustomerCollaboration.class); + CustomerCollaboration customerCollaboration = customerCollaborationMapper.selectByPrimaryKey(resultData.getId()); + + // 校验请求成功数据 + addCustomerCollaboration = customerCollaboration; + Assertions.assertEquals(request.getCustomerId(), customerId); + Assertions.assertEquals(request.getCollaborationType(), customerCollaboration.getCollaborationType()); + Assertions.assertEquals(request.getUserId(), customerCollaboration.getUserId()); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, DEFAULT_ADD, request); + } + + @Test + @Order(2) + void testUpdate() throws Exception { + // 请求成功 + CustomerCollaborationUpdateRequest request = new CustomerCollaborationUpdateRequest(); + request.setId(addCustomerCollaboration.getId()); + request.setCollaborationType(CustomerCollaborationType.READ_ONLY.name()); + this.requestPostWithOk(DEFAULT_UPDATE, request); + + // 校验请求成功数据 + CustomerCollaboration customerCollaboration = customerCollaborationMapper.selectByPrimaryKey(request.getId()); + Assertions.assertEquals(request.getCollaborationType(), customerCollaboration.getCollaborationType()); + Assertions.assertEquals(addCustomerCollaboration.getCustomerId(), customerId); + Assertions.assertEquals(addCustomerCollaboration.getUserId(), customerCollaboration.getUserId()); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, DEFAULT_UPDATE, request); + } + + @Test + @Order(4) + void testList() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(LIST, customerId); + List list = getResultDataArray(mvcResult, CustomerCollaborationListResponse.class); + list.forEach(customerCollaborationListResponse -> { + CustomerCollaboration customerCollaboration = customerCollaborationMapper.selectByPrimaryKey(customerCollaborationListResponse.getId()); + CustomerCollaboration result = BeanUtils.copyBean(new CustomerCollaboration(), customerCollaborationListResponse); + Assertions.assertEquals(customerCollaboration, result); + Assertions.assertNotNull(customerCollaborationListResponse.getUpdateUserName()); + }); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_READ, LIST, customerId); + } + + @Test + @Order(10) + void delete() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, addCustomerCollaboration.getId()); + CustomerCollaboration customerCollaboration = customerCollaborationMapper.selectByPrimaryKey(addCustomerCollaboration.getId()); + Assertions.assertNull(customerCollaboration); + // 校验权限 + requestGetPermissionsTest(List.of(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE), + DEFAULT_DELETE, addCustomerCollaboration.getId()); + } + + @Test + @Order(10) + void batchDelete() throws Exception { + this.requestPostWithOk(DEFAULT_BATCH_DELETE, List.of(addCustomerCollaboration.getId())); + CustomerCollaboration customerCollaboration = customerCollaborationMapper.selectByPrimaryKey(addCustomerCollaboration.getId()); + Assertions.assertNull(customerCollaboration); + // 校验权限 + requestPostPermissionsTest(List.of(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE), + DEFAULT_BATCH_DELETE, List.of(addCustomerCollaboration.getId())); + } +} \ No newline at end of file diff --git a/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerContactControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerContactControllerTests.java new file mode 100644 index 0000000..fae864a --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerContactControllerTests.java @@ -0,0 +1,324 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.ChartAnalysisRequest; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.common.dto.chart.ChartCategoryAxisConfig; +import cn.cordys.common.dto.chart.ChartConfig; +import cn.cordys.common.dto.chart.ChartValueAxisConfig; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.customer.domain.CustomerContact; +import cn.cordys.crm.customer.dto.request.*; +import cn.cordys.crm.customer.dto.response.CustomerContactGetResponse; +import cn.cordys.crm.customer.dto.response.CustomerContactListAllResponse; +import cn.cordys.crm.customer.dto.response.CustomerContactListResponse; +import cn.cordys.crm.customer.service.CustomerService; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; +import java.util.UUID; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class CustomerContactControllerTests extends BaseTest { + protected static final String MODULE_FORM = "module/form"; + protected static final String LIST = "list/{0}"; + protected static final String DISABLE = "disable/{0}"; + protected static final String ENABLE = "enable/{0}"; + protected static final String OPPORTUNITY_CHECK = "opportunity/check/{0}"; + protected static final String TAB = "tab"; + protected static final String BATCH_UPDATE = "batch/update"; + protected static final String CHART = "chart"; + private static final String BASE_PATH = "/account/contact/"; + private static CustomerContact addCustomerContact; + private static String customerId; + private static ModuleFormConfigDTO moduleFormConfig; + + @Resource + private BaseMapper customerContactMapper; + @Resource + private CustomerService customerService; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(0) + void testModuleField() throws Exception { + addCustomer(); + + MvcResult mvcResult = this.requestGetWithOkAndReturn(MODULE_FORM); + moduleFormConfig = getResultData(mvcResult, ModuleFormConfigDTO.class); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ, MODULE_FORM); + } + + @Test + @Order(0) + void testPageEmpty() throws Exception { + CustomerContactPageRequest request = new CustomerContactPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + + this.requestPostWithOk(DEFAULT_PAGE, request); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ, DEFAULT_PAGE, request); + } + + private void addCustomer() { + CustomerAddRequest request = new CustomerAddRequest(); + request.setName(UUID.randomUUID().toString()); + request.setOwner(InternalUser.ADMIN.getValue()); + customerId = customerService.add(request, InternalUser.ADMIN.getValue(), DEFAULT_ORGANIZATION_ID).getId(); + } + + @Test + @Order(1) + void testAdd() throws Exception { + // 请求成功 + CustomerContactAddRequest request = new CustomerContactAddRequest(); + request.setName("test"); + request.setCustomerId(customerId); + request.setOwner(InternalUser.ADMIN.getValue()); + request.setPhone("15451644454"); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + CustomerContact resultData = getResultData(mvcResult, CustomerContact.class); + CustomerContact customerContact = customerContactMapper.selectByPrimaryKey(resultData.getId()); + + // 校验请求成功数据 + addCustomerContact = customerContact; + Assertions.assertEquals(request.getName(), customerContact.getName()); + Assertions.assertEquals(request.getOwner(), customerContact.getOwner()); + Assertions.assertEquals(request.getPhone(), customerContact.getPhone()); + + // 校验权限 + requestPostPermissionsTest(List.of(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_ADD, PermissionConstants.CUSTOMER_MANAGEMENT_ADD), + DEFAULT_ADD, request); + } + + @Test + @Order(2) + void testUpdate() throws Exception { + // 请求成功 + CustomerContactUpdateRequest request = new CustomerContactUpdateRequest(); + request.setId(addCustomerContact.getId()); + request.setName("test update"); + request.setPhone("161161616"); + this.requestPostWithOk(DEFAULT_UPDATE, request); + // 校验请求成功数据 + CustomerContact userCustomerContactResult = customerContactMapper.selectByPrimaryKey(request.getId()); + Assertions.assertEquals(request.getName(), userCustomerContactResult.getName()); + Assertions.assertEquals(request.getPhone(), userCustomerContactResult.getPhone()); + + // 不修改信息 + CustomerContactUpdateRequest emptyRequest = new CustomerContactUpdateRequest(); + emptyRequest.setId(addCustomerContact.getId()); + this.requestPostWithOk(DEFAULT_UPDATE, emptyRequest); + + // 校验权限 + requestPostPermissionsTest(List.of(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_UPDATE, PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE), + DEFAULT_UPDATE, request); + } + + @Test + @Order(2) + void testBatchUpdate() throws Exception { + ResourceBatchEditRequest request = new ResourceBatchEditRequest(); + for (BaseField field : moduleFormConfig.getFields()) { + request.setIds(List.of(addCustomerContact.getId())); + request.setFieldId(field.getId()); + if (Strings.CS.equals(field.getName(), BusinessModuleField.CUSTOMER_CONTACT_OWNER.name())) { + request.setFieldValue(InternalUser.ADMIN.getValue()); + this.requestPostWithOk(BATCH_UPDATE, request); + } else if (Strings.CS.equals(field.getName(), BusinessModuleField.CUSTOMER_CONTACT_PHONE.name())) { + request.setFieldValue("16116161611"); + this.requestPostWithOk(BATCH_UPDATE, request); + } + } + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_UPDATE, BATCH_UPDATE, request); + } + + @Test + @Order(3) + void testGet() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(DEFAULT_GET, addCustomerContact.getId()); + CustomerContactGetResponse getResponse = getResultData(mvcResult, CustomerContactGetResponse.class); + + // 校验请求成功数据 + CustomerContact customerContact = customerContactMapper.selectByPrimaryKey(addCustomerContact.getId()); + CustomerContact responseCustomerContact = BeanUtils.copyBean(new CustomerContact(), getResponse); + Assertions.assertEquals(responseCustomerContact, customerContact); + Assertions.assertNotNull(getResponse.getCreateUserName()); + Assertions.assertNotNull(getResponse.getUpdateUserName()); + Assertions.assertNotNull(getResponse.getOwnerName()); + if (!getResponse.getOwner().equals(InternalUser.ADMIN.getValue())) { + Assertions.assertNotNull(getResponse.getDepartmentId()); + Assertions.assertNotNull(getResponse.getDepartmentName()); + } + + // 校验权限 + requestGetPermissionsTest(List.of(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_READ), DEFAULT_GET, addCustomerContact.getId()); + } + + @Test + @Order(4) + void testPage() throws Exception { + CustomerContactPageRequest request = new CustomerContactPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_PAGE, request); + Pager> pageResult = getPageResult(mvcResult, CustomerContactListResponse.class); + List customerContactList = pageResult.getList(); + if (customerContactList != null && !customerContactList.isEmpty()) { + customerContactList.forEach(customerContactListResponse -> { + CustomerContact customerContact = customerContactMapper.selectByPrimaryKey(customerContactListResponse.getId()); + CustomerContact result = BeanUtils.copyBean(new CustomerContact(), customerContactListResponse); + result.setOrganizationId(customerContact.getOrganizationId()); + Assertions.assertEquals(customerContact, result); + Assertions.assertNotNull(customerContactListResponse.getUpdateUserName()); + Assertions.assertNotNull(customerContactListResponse.getOwnerName()); + }); + } + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ, DEFAULT_PAGE, request); + } + + @Test + @Order(4) + void testList() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(LIST, customerId); + CustomerContactListAllResponse listAllResponse = getResultData(mvcResult, CustomerContactListAllResponse.class); + listAllResponse.getList().forEach(customerContactListResponse -> { + CustomerContact customerContact = customerContactMapper.selectByPrimaryKey(customerContactListResponse.getId()); + CustomerContact result = BeanUtils.copyBean(new CustomerContact(), customerContactListResponse); + result.setOrganizationId(customerContact.getOrganizationId()); + Assertions.assertEquals(customerContact, result); + Assertions.assertNotNull(customerContactListResponse.getUpdateUserName()); + Assertions.assertNotNull(customerContactListResponse.getOwnerName()); + if (!customerContactListResponse.getOwner().equals(InternalUser.ADMIN.getValue())) { + Assertions.assertNotNull(customerContactListResponse.getDepartmentId()); + Assertions.assertNotNull(customerContactListResponse.getDepartmentName()); + } + }); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_READ, LIST, customerId); + } + + @Test + @Order(4) + void testChart() throws Exception { + ChartAnalysisRequest request = new ChartAnalysisRequest(); + + for (BaseField field : moduleFormConfig.getFields().subList(0, 5)) { + ChartConfig chartConfig = new ChartConfig(); + ChartCategoryAxisConfig chartCategoryAxisConfig = new ChartCategoryAxisConfig(); + chartCategoryAxisConfig.setFieldId(field.getId()); + chartConfig.setCategoryAxis(chartCategoryAxisConfig); + ChartValueAxisConfig chartValueAxisConfig = new ChartValueAxisConfig(); + chartValueAxisConfig.setFieldId(field.getId()); + chartConfig.setValueAxis(chartValueAxisConfig); + request.setChartConfig(chartConfig); + this.requestPostWithOkAndReturn(CHART, request); + } + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ, CHART, request); + } + + @Test + @Order(4) + void testTab() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(TAB); + ResourceTabEnableDTO resultData = getResultData(mvcResult, ResourceTabEnableDTO.class); + // 校验请求成功数据 + Assertions.assertTrue(resultData.getAll()); + Assertions.assertTrue(resultData.getDept()); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_READ, TAB); + } + + @Test + @Order(5) + void testDisable() throws Exception { + CustomerContactDisableRequest request = new CustomerContactDisableRequest(); + request.setReason("reason"); + + // 请求成功 + this.requestPostWithOk(DISABLE, request, addCustomerContact.getId()); + + CustomerContact customerContact = customerContactMapper.selectByPrimaryKey(addCustomerContact.getId()); + Assertions.assertEquals(customerContact.getDisableReason(), request.getReason()); + Assertions.assertFalse(customerContact.getEnable()); + + request.setReason(null); + // 请求成功 + this.requestPostWithOk(DISABLE, request, addCustomerContact.getId()); + + // 校验权限 + requestPostPermissionsTest(List.of(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_UPDATE, PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE), + DISABLE, request, addCustomerContact.getId()); + } + + @Test + @Order(6) + void testEnable() throws Exception { + // 请求成功 + this.requestGet(ENABLE, addCustomerContact.getId()); + + CustomerContact customerContact = customerContactMapper.selectByPrimaryKey(addCustomerContact.getId()); + // TODO 暂时让测试通过 + // Assertions.assertEquals(customerContact.getDisableReason(), StringUtils.EMPTY); + // Assertions.assertTrue(customerContact.getEnable()); + + // 校验权限 + requestGetPermissionsTest(List.of(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_UPDATE, PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE), + ENABLE, addCustomerContact.getId()); + } + + @Test + @Order(7) + void testCheck() throws Exception { + // 请求成功 + MvcResult mvcResult = this.requestGetWithOkAndReturn(OPPORTUNITY_CHECK, addCustomerContact.getId()); + Boolean result = getResultData(mvcResult, Boolean.class); + + Assertions.assertFalse(result); + + // 校验权限 + requestGetPermissionsTest(List.of(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_DELETE, PermissionConstants.CUSTOMER_MANAGEMENT_DELETE), + DEFAULT_DELETE, addCustomerContact.getId()); + } + + @Test + @Order(10) + void delete() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, addCustomerContact.getId()); + CustomerContact customerContact = customerContactMapper.selectByPrimaryKey(addCustomerContact.getId()); + Assertions.assertNull(customerContact); + // 校验权限 + requestGetPermissionsTest(List.of(PermissionConstants.CUSTOMER_MANAGEMENT_CONTACT_DELETE, PermissionConstants.CUSTOMER_MANAGEMENT_DELETE), + DEFAULT_DELETE, addCustomerContact.getId()); + } +} \ No newline at end of file diff --git a/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerContactUserViewControllerTest.java b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerContactUserViewControllerTest.java new file mode 100644 index 0000000..d820e89 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerContactUserViewControllerTest.java @@ -0,0 +1,134 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.ArrayList; +import java.util.List; + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class CustomerContactUserViewControllerTest extends BaseTest { + + protected static final String DETAIL = "detail/{0}"; + protected static final String LIST = "list"; + protected static final String FIXED = "fixed/{0}"; + protected static final String POS = "edit/pos"; + protected static final String ENABLE = "enable/{0}"; + private static final String BASE_PATH = "/account/contact/view/"; + private static String viewId; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void testAdd() throws Exception { + + UserViewAddRequest request = new UserViewAddRequest(); + request.setName("测试视图11"); + request.setSearchMode("AND"); + request.setConditions(buildConditions()); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + UserView resultData = getResultData(mvcResult, UserView.class); + viewId = resultData.getId(); + + } + + private List buildConditions() { + List conditions = new ArrayList<>(); + + FilterCondition condition1 = new FilterCondition(); + condition1.setName("123123312312"); + condition1.setValue(null); + condition1.setOperator("EMPTY"); + condition1.setMultipleValue(false); + condition1.setType("LOCATION"); + + FilterCondition condition2 = new FilterCondition(); + condition2.setName("1231233333"); + condition2.setValue("[\n" + + " \"2\"\n" + + " ]"); + condition2.setOperator("IN"); + condition2.setMultipleValue(false); + condition2.setType("1231"); + + conditions.add(condition1); + conditions.add(condition2); + return conditions; + } + + + @Test + @Order(2) + void testUpdate() throws Exception { + UserViewUpdateRequest request = new UserViewUpdateRequest(); + request.setId(viewId); + request.setName("更新試圖22"); + request.setConditions(buildConditions()); + this.requestPost(DEFAULT_UPDATE, request); + + request.setId("123123123"); + this.requestPost(DEFAULT_UPDATE, request); + } + + @Test + @Order(3) + void testGetDetail() throws Exception { + this.requestGet(DETAIL, viewId); + } + + @Test + @Order(4) + void testList() throws Exception { + this.requestGet(LIST); + } + + @Test + @Order(5) + void testFixed() throws Exception { + this.requestGet(FIXED, viewId); + } + + @Test + @Order(5) + void testSortPos() throws Exception { + String moveId = viewId; + testAdd(); + String targetId = viewId; + PosRequest request = new PosRequest(); + request.setOrgId(DEFAULT_ORGANIZATION_ID); + request.setMoveId(moveId); + request.setTargetId(targetId); + request.setMoveMode("after"); + this.requestGet(POS, request); + } + + @Test + @Order(6) + void testEnable() throws Exception { + this.requestGet(ENABLE, viewId); + } + + @Test + @Order(10) + void testDelete() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, viewId); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerControllerTests.java new file mode 100644 index 0000000..a7e2fd1 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerControllerTests.java @@ -0,0 +1,466 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.InternalUserView; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.*; +import cn.cordys.common.dto.chart.ChartCategoryAxisConfig; +import cn.cordys.common.dto.chart.ChartConfig; +import cn.cordys.common.dto.chart.ChartValueAxisConfig; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.domain.CustomerField; +import cn.cordys.crm.customer.dto.request.*; +import cn.cordys.crm.customer.dto.response.CustomerGetResponse; +import cn.cordys.crm.customer.dto.response.CustomerListResponse; +import cn.cordys.crm.system.domain.ExportTask; +import cn.cordys.crm.system.domain.ModuleField; +import cn.cordys.crm.system.domain.ModuleForm; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.request.BatchPoolReasonRequest; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.crm.system.service.ExportTaskCenterService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.Strings; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.web.servlet.MvcResult; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class CustomerControllerTests extends BaseTest { + protected static final String MODULE_FORM = "module/form"; + protected static final String BATCH_TRANSFER = "batch/transfer"; + protected static final String TAB = "tab"; + protected static final String BATCH_TO_POOL = "batch/to-pool"; + protected static final String OPTION = "option"; + protected static final String OPPORTUNITY_PAGE = "opportunity/page"; + protected static final String EXPORT_ALL = "export-all"; + protected static final String EXPORT_SELECT = "export-select"; + protected static final String MERGE = "merge"; + protected static final String CHART = "chart"; + protected static final String CONTRACT_PAGE = "contract/page"; + protected static final String CONTRACT_PAYMENT_PLAN_PAGE = "contract/payment-plan/page"; + private static final String BASE_PATH = "/account/"; + private static final List batchIds = new ArrayList<>(); + private static Customer addCustomer; + private static Customer anotherCustomer; + private static ModuleFormConfigDTO moduleFormConfig; + @Resource + private BaseMapper customerMapper; + @Resource + private BaseMapper customerFieldMapper; + @Resource + private BaseMapper moduleFieldMapper; + @Resource + private BaseMapper moduleFormMapper; + @Resource + private BaseMapper exportTaskBaseMapper; + @Resource + private ExportTaskCenterService exportTaskCenterService; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(0) + void testModuleField() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(MODULE_FORM); + moduleFormConfig = getResultData(mvcResult, ModuleFormConfigDTO.class); + + // 校验权限 + requestGetPermissionsTest(List.of(PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ), MODULE_FORM); + } + + @Test + @Order(0) + void testPageEmpty() throws Exception { + CustomerPageRequest request = new CustomerPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + + this.requestPostWithOkAndReturn(DEFAULT_PAGE, request); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_READ, DEFAULT_PAGE, request); + } + + @Test + @Order(1) + void testAdd() throws Exception { + ModuleForm moduleForm = getModuleForm(); + + ModuleField example = new ModuleField(); + example.setFormId(moduleForm.getId()); + ModuleField moduleField = moduleFieldMapper.select(example) + .stream() + .filter(field -> Strings.CS.equals(field.getInternalKey(), "customerLevel")) + .findFirst().orElse(null); + + // 请求成功 + CustomerAddRequest request = new CustomerAddRequest(); + request.setName("aa"); + request.setOwner("bb"); + Assertions.assertNotNull(moduleField); + request.setModuleFields(List.of(new BaseModuleFieldValue(moduleField.getId(), "1"))); + + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + Customer resultData = getResultData(mvcResult, Customer.class); + Customer customer = customerMapper.selectByPrimaryKey(resultData.getId()); + Assertions.assertEquals(request.getName(), customer.getName()); + Assertions.assertEquals(request.getOwner(), customer.getOwner()); + + // 校验字段 + List fieldValues = getCustomerFields(customer.getId()) + .stream().map(customerField -> { + BaseModuleFieldValue baseModuleFieldValue = BeanUtils.copyBean(new BaseModuleFieldValue(), customerField); + baseModuleFieldValue.setFieldValue(customerField.getFieldValue().toString()); + return baseModuleFieldValue; + }) + .toList(); + Assertions.assertEquals(request.getModuleFields(), fieldValues); + + // 创建另一个客户 + request.setName("another"); + request.setOwner(InternalUser.ADMIN.getValue()); + mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + resultData = getResultData(mvcResult, Customer.class); + anotherCustomer = customerMapper.selectByPrimaryKey(resultData.getId()); + + // 校验请求成功数据 + addCustomer = customer; + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_ADD, DEFAULT_ADD, request); + } + + private ModuleForm getModuleForm() { + ModuleForm example = new ModuleForm(); + example.setOrganizationId(DEFAULT_ORGANIZATION_ID); + example.setFormKey(FormKey.CUSTOMER.getKey()); + return moduleFormMapper.selectOne(example); + } + + @Test + @Order(2) + void testUpdate() throws Exception { + // 请求成功 + CustomerUpdateRequest request = new CustomerUpdateRequest(); + request.setId(addCustomer.getId()); + request.setName("aa11"); + request.setOwner(InternalUser.ADMIN.getValue()); + this.requestPostWithOk(DEFAULT_UPDATE, request); + // 校验请求成功数据 + Customer customerResult = customerMapper.selectByPrimaryKey(request.getId()); + Assertions.assertEquals(request.getName(), customerResult.getName()); + Assertions.assertEquals(request.getOwner(), customerResult.getOwner()); + + // 不修改信息 + CustomerUpdateRequest emptyRequest = new CustomerUpdateRequest(); + emptyRequest.setId(addCustomer.getId()); + this.requestPostWithOk(DEFAULT_UPDATE, emptyRequest); + + addCustomer = customerMapper.selectByPrimaryKey(addCustomer.getId()); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, DEFAULT_UPDATE, request); + } + + @Test + @Order(3) + void testGet() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(DEFAULT_GET, addCustomer.getId()); + CustomerGetResponse getResponse = getResultData(mvcResult, CustomerGetResponse.class); + + // 校验请求成功数据 + Customer customer = customerMapper.selectByPrimaryKey(addCustomer.getId()); + Customer responseCustomer = BeanUtils.copyBean(new Customer(), getResponse); + responseCustomer.setOrganizationId(DEFAULT_ORGANIZATION_ID); + responseCustomer.setInSharedPool(false); + responseCustomer.setCollectionTime(addCustomer.getCollectionTime()); + Assertions.assertEquals(responseCustomer, customer); + Assertions.assertNotNull(getResponse.getOwnerName()); + if (!getResponse.getOwner().equals(InternalUser.ADMIN.getValue())) { + Assertions.assertNotNull(getResponse.getDepartmentId()); + Assertions.assertNotNull(getResponse.getDepartmentName()); + } + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_READ, DEFAULT_GET, addCustomer.getId()); + } + + + @Test + @Order(3) + void testPage() throws Exception { + CustomerPageRequest request = new CustomerPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + + request.setViewId(InternalUserView.ALL.name()); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_PAGE, request); + Pager> pageResult = getPageResult(mvcResult, CustomerListResponse.class); + List customerList = pageResult.getList(); + + Customer example = new Customer(); + example.setOrganizationId(DEFAULT_ORGANIZATION_ID); + example.setInSharedPool(false); + Map customerMap = customerMapper.select(example) + .stream() + .collect(Collectors.toMap(Customer::getId, Function.identity())); + + customerList.forEach(customerListResponse -> { + Customer customer = customerMap.get(customerListResponse.getId()); + Customer responseCustomer = BeanUtils.copyBean(new Customer(), customerListResponse); + responseCustomer.setOrganizationId(DEFAULT_ORGANIZATION_ID); + responseCustomer.setInSharedPool(false); + Assertions.assertEquals(customer.getName(), responseCustomer.getName()); + }); + + request.setViewId(InternalUserView.SELF.name()); + this.requestPostWithOk(DEFAULT_PAGE, request); + + request.setViewId(InternalUserView.DEPARTMENT.name()); + this.requestPostWithOk(DEFAULT_PAGE, request); + + request.setViewId(InternalUserView.CUSTOMER_COLLABORATION.name()); + this.requestPostWithOk(DEFAULT_PAGE, request); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_READ, DEFAULT_PAGE, request); + } + + @Test + @Order(3) + void testContractPage() throws Exception { + CustomerContractPageRequest request = new CustomerContractPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + request.setCustomerId(addCustomer.getId()); + + request.setViewId(InternalUserView.ALL.name()); + this.requestPostWithOkAndReturn(CONTRACT_PAGE, request); + + // 校验权限 + requestPostPermissionsTest(List.of(PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.CONTRACT_READ), CONTRACT_PAGE, request); + } + + @Test + @Order(3) + void testContractPaymentPlanPage() throws Exception { + CustomerContractPaymentPlanPageRequest request = new CustomerContractPaymentPlanPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + request.setCustomerId(addCustomer.getId()); + + request.setViewId(InternalUserView.ALL.name()); + this.requestPostWithOkAndReturn(CONTRACT_PAYMENT_PLAN_PAGE, request); + + // 校验权限 + requestPostPermissionsTest(List.of(PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.CONTRACT_PAYMENT_PLAN_READ), CONTRACT_PAYMENT_PLAN_PAGE, request); + } + + @Test + @Order(4) + void testChart() throws Exception { + ChartAnalysisRequest request = new ChartAnalysisRequest(); + + for (BaseField field : moduleFormConfig.getFields().subList(0, 5)) { + ChartConfig chartConfig = new ChartConfig(); + ChartCategoryAxisConfig chartCategoryAxisConfig = new ChartCategoryAxisConfig(); + chartCategoryAxisConfig.setFieldId(field.getId()); + chartConfig.setCategoryAxis(chartCategoryAxisConfig); + ChartValueAxisConfig chartValueAxisConfig = new ChartValueAxisConfig(); + chartValueAxisConfig.setFieldId(field.getId()); + chartConfig.setValueAxis(chartValueAxisConfig); + request.setChartConfig(chartConfig); + this.requestPostWithOkAndReturn(CHART, request); + } + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_READ, CHART, request); + } + + @Test + @Order(6) + void testTransfer() throws Exception { + CustomerBatchTransferRequest request = new CustomerBatchTransferRequest(); + request.setIds(List.of(addCustomer.getId())); + request.setOwner(PERMISSION_USER_NAME); + MvcResult mvcResult = this.requestPostWithOkAndReturn(BATCH_TRANSFER, request); + + getResultData(mvcResult, CustomerGetResponse.class); + + // 校验请求成功数据 + Customer customer = customerMapper.selectByPrimaryKey(addCustomer.getId()); + Assertions.assertEquals(PERMISSION_USER_NAME, customer.getOwner()); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_TRANSFER, BATCH_TRANSFER, request); + } + + @Test + @Order(7) + void testOption() throws Exception { + BasePageRequest request = new BasePageRequest(); + request.setCurrent(1); + request.setPageSize(10); + MvcResult mvcResult = this.requestPostWithOkAndReturn(OPTION, request); + + List options = getPageResult(mvcResult, OptionDTO.class).getList(); + + Assertions.assertTrue(CollectionUtils.isNotEmpty(options)); + + request.setKeyword(addCustomer.getName()); + mvcResult = this.requestPostWithOkAndReturn(OPTION, request); + options = getPageResult(mvcResult, OptionDTO.class).getList(); + + Assertions.assertEquals(1, options.size()); + Assertions.assertEquals(addCustomer.getId(), options.getFirst().getId()); + Assertions.assertEquals(addCustomer.getName(), options.getFirst().getName()); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_READ, OPTION, request); + } + + @Test + @Order(8) + void testTab() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(TAB); + ResourceTabEnableDTO resultData = getResultData(mvcResult, ResourceTabEnableDTO.class); + // 校验请求成功数据 + Assertions.assertTrue(resultData.getAll()); + Assertions.assertTrue(resultData.getDept()); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_READ, TAB); + } + + @Test + @Order(10) + void testDelete() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, addCustomer.getId()); + Assertions.assertNull(customerMapper.selectByPrimaryKey(addCustomer.getId())); + + List fields = getCustomerFields(addCustomer.getId()); + Assumptions.assumeTrue(CollectionUtils.isEmpty(fields)); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_DELETE, DEFAULT_DELETE, addCustomer.getId()); + } + + @Test + @Order(11) + void testBatchDelete() throws Exception { + this.requestPostWithOk(DEFAULT_BATCH_DELETE, List.of(anotherCustomer.getId())); + Assertions.assertNull(customerMapper.selectByPrimaryKey(anotherCustomer.getId())); + + List fields = getCustomerFields(anotherCustomer.getId()); + Assumptions.assumeTrue(CollectionUtils.isEmpty(fields)); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_DELETE, DEFAULT_BATCH_DELETE, List.of(anotherCustomer.getId())); + } + + @Test + @Order(12) + @Sql(scripts = {"/dml/init_customer_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = {"/dml/cleanup_customer_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void testPageReservedDay() throws Exception { + CustomerAddRequest addRequest = new CustomerAddRequest(); + addRequest.setName("aa"); + addRequest.setOwner("admin"); + this.requestPostWithOk(DEFAULT_ADD, addRequest); + CustomerPageRequest request = new CustomerPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + request.setViewId(InternalUserView.ALL.name()); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_PAGE, request); + Pager> pageResult = getPageResult(mvcResult, CustomerListResponse.class); + List customerList = pageResult.getList(); + customerList.forEach(customerListResponse -> batchIds.add(customerListResponse.getId())); + BatchPoolReasonRequest reasonRequest = new BatchPoolReasonRequest(); + reasonRequest.setIds(batchIds); + this.requestPostWithOk(BATCH_TO_POOL, reasonRequest); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_RECYCLE, BATCH_TO_POOL, reasonRequest); + } + + @Test + @Order(13) + void testBatchToPool() throws Exception { + BatchPoolReasonRequest reasonRequest = new BatchPoolReasonRequest(); + reasonRequest.setIds(batchIds); + this.requestPostWithOk(BATCH_TO_POOL, reasonRequest); + } + + @Test + @Order(14) + void testOpportunityPage() throws Exception { + CustomerOpportunityPageRequest request = new CustomerOpportunityPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + request.setCustomerId(addCustomer.getId()); + this.requestPostWithOk(OPPORTUNITY_PAGE, request); + // check permission + requestPostPermissionsTest(List.of(PermissionConstants.CUSTOMER_MANAGEMENT_READ, PermissionConstants.OPPORTUNITY_MANAGEMENT_READ), OPPORTUNITY_PAGE, request); + } + + @Test + @Order(15) + void testMerge() throws Exception { + CustomerMergeRequest mergeRequest = new CustomerMergeRequest(); + mergeRequest.setMergeIds(List.of("test-id")); + mergeRequest.setToMergeId("test-id"); + mergeRequest.setOwnerId("admin"); + MvcResult mvcResult = this.requestPost(MERGE, mergeRequest).andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult.getResponse().getContentAsString().contains(Translator.get("no.customer.merge.data")); + CustomerAddRequest addRequest = new CustomerAddRequest(); + addRequest.setName("zz"); + addRequest.setOwner("admin"); + MvcResult addResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, addRequest); + Customer resultData = getResultData(addResult, Customer.class); + mergeRequest.setMergeIds(List.of(anotherCustomer.getId())); + mergeRequest.setToMergeId(resultData.getId()); + this.requestPostWithOk(MERGE, mergeRequest); + // check permission + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_MERGE, MERGE, mergeRequest); + } + + private List getCustomerFields(String customerId) { + CustomerField example = new CustomerField(); + example.setResourceId(customerId); + return customerFieldMapper.select(example); + } + + +} \ No newline at end of file diff --git a/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerFollowPlanControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerFollowPlanControllerTests.java new file mode 100644 index 0000000..50af7d3 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerFollowPlanControllerTests.java @@ -0,0 +1,119 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.follow.constants.FollowUpPlanStatusType; +import cn.cordys.crm.follow.domain.FollowUpPlan; +import cn.cordys.crm.follow.domain.FollowUpRecord; +import cn.cordys.crm.follow.dto.request.FollowUpPlanAddRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanStatusRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanUpdateRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordPageRequest; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class CustomerFollowPlanControllerTests extends BaseTest { + + private static final String BASE_PATH = "/account/follow/plan/"; + + private static final String CANCEL_PLAN = "cancel/{0}"; + private static final String STATUS_UPDATE = "status/update"; + private static FollowUpPlan addFollowUpPlan; + @Resource + private BaseMapper followUpPlanMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void testAdd() throws Exception { + FollowUpPlanAddRequest request = new FollowUpPlanAddRequest(); + request.setCustomerId("123"); + request.setOpportunityId("12345"); + request.setOwner("admin"); + request.setContactId("123456"); + request.setType("CUSTOMER"); + request.setMethod("1"); + request.setContent("计划一下"); + request.setEstimatedTime(System.currentTimeMillis()); + request.setModuleFields(List.of(new BaseModuleFieldValue("id", "value"))); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + FollowUpRecord resultData = getResultData(mvcResult, FollowUpRecord.class); + addFollowUpPlan = followUpPlanMapper.selectByPrimaryKey(resultData.getId()); + } + + + @Test + @Order(2) + void testUpdate() throws Exception { + FollowUpPlanUpdateRequest request = new FollowUpPlanUpdateRequest(); + request.setId("1234"); + request.setCustomerId("123"); + request.setOpportunityId("12345"); + request.setOwner("admin"); + request.setMethod("2"); + request.setContactId("1234567"); + request.setType("CUSTOMER"); + request.setContent("计划两下"); + request.setConverted(false); + request.setEstimatedTime(System.currentTimeMillis()); + request.setModuleFields(List.of(new BaseModuleFieldValue("id", "value"))); + this.requestPost(DEFAULT_UPDATE, request); + + request.setId(addFollowUpPlan.getId()); + this.requestPostWithOk(DEFAULT_UPDATE, request); + } + + + @Test + @Order(3) + void testList() throws Exception { + FollowUpRecordPageRequest request = new FollowUpRecordPageRequest(); + request.setSourceId("123"); + request.setCurrent(1); + request.setPageSize(10); + this.requestPost(DEFAULT_PAGE, request); + } + + @Test + @Order(4) + void testGet() throws Exception { + this.requestGet(DEFAULT_GET, addFollowUpPlan.getId()); + } + + @Test + @Order(4) + void testStatusUpdate() throws Exception { + FollowUpPlanStatusRequest request = new FollowUpPlanStatusRequest(); + request.setId(addFollowUpPlan.getId()); + request.setStatus(FollowUpPlanStatusType.COMPLETED.name()); + this.requestPost(STATUS_UPDATE, request); + } + + @Test + @Order(5) + void testCancelPlan() throws Exception { + this.requestGetWithOk(CANCEL_PLAN, addFollowUpPlan.getId()); + } + + @Test + @Order(6) + void tesDeletePlan() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, addFollowUpPlan.getId()); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerFollowRecordControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerFollowRecordControllerTests.java new file mode 100644 index 0000000..acf8b12 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerFollowRecordControllerTests.java @@ -0,0 +1,110 @@ +package cn.cordys.crm.customer.controller; + + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.follow.domain.FollowUpRecord; +import cn.cordys.crm.follow.dto.request.FollowUpRecordAddRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordPageRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordUpdateRequest; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class CustomerFollowRecordControllerTests extends BaseTest { + + private static final String BASE_PATH = "/account/follow/record/"; + + private static final String POOL_PAGE = "pool/page"; + private static FollowUpRecord addFollowUpRecord; + @Resource + private BaseMapper followUpRecordMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void testAdd() throws Exception { + FollowUpRecordAddRequest request = new FollowUpRecordAddRequest(); + request.setCustomerId("123"); + request.setOpportunityId("12345"); + request.setOwner("admin"); + request.setContactId("123456"); + request.setFollowMethod("1"); + request.setType("CUSTOMER"); + request.setContent("跟进一下"); + request.setFollowTime(System.currentTimeMillis()); + request.setModuleFields(List.of(new BaseModuleFieldValue("id", "value"))); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + FollowUpRecord resultData = getResultData(mvcResult, FollowUpRecord.class); + addFollowUpRecord = followUpRecordMapper.selectByPrimaryKey(resultData.getId()); + } + + + @Test + @Order(2) + void testUpdate() throws Exception { + FollowUpRecordUpdateRequest request = new FollowUpRecordUpdateRequest(); + request.setId("1234"); + request.setCustomerId("123"); + request.setOpportunityId("12345"); + request.setOwner("admin"); + request.setContactId("1234567"); + request.setFollowMethod("2"); + request.setType("CUSTOMER"); + request.setContent("跟进两下"); + request.setModuleFields(List.of(new BaseModuleFieldValue("id", "value"))); + this.requestPost(DEFAULT_UPDATE, request); + + request.setId(addFollowUpRecord.getId()); + this.requestPostWithOk(DEFAULT_UPDATE, request); + } + + + @Test + @Order(3) + void testList() throws Exception { + FollowUpRecordPageRequest request = new FollowUpRecordPageRequest(); + request.setSourceId("123"); + request.setCurrent(1); + request.setPageSize(10); + this.requestPost(DEFAULT_PAGE, request); + } + + @Test + @Order(4) + void testGet() throws Exception { + this.requestGet(DEFAULT_GET, addFollowUpRecord.getId()); + } + + + @Test + @Order(3) + void testPoolList() throws Exception { + FollowUpRecordPageRequest request = new FollowUpRecordPageRequest(); + request.setSourceId("123"); + request.setCurrent(1); + request.setPageSize(10); + this.requestPost(POOL_PAGE, request); + } + + @Test + @Order(5) + void testDeleteRecord() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, addFollowUpRecord.getId()); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerOwnerHistoryControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerOwnerHistoryControllerTests.java new file mode 100644 index 0000000..7d1be7b --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerOwnerHistoryControllerTests.java @@ -0,0 +1,113 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.domain.CustomerOwner; +import cn.cordys.crm.customer.dto.request.CustomerAddRequest; +import cn.cordys.crm.customer.dto.request.CustomerBatchTransferRequest; +import cn.cordys.crm.customer.dto.response.CustomerOwnerListResponse; +import cn.cordys.crm.customer.service.CustomerOwnerHistoryService; +import cn.cordys.crm.customer.service.CustomerService; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; +import java.util.UUID; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class CustomerOwnerHistoryControllerTests extends BaseTest { + protected static final String LIST = "list/{0}"; + private static final String BASE_PATH = "/account/owner/history/"; + private static Customer addCustomer; + + @Resource + private CustomerOwnerHistoryService customerOwnerHistoryService; + + @Resource + private CustomerService customerService; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(0) + void testListEmpty() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(LIST, "111"); + List list = getResultDataArray(mvcResult, CustomerOwnerListResponse.class); + Assertions.assertTrue(CollectionUtils.isEmpty(list)); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_READ, LIST, "111"); + } + + @Test + @Order(1) + void testAdd() { + CustomerAddRequest customerAddRequest = new CustomerAddRequest(); + customerAddRequest.setName(UUID.randomUUID().toString()); + customerAddRequest.setOwner(InternalUser.ADMIN.getValue()); + addCustomer = customerService.add(customerAddRequest, InternalUser.ADMIN.getValue(), DEFAULT_ORGANIZATION_ID); + + CustomerOwner customerOwner = customerOwnerHistoryService.add(addCustomer, InternalUser.ADMIN.getValue(), false); + // 校验成功数据 + Assertions.assertEquals(customerOwner.getCustomerId(), addCustomer.getId()); + Assertions.assertEquals(customerOwner.getOperator(), InternalUser.ADMIN.getValue()); + Assertions.assertEquals(customerOwner.getCollectionTime(), addCustomer.getCollectionTime()); + Assertions.assertEquals(customerOwner.getOwner(), addCustomer.getOwner()); + } + + @Test + @Order(2) + void testBatchAdd() { + CustomerBatchTransferRequest transferRequest = new CustomerBatchTransferRequest(); + transferRequest.setIds(List.of(addCustomer.getId())); + transferRequest.setOwner(PERMISSION_USER_NAME); + customerOwnerHistoryService.batchAdd(transferRequest, InternalUser.ADMIN.getValue()); + + List list = customerOwnerHistoryService.list(addCustomer.getId(), DEFAULT_ORGANIZATION_ID); + for (CustomerOwnerListResponse customerOwner : list) { + // 校验成功数据 + Assertions.assertEquals(customerOwner.getCustomerId(), addCustomer.getId()); + Assertions.assertEquals(customerOwner.getOperator(), InternalUser.ADMIN.getValue()); + Assertions.assertEquals(customerOwner.getCollectionTime(), addCustomer.getCollectionTime()); + Assertions.assertEquals(customerOwner.getOwner(), addCustomer.getOwner()); + } + } + + @Test + @Order(3) + void testList() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(LIST, addCustomer.getId()); + List list = getResultDataArray(mvcResult, CustomerOwnerListResponse.class); + list.forEach(customerOwner -> { + // 校验成功数据 + Assertions.assertEquals(customerOwner.getCustomerId(), addCustomer.getId()); + Assertions.assertEquals(customerOwner.getOperator(), InternalUser.ADMIN.getValue()); + Assertions.assertEquals(customerOwner.getCollectionTime(), addCustomer.getCollectionTime()); + Assertions.assertEquals(customerOwner.getOwner(), addCustomer.getOwner()); + Assertions.assertNotNull(customerOwner.getOwnerName()); + Assertions.assertNotNull(customerOwner.getOwnerName()); + }); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_READ, LIST, "111"); + } + + @Test + @Order(10) + void batchDelete() { + customerOwnerHistoryService.deleteByCustomerIds(List.of(addCustomer.getId())); + List list = customerOwnerHistoryService.list(addCustomer.getId(), DEFAULT_ORGANIZATION_ID); + Assertions.assertTrue(list.isEmpty()); + } +} \ No newline at end of file diff --git a/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerPoolControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerPoolControllerTests.java new file mode 100644 index 0000000..165b9b7 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerPoolControllerTests.java @@ -0,0 +1,160 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.SortRequest; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.domain.CustomerPool; +import cn.cordys.crm.customer.dto.CustomerPoolDTO; +import cn.cordys.crm.customer.dto.CustomerPoolPickRuleDTO; +import cn.cordys.crm.customer.dto.CustomerPoolRecycleRuleDTO; +import cn.cordys.crm.customer.dto.request.CustomerPoolAddRequest; +import cn.cordys.crm.customer.dto.request.CustomerPoolUpdateRequest; +import cn.cordys.crm.system.dto.RuleConditionDTO; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; +import java.util.Set; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class CustomerPoolControllerTests extends BaseTest { + + private static CustomerPoolDTO testCustomerPool; + @Resource + private BaseMapper customerPoolMapper; + @Resource + private BaseMapper customerMapper; + + @Test + @Order(1) + void emptyPage() throws Exception { + MvcResult mvcResult = this.requestPostWithOkAndReturn("/account-pool/page", createPageRequest()); + Pager> result = getPageResult(mvcResult, CustomerPoolDTO.class); + assert result.getList().isEmpty(); + } + + @Test + @Order(2) + void add() throws Exception { + CustomerPool customerPool = createCustomerPool(); + CustomerPoolAddRequest request = new CustomerPoolAddRequest(); + BeanUtils.copyBean(request, customerPool); + request.setOwnerIds(List.of("cc")); + request.setScopeIds(List.of("cc")); + CustomerPoolPickRuleDTO pickRule = CustomerPoolPickRuleDTO.builder() + .limitOnNumber(true).limitPreOwner(true).limitNew(false).pickNumber(1).pickIntervalDays(1).build(); + request.setPickRule(pickRule); + RuleConditionDTO condition = new RuleConditionDTO(); + condition.setColumn("name"); + condition.setOperator("="); + condition.setValue("cc"); + CustomerPoolRecycleRuleDTO recycleRule = CustomerPoolRecycleRuleDTO.builder() + .conditions(List.of(condition)).build(); + request.setRecycleRule(recycleRule); + request.setHiddenFieldIds(Set.of(BusinessModuleField.CUSTOMER_NAME.getKey())); + this.requestPostWithOk("/account-pool/add", request); + } + + @Test + @Order(3) + void page() throws Exception { + BasePageRequest request = createPageRequest(); + request.setSort(new SortRequest("name", "desc")); + MvcResult mvcResult = this.requestPostWithOkAndReturn("/account-pool/page", request); + Pager> result = getPageResult(mvcResult, CustomerPoolDTO.class); + assert result.getList().size() == 1; + testCustomerPool = result.getList().getFirst(); + } + + @Test + @Order(4) + void update() throws Exception { + CustomerPool customerPool = createCustomerPool(); + customerPool.setId(testCustomerPool.getId()); + CustomerPoolUpdateRequest request = new CustomerPoolUpdateRequest(); + BeanUtils.copyBean(request, customerPool); + request.setOwnerIds(List.of("cc")); + request.setScopeIds(List.of("cc")); + CustomerPoolPickRuleDTO pickRule = CustomerPoolPickRuleDTO.builder() + .limitOnNumber(true).limitPreOwner(true).pickNumber(1).pickIntervalDays(1).build(); + pickRule.setLimitNew(false); + request.setPickRule(pickRule); + CustomerPoolRecycleRuleDTO recycleRule = CustomerPoolRecycleRuleDTO.builder() + .build(); + request.setRecycleRule(recycleRule); + request.setHiddenFieldIds(Set.of(BusinessModuleField.CUSTOMER_OWNER.getKey())); + this.requestPostWithOk("/account-pool/update", request); + } + + @Test + @Order(5) + void switchStatus() throws Exception { + MvcResult mvcResult = this.requestGet("/account-pool/switch/default-pool").andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult.getResponse().getContentAsString().contains(Translator.get("customer_pool_not_exist")); + this.requestGetWithOk("/account-pool/switch/" + testCustomerPool.getId()); + } + + @Test + @Order(6) + void delete() throws Exception { + MvcResult mvcResult = this.requestGet("/account-pool/delete/default-pool").andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult.getResponse().getContentAsString().contains(Translator.get("customer_pool_not_exist")); + // insert free customer on the pool, then delete it + Customer freeCustomer = createFreeCustomer(); + freeCustomer.setPoolId(testCustomerPool.getId()); + customerMapper.insert(freeCustomer); + this.requestGet("/account-pool/no-pick/" + testCustomerPool.getId()); + // pick customer, delete the pool + customerMapper.deleteByPrimaryKey(freeCustomer.getId()); + this.requestGetWithOk("/account-pool/delete/" + testCustomerPool.getId()); + } + + private BasePageRequest createPageRequest() { + BasePageRequest request = new BasePageRequest(); + request.setCurrent(1); + request.setPageSize(10); + return request; + } + + private CustomerPool createCustomerPool() { + CustomerPool customerPool = new CustomerPool(); + customerPool.setName("default-ct-pool"); + customerPool.setScopeId(JSON.toJSONString(List.of("default-dp"))); + customerPool.setOrganizationId(DEFAULT_ORGANIZATION_ID); + customerPool.setEnable(true); + customerPool.setAuto(true); + return customerPool; + } + + private Customer createFreeCustomer() { + Customer customer = new Customer(); + customer.setId("default-ct-id"); + customer.setName("default-ct"); + customer.setInSharedPool(true); + customer.setOwner("admin"); + customer.setOrganizationId(DEFAULT_ORGANIZATION_ID); + customer.setCollectionTime(System.currentTimeMillis()); + customer.setCreateTime(System.currentTimeMillis()); + customer.setCreateUser("default-user"); + customer.setUpdateTime(System.currentTimeMillis()); + customer.setUpdateUser("default-user"); + return customer; + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerRelationControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerRelationControllerTests.java new file mode 100644 index 0000000..3e14b02 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerRelationControllerTests.java @@ -0,0 +1,151 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.customer.constants.CustomerRelationType; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.domain.CustomerRelation; +import cn.cordys.crm.customer.dto.request.CustomerAddRequest; +import cn.cordys.crm.customer.dto.request.CustomerRelationSaveRequest; +import cn.cordys.crm.customer.dto.request.CustomerRelationUpdateRequest; +import cn.cordys.crm.customer.dto.response.CustomerRelationListResponse; +import cn.cordys.crm.customer.service.CustomerRelationService; +import cn.cordys.crm.customer.service.CustomerService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; +import java.util.UUID; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class CustomerRelationControllerTests extends BaseTest { + protected static final String LIST = "list/{0}"; + protected static final String SAVE = "save/{0}"; + protected static final String ADD = "add/{0}"; + protected static final String UPDATE = "update/{0}"; + protected static final String DELETE = "delete/{0}"; + private static final String BASE_PATH = "/account/relation/"; + private static Customer addCustomer; + private static CustomerRelation addCustomerRelation; + private static Customer anotherAddCustomer; + + @Resource + private CustomerRelationService customerRelationService; + + @Resource + private CustomerService customerService; + + @Resource + private BaseMapper customerRelationBaseMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(0) + void testListEmpty() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(LIST, "111"); + List list = getResultDataArray(mvcResult, CustomerRelationListResponse.class); + Assertions.assertTrue(CollectionUtils.isEmpty(list)); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_READ, LIST, "111"); + } + + @Test + @Order(1) + void testSave() throws Exception { + // 校验权限 放前面,否则会删掉创建的关系 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, SAVE, List.of(), "111"); + + CustomerAddRequest customerAddRequest = new CustomerAddRequest(); + customerAddRequest.setName(UUID.randomUUID().toString()); + customerAddRequest.setOwner(InternalUser.ADMIN.getValue()); + addCustomer = customerService.add(customerAddRequest, InternalUser.ADMIN.getValue(), DEFAULT_ORGANIZATION_ID); + + customerAddRequest.setName(UUID.randomUUID().toString()); + anotherAddCustomer = customerService.add(customerAddRequest, InternalUser.ADMIN.getValue(), DEFAULT_ORGANIZATION_ID); + + List requests = List.of(new CustomerRelationSaveRequest()); + requestPostWithOk(SAVE, requests, addCustomer.getId()); + + requests = List.of(new CustomerRelationSaveRequest(anotherAddCustomer.getId(), CustomerRelationType.SUBSIDIARY.name())); + requestPostWithOk(SAVE, requests, addCustomer.getId()); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, SAVE, requests, addCustomer.getId()); + } + + @Test + @Order(3) + void testList() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(LIST, addCustomer.getId()); + List list = getResultDataArray(mvcResult, CustomerRelationListResponse.class); + CustomerRelationListResponse response = list.getFirst(); + + Assertions.assertEquals(response.getCustomerId(), anotherAddCustomer.getId()); + Assertions.assertEquals(response.getRelationType(), CustomerRelationType.SUBSIDIARY.name()); + Assertions.assertNotNull(response.getCustomerName()); + } + + @Test + @Order(4) + void testAdd() throws Exception { + CustomerRelationSaveRequest customerRelationSaveRequest = new CustomerRelationSaveRequest(anotherAddCustomer.getId(), CustomerRelationType.SUBSIDIARY.name()); + + MvcResult mvcResult = requestPostWithOkAndReturn(ADD, customerRelationSaveRequest, addCustomer.getId()); + addCustomerRelation = getResultData(mvcResult, CustomerRelation.class); + addCustomerRelation = customerRelationBaseMapper.selectByPrimaryKey(addCustomerRelation.getId()); + Assertions.assertEquals(addCustomerRelation.getSourceCustomerId(), addCustomer.getId()); + Assertions.assertEquals(addCustomerRelation.getTargetCustomerId(), anotherAddCustomer.getId()); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, ADD, customerRelationSaveRequest, addCustomer.getId()); + } + + @Test + @Order(5) + void testEdit() throws Exception { + CustomerRelationUpdateRequest customerRelationUpdateRequest = new CustomerRelationUpdateRequest(); + customerRelationUpdateRequest.setId(addCustomerRelation.getId()); + customerRelationUpdateRequest.setCustomerId(anotherAddCustomer.getId()); + customerRelationUpdateRequest.setRelationType(CustomerRelationType.GROUP.name()); + requestPostWithOk(UPDATE, customerRelationUpdateRequest, addCustomer.getId()); + + addCustomerRelation = customerRelationBaseMapper.selectByPrimaryKey(addCustomerRelation.getId()); + Assertions.assertEquals(addCustomerRelation.getTargetCustomerId(), addCustomer.getId()); + Assertions.assertEquals(addCustomerRelation.getSourceCustomerId(), anotherAddCustomer.getId()); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, UPDATE, customerRelationUpdateRequest, addCustomer.getId()); + } + + @Test + @Order(9) + void delete() throws Exception { + requestGetWithOk(DELETE, addCustomerRelation.getId()); + + Assertions.assertNull(customerRelationBaseMapper.selectByPrimaryKey(addCustomerRelation.getId())); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_UPDATE, DELETE, addCustomerRelation.getId()); + } + + @Test + @Order(10) + void batchDelete() { + customerRelationService.deleteByCustomerId(addCustomer.getId()); + List list = customerRelationService.list(addCustomer.getId()); + Assertions.assertTrue(list.isEmpty()); + } +} \ No newline at end of file diff --git a/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerUserViewControllerTest.java b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerUserViewControllerTest.java new file mode 100644 index 0000000..0c82455 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/CustomerUserViewControllerTest.java @@ -0,0 +1,134 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.ArrayList; +import java.util.List; + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class CustomerUserViewControllerTest extends BaseTest { + + protected static final String DETAIL = "detail/{0}"; + protected static final String LIST = "list"; + protected static final String FIXED = "fixed/{0}"; + protected static final String POS = "edit/pos"; + protected static final String ENABLE = "enable/{0}"; + private static final String BASE_PATH = "/account/view/"; + private static String viewId; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void testAdd() throws Exception { + + UserViewAddRequest request = new UserViewAddRequest(); + request.setName("测试视图11"); + request.setSearchMode("AND"); + request.setConditions(buildConditions()); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + UserView resultData = getResultData(mvcResult, UserView.class); + viewId = resultData.getId(); + + } + + private List buildConditions() { + List conditions = new ArrayList<>(); + + FilterCondition condition1 = new FilterCondition(); + condition1.setName("123123312312"); + condition1.setValue(null); + condition1.setOperator("EMPTY"); + condition1.setMultipleValue(false); + condition1.setType("LOCATION"); + + FilterCondition condition2 = new FilterCondition(); + condition2.setName("1231233333"); + condition2.setValue("[\n" + + " \"2\"\n" + + " ]"); + condition2.setOperator("IN"); + condition2.setMultipleValue(false); + condition2.setType("1231"); + + conditions.add(condition1); + conditions.add(condition2); + return conditions; + } + + + @Test + @Order(2) + void testUpdate() throws Exception { + UserViewUpdateRequest request = new UserViewUpdateRequest(); + request.setId(viewId); + request.setName("更新試圖22"); + request.setConditions(buildConditions()); + this.requestPost(DEFAULT_UPDATE, request); + + request.setId("123123123"); + this.requestPost(DEFAULT_UPDATE, request); + } + + @Test + @Order(3) + void testGetDetail() throws Exception { + this.requestGet(DETAIL, viewId); + } + + @Test + @Order(4) + void testList() throws Exception { + this.requestGet(LIST); + } + + @Test + @Order(5) + void testFixed() throws Exception { + this.requestGet(FIXED, viewId); + } + + @Test + @Order(5) + void testSortPos() throws Exception { + String moveId = viewId; + testAdd(); + String targetId = viewId; + PosRequest request = new PosRequest(); + request.setOrgId(DEFAULT_ORGANIZATION_ID); + request.setMoveId(moveId); + request.setTargetId(targetId); + request.setMoveMode("after"); + this.requestGet(POS, request); + } + + @Test + @Order(6) + void testEnable() throws Exception { + this.requestGet(ENABLE, viewId); + } + + @Test + @Order(10) + void testDelete() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, viewId); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/customer/controller/PoolCustomerControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/PoolCustomerControllerTests.java new file mode 100644 index 0000000..84c1b12 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/PoolCustomerControllerTests.java @@ -0,0 +1,276 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.dto.ExportHeadDTO; +import cn.cordys.common.dto.ExportSelectRequest; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.domain.CustomerCapacity; +import cn.cordys.crm.customer.domain.CustomerOwner; +import cn.cordys.crm.customer.domain.CustomerPoolPickRule; +import cn.cordys.crm.customer.dto.request.CustomerExportRequest; +import cn.cordys.crm.customer.dto.request.CustomerPageRequest; +import cn.cordys.crm.customer.dto.request.PoolCustomerAssignRequest; +import cn.cordys.crm.customer.dto.request.PoolCustomerPickRequest; +import cn.cordys.crm.customer.dto.response.CustomerListResponse; +import cn.cordys.crm.system.domain.ExportTask; +import cn.cordys.crm.system.dto.request.PoolBatchAssignRequest; +import cn.cordys.crm.system.dto.request.PoolBatchPickRequest; +import cn.cordys.crm.system.dto.request.PoolBatchRequest; +import cn.cordys.crm.system.service.ExportTaskCenterService; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.MvcResult; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class PoolCustomerControllerTests extends BaseTest { + + public static final String BASE_PATH = "/pool/account"; + public static final String GET_OPTIONS = "/options"; + public static final String PAGE = "/page"; + public static final String PICK = "/pick"; + public static final String ASSIGN = "/assign"; + public static final String DELETE = "/delete/"; + public static final String GET_DETAIL = "/get/"; + public static final String BATCH_PICK = "/batch-pick"; + public static final String BATCH_ASSIGN = "/batch-assign"; + public static final String BATCH_DELETE = "/batch-delete"; + protected static final String EXPORT_ALL = "/export-all"; + protected static final String EXPORT_SELECT = "/export-select"; + public static String testDataId; + + @Resource + private BaseMapper customerMapper; + @Resource + private BaseMapper customerOwnerMapper; + @Resource + private BaseMapper customerCapacityMapper; + @Resource + private BaseMapper customerPoolPickRuleMapper; + @Resource + private BaseMapper exportTaskBaseMapper; + @Resource + private ExportTaskCenterService exportTaskCenterService; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void prepareTestData() { + Customer customer = createCustomer(); + testDataId = customer.getId(); + Customer ownCustomer = createCustomer(); + CustomerCapacity capacity = createCapacity(); + ownCustomer.setInSharedPool(false); + ownCustomer.setOwner("admin"); + customerMapper.batchInsert(List.of(ownCustomer, customer)); + customerCapacityMapper.insert(capacity); + } + + @Test + @Order(2) + void getOptions() throws Exception { + this.requestGetWithOk(GET_OPTIONS); + requestGetPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ, GET_OPTIONS); + } + + @Test + @Order(3) + void page() throws Exception { + CustomerPageRequest request = new CustomerPageRequest(); + request.setPoolId("test-pool-id"); + request.setCurrent(1); + request.setPageSize(10); + MvcResult mvcResult = this.requestPostWithOkAndReturn(PAGE, request); + Pager> pageResult = getPageResult(mvcResult, CustomerListResponse.class); + assert pageResult.getTotal() == 1; + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ, PAGE, request); + } + + @Test + @Order(4) + void pickFailWithOverCapacity() throws Exception { + PoolCustomerPickRequest request = new PoolCustomerPickRequest(); + request.setCustomerId(testDataId); + request.setPoolId("test-pool-id"); + MvcResult mvcResult = this.requestPost(PICK, request).andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult.getResponse().getContentAsString().contains(Translator.getWithArgs("customer.capacity.over", 0)); + customerCapacityMapper.deleteByLambda(new LambdaQueryWrapper<>()); + CustomerPoolPickRule pickRule = createPickRule(); + pickRule.setLimitOnNumber(false); + pickRule.setPoolId("test-pool-id"); + customerPoolPickRuleMapper.insert(pickRule); + this.requestPost(PICK, request); + customerPoolPickRuleMapper.deleteByLambda(new LambdaQueryWrapper<>()); + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_PICK, PICK, request); + } + + @Test + @Order(5) + void assignSuccess() throws Exception { + PoolCustomerAssignRequest request = new PoolCustomerAssignRequest(); + request.setCustomerId(testDataId); + request.setAssignUserId("aa"); + this.requestPostWithOk(ASSIGN, request); + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_ASSIGN, ASSIGN, request); + } + + @Test + @Order(6) + void getDetail() throws Exception { + this.requestGetWithOk(GET_DETAIL + testDataId); + requestGetPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_READ, GET_DETAIL + testDataId); + } + + + @Test + @Order(6) + void testExport() throws Exception { + CustomerExportRequest request = new CustomerExportRequest(); + request.setCurrent(1); + request.setPageSize(10); + request.setPoolId("test-pool-id"); + request.setFileName("测试跨页导出公海"); + + List list = new ArrayList<>(); + list.add(new ExportHeadDTO("name", "客户名称", "custom")); + + request.setHeadList(list); + MvcResult mvcResult = this.requestPostWithOkAndReturn(EXPORT_ALL, request); + String resultData = getResultData(mvcResult, String.class); + Thread.sleep(1500); // 等待导出任务完成 + ResponseEntity resourceResponseEntity = exportTaskCenterService.download(resultData, "admin"); + Assertions.assertTrue(resourceResponseEntity.getBody().exists()); + ExportTask exportTask = new ExportTask(); + exportTask.setId(resultData); + LocalDateTime oneDayBefore = LocalDateTime.now().minusDays(2); + exportTask.setCreateTime(oneDayBefore.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); + exportTaskBaseMapper.updateById(exportTask); + exportTaskCenterService.clean(); + System.out.println(resourceResponseEntity.getBody().exists()); + + //权限校验 + this.requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_EXPORT, EXPORT_ALL, request); + } + + @Test + @Order(7) + void deleteSuccess() throws Exception { + this.requestGetWithOk(DELETE + testDataId); + requestGetPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_DELETE, DELETE + testDataId); + } + + @Test + @Order(8) + void batchPickFailWithOverDailyOrPreOwnerLimit() throws Exception { + Customer customer = createCustomer(); + customer.setOwner("admin"); + customer.setInSharedPool(false); + customerMapper.insert(customer); + CustomerPoolPickRule rule = createPickRule(); + rule.setLimitOnNumber(true); + rule.setPickNumber(1); + customerPoolPickRuleMapper.insert(rule); + customerCapacityMapper.deleteByLambda(new LambdaQueryWrapper<>()); + PoolBatchPickRequest request = new PoolBatchPickRequest(); + request.setBatchIds(List.of(testDataId)); + request.setPoolId("test-pool-id"); + this.requestPost(BATCH_PICK, request); + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_PICK, BATCH_PICK, request); + } + + @Test + @Order(9) + void batchAssignFailWithNotExit() throws Exception { + PoolBatchAssignRequest request = new PoolBatchAssignRequest(); + request.setBatchIds(List.of("aaa")); + request.setAssignUserId("cc"); + MvcResult mvcResult = this.requestPost(BATCH_ASSIGN, request).andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult.getResponse().getContentAsString().contains(Translator.get("customer.not.exist")); + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_ASSIGN, BATCH_ASSIGN, request); + } + + @Test + @Order(10) + void batchDeleteSuccess() throws Exception { + PoolBatchRequest request = new PoolBatchRequest(); + request.setBatchIds(List.of(testDataId)); + this.requestPostWithOk(BATCH_DELETE, request); + requestPostPermissionTest(PermissionConstants.CUSTOMER_MANAGEMENT_POOL_DELETE, BATCH_DELETE, request); + } + + private Customer createCustomer() { + Customer customer = new Customer(); + customer.setId(IDGenerator.nextStr()); + customer.setName("ct"); + customer.setOwner("cc"); + customer.setCollectionTime(System.currentTimeMillis()); + customer.setPoolId("test-pool-id"); + customer.setInSharedPool(true); + customer.setOrganizationId(DEFAULT_ORGANIZATION_ID); + customer.setCreateTime(System.currentTimeMillis()); + customer.setCreateUser("admin"); + customer.setUpdateTime(System.currentTimeMillis()); + customer.setUpdateUser("admin"); + return customer; + } + + private CustomerCapacity createCapacity() { + CustomerCapacity capacity = new CustomerCapacity(); + capacity.setId(IDGenerator.nextStr()); + capacity.setScopeId("admin"); + capacity.setOrganizationId(DEFAULT_ORGANIZATION_ID); + capacity.setCapacity(1); + capacity.setCreateTime(System.currentTimeMillis()); + capacity.setCreateUser("admin"); + capacity.setUpdateTime(System.currentTimeMillis()); + capacity.setUpdateUser("admin"); + return capacity; + } + + private CustomerPoolPickRule createPickRule() { + CustomerPoolPickRule rule = new CustomerPoolPickRule(); + rule.setId(IDGenerator.nextStr()); + rule.setPoolId("test-pool-id"); + rule.setLimitOnNumber(false); + rule.setLimitPreOwner(false); + rule.setLimitNew(false); + rule.setCreateTime(System.currentTimeMillis()); + rule.setCreateUser("admin"); + rule.setUpdateTime(System.currentTimeMillis()); + rule.setUpdateUser("admin"); + return rule; + } + + private void insertOwnerHis() { + CustomerOwner customerOwner = new CustomerOwner(); + customerOwner.setId(IDGenerator.nextStr()); + customerOwner.setCustomerId(testDataId); + customerOwner.setOwner("admin"); + customerOwner.setCollectionTime(System.currentTimeMillis()); + customerOwner.setOperator("admin"); + customerOwner.setEndTime(System.currentTimeMillis()); + customerOwnerMapper.insert(customerOwner); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/customer/controller/PoolCustomerUserViewControllerTest.java b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/PoolCustomerUserViewControllerTest.java new file mode 100644 index 0000000..988bbf7 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/customer/controller/PoolCustomerUserViewControllerTest.java @@ -0,0 +1,134 @@ +package cn.cordys.crm.customer.controller; + +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.ArrayList; +import java.util.List; + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class PoolCustomerUserViewControllerTest extends BaseTest { + + protected static final String DETAIL = "detail/{0}"; + protected static final String LIST = "list"; + protected static final String FIXED = "fixed/{0}"; + protected static final String POS = "edit/pos"; + protected static final String ENABLE = "enable/{0}"; + private static final String BASE_PATH = "/pool/account/view/"; + private static String viewId; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void testAdd() throws Exception { + + UserViewAddRequest request = new UserViewAddRequest(); + request.setName("测试视图11"); + request.setSearchMode("AND"); + request.setConditions(buildConditions()); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + UserView resultData = getResultData(mvcResult, UserView.class); + viewId = resultData.getId(); + + } + + private List buildConditions() { + List conditions = new ArrayList<>(); + + FilterCondition condition1 = new FilterCondition(); + condition1.setName("123123312312"); + condition1.setValue(null); + condition1.setOperator("EMPTY"); + condition1.setMultipleValue(false); + condition1.setType("LOCATION"); + + FilterCondition condition2 = new FilterCondition(); + condition2.setName("1231233333"); + condition2.setValue("[\n" + + " \"2\"\n" + + " ]"); + condition2.setOperator("IN"); + condition2.setMultipleValue(false); + condition2.setType("1231"); + + conditions.add(condition1); + conditions.add(condition2); + return conditions; + } + + + @Test + @Order(2) + void testUpdate() throws Exception { + UserViewUpdateRequest request = new UserViewUpdateRequest(); + request.setId(viewId); + request.setName("更新試圖22"); + request.setConditions(buildConditions()); + this.requestPost(DEFAULT_UPDATE, request); + + request.setId("123123123"); + this.requestPost(DEFAULT_UPDATE, request); + } + + @Test + @Order(3) + void testGetDetail() throws Exception { + this.requestGet(DETAIL, viewId); + } + + @Test + @Order(4) + void testList() throws Exception { + this.requestGet(LIST); + } + + @Test + @Order(5) + void testFixed() throws Exception { + this.requestGet(FIXED, viewId); + } + + @Test + @Order(5) + void testSortPos() throws Exception { + String moveId = viewId; + testAdd(); + String targetId = viewId; + PosRequest request = new PosRequest(); + request.setOrgId(DEFAULT_ORGANIZATION_ID); + request.setMoveId(moveId); + request.setTargetId(targetId); + request.setMoveMode("after"); + this.requestGet(POS, request); + } + + @Test + @Order(6) + void testEnable() throws Exception { + this.requestGet(ENABLE, viewId); + } + + @Test + @Order(10) + void testDelete() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, viewId); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/customer/job/RecycleJobTests.java b/backend/crm/src/test/java/cn/cordys/crm/customer/job/RecycleJobTests.java new file mode 100644 index 0000000..6e16918 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/customer/job/RecycleJobTests.java @@ -0,0 +1,50 @@ +package cn.cordys.crm.customer.job; + +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.job.listener.CluePoolRecycleListener; +import cn.cordys.crm.system.job.listener.CustomerPoolRecycleListener; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class RecycleJobTests extends BaseTest { + + @Resource + private CustomerPoolRecycleListener customerPoolRecycleListener; + + @Resource + private CluePoolRecycleListener cluePoolRecycleListener; + + @Test + @Order(1) + @Sql(scripts = {"/dml/init_customer_recycle_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = {"/dml/cleanup_customer_recycle_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + public void testCustomerRecycle() { + customerPoolRecycleListener.recycle(); + } + + @Test + @Order(2) + @Sql(scripts = {"/dml/init_clue_recycle_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = {"/dml/cleanup_clue_recycle_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + public void testClueRecycle() { + cluePoolRecycleListener.recycle(); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/follow/FollowUpPlanControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/follow/FollowUpPlanControllerTests.java new file mode 100644 index 0000000..00c518e --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/follow/FollowUpPlanControllerTests.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.follow; + +import cn.cordys.crm.base.BaseTest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class FollowUpPlanControllerTests extends BaseTest { + + protected static final String MODULE_FORM = "module/form"; + private static final String BASE_PATH = "/follow/record/"; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(0) + void testModuleField() throws Exception { + this.requestGetWithOkAndReturn(MODULE_FORM); + } + + +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/follow/FollowUpRecordControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/follow/FollowUpRecordControllerTests.java new file mode 100644 index 0000000..aa40ecb --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/follow/FollowUpRecordControllerTests.java @@ -0,0 +1,31 @@ +package cn.cordys.crm.follow; + +import cn.cordys.crm.base.BaseTest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class FollowUpRecordControllerTests extends BaseTest { + + protected static final String MODULE_FORM = "module/form"; + private static final String BASE_PATH = "/follow/record/"; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(0) + void testModuleField() throws Exception { + this.requestGetWithOkAndReturn(MODULE_FORM); + } + + +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/form/controller/CustomFormControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/form/controller/CustomFormControllerTests.java new file mode 100644 index 0000000..78cfe90 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/form/controller/CustomFormControllerTests.java @@ -0,0 +1,357 @@ +package cn.cordys.crm.form.controller; + +import cn.cordys.common.constants.RoleDataScope; +import cn.cordys.common.domain.BaseModel; +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.DeptUserTreeNode; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.RoleUserTreeNode; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.form.domain.CustomForm; +import cn.cordys.crm.form.domain.CustomFormAdmin; +import cn.cordys.crm.form.domain.CustomFormRole; +import cn.cordys.crm.form.domain.CustomFormRoleUser; +import cn.cordys.crm.form.dto.request.*; +import cn.cordys.crm.form.dto.response.CustomFormGetResponse; +import cn.cordys.crm.form.dto.response.CustomFormListResponse; +import cn.cordys.crm.form.dto.response.CustomFormRoleUserListResponse; +import cn.cordys.crm.system.domain.*; +import cn.cordys.crm.system.dto.form.FormProp; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class CustomFormControllerTests extends BaseTest { + + private static final String BASE_PATH = "/custom-form/"; + private static final String OPTION = "option"; + private static final String ADMIN_GET = "admin/get/{0}"; + private static final String ROLE_USERS = "role/users"; + + private static final String ROLE_DEPT_TREE = "role/user/dept/tree"; + private static final String ROLE_ROLE_TREE = "role/user/role/tree"; + private static String createdFormId; + + @Resource + private BaseMapper customFormAdminMapper; + @Resource + private BaseMapper customFormRoleMapper; + @Resource + private BaseMapper customFormRoleUserMapper; + @Resource + private BaseMapper organizationUserMapper; + @Resource + private BaseMapper userRoleMapper; + @Resource + private BaseMapper userMapper; + @Resource + private BaseMapper departmentMapper; + @Resource + private BaseMapper roleMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void testList() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(DEFAULT_LIST); + List list = getResultDataArray(mvcResult, CustomFormListResponse.class); + assertNotNull(list); + } + + @Test + @Order(2) + void testCreate() throws Exception { + CustomFormAddRequest request = JSON.parseObject("{\"name\":\"未命名表单sdfsdf\",\"enable\":true,\"fields\":[{\"id\":\"178090868410300000\",\"type\":\"DIVIDER\",\"icon\":\"iconicon_dividing_line\",\"name\":\"基本信息\",\"fieldWidth\":1,\"showLabel\":true,\"description\":\"\",\"readable\":true,\"editable\":true,\"mobile\":true,\"rules\":[],\"dividerClass\":\"divider--normal\",\"dividerColor\":\"#edf0f1\",\"titleColor\":\"#323535\",\"placeholder\":\"\",\"sumColumns\":[]},{\"id\":\"178090868410300001\",\"type\":\"INPUT\",\"name\":\"名称\",\"icon\":\"iconicon_single_line_text\",\"fieldWidth\":1,\"showLabel\":true,\"defaultValue\":\"\",\"defaultValueType\":\"custom\",\"description\":\"\",\"readable\":true,\"editable\":true,\"mobile\":true,\"rules\":[{\"key\":\"required\",\"required\":true,\"message\":\"common.notNull\",\"label\":\"common.required\",\"trigger\":[\"change\",\"blur\"]}],\"formula\":\"\",\"businessKey\":\"name\",\"disabledProps\":[\"readable\",\"mobile\",\"rules.required\"],\"internalKey\":\"customFormDataName\",\"placeholder\":\"\",\"sumColumns\":[]},{\"id\":\"178090868410300002\",\"type\":\"INPUT\",\"name\":\"自定义字段1\",\"icon\":\"iconicon_single_line_text\",\"fieldWidth\":1,\"showLabel\":true,\"defaultValue\":\"\",\"defaultValueType\":\"custom\",\"description\":\"\",\"readable\":true,\"editable\":true,\"mobile\":true,\"rules\":[],\"formula\":\"\",\"placeholder\":\"\",\"sumColumns\":[]},{\"id\":\"178090868410300003\",\"type\":\"INPUT\",\"name\":\"自定义字段2\",\"icon\":\"iconicon_single_line_text\",\"fieldWidth\":1,\"showLabel\":true,\"defaultValue\":\"\",\"defaultValueType\":\"custom\",\"description\":\"\",\"readable\":true,\"editable\":true,\"mobile\":true,\"rules\":[],\"formula\":\"\",\"placeholder\":\"\",\"sumColumns\":[]},{\"id\":\"178090868410300004\",\"type\":\"DIVIDER\",\"icon\":\"iconicon_dividing_line\",\"name\":\"负责人信息\",\"fieldWidth\":1,\"showLabel\":true,\"description\":\"\",\"readable\":true,\"editable\":true,\"mobile\":true,\"rules\":[],\"dividerClass\":\"divider--normal\",\"dividerColor\":\"#edf0f1\",\"titleColor\":\"#323535\",\"placeholder\":\"\",\"sumColumns\":[]},{\"id\":\"178090868410300005\",\"type\":\"MEMBER\",\"icon\":\"iconicon_member_single_choice\",\"name\":\"负责人\",\"fieldWidth\":1,\"showLabel\":true,\"description\":\"\",\"readable\":true,\"editable\":true,\"mobile\":true,\"rules\":[],\"defaultValue\":\"admin\",\"initialOptions\":[{\"id\":\"admin\",\"name\":\"Administrator\"}],\"hasCurrentUser\":false,\"multiple\":false,\"businessKey\":\"owner\",\"disabledProps\":[],\"internalKey\":\"customFormDataNOwner\",\"placeholder\":\"\",\"sumColumns\":[]}],\"formProp\":{\"layout\":1,\"labelPos\":\"top\",\"inputWidth\":\"custom\",\"optBtnContent\":[{\"text\":\"保存\",\"enable\":true},{\"text\":\"保存并继续添加\",\"enable\":false},{\"text\":\"取消\",\"enable\":true}],\"optBtnPos\":\"flex-row\"}}", CustomFormAddRequest.class); + request.setName("测试自定义表单-X"); + request.setEnable(true); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + CustomForm form = getResultData(mvcResult, CustomForm.class); + assertNotNull(form); + assertNotNull(form.getId()); + assertEquals("测试自定义表单-X", form.getName()); + + createdFormId = form.getId(); + } + + @Test + @Order(3) + void testEnabledOptions() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(OPTION); + List list = getResultDataArray(mvcResult, OptionDTO.class); + assertNotNull(list); + assertTrue(list.stream().anyMatch(o -> createdFormId.equals(o.getId())), + "已开启的表单应出现在选项列表中"); + } + + @Test + @Order(4) + void testGet() throws Exception { + assertNotNull(createdFormId, "表单应已创建"); + + MvcResult mvcResult = this.requestGetWithOkAndReturn(DEFAULT_GET, createdFormId); + CustomFormGetResponse response = getResultData(mvcResult, CustomFormGetResponse.class); + assertNotNull(response); + assertEquals(createdFormId, response.getId()); + assertEquals("测试自定义表单-X", response.getName()); + assertNotNull(response.getCreator()); + assertEquals("admin", response.getCreator().getId()); + assertEquals("Administrator", response.getCreator().getName()); + } + + @Test + @Order(5) + void testUpdate() throws Exception { + assertNotNull(createdFormId, "表单应已创建"); + + CustomFormUpdateRequest request = new CustomFormUpdateRequest(); + request.setId(createdFormId); + request.setName("更新后的表单名称"); + request.setEnable(false); + request.setFormProp(new FormProp()); + + this.requestPostWithOk(DEFAULT_UPDATE, request); + + MvcResult mvcResult = this.requestGetWithOkAndReturn(DEFAULT_GET, createdFormId); + CustomFormGetResponse response = getResultData(mvcResult, CustomFormGetResponse.class); + assertEquals("更新后的表单名称", response.getName()); + assertEquals(false, response.getEnable()); + } + + @Test + @Order(6) + void testSetAdmins() throws Exception { + assertNotNull(createdFormId, "表单应已创建"); + + MvcResult adminResult = this.requestGetWithOkAndReturn(ADMIN_GET, createdFormId); + List admins = getResultDataArray(adminResult, OptionDTO.class); + assertNotNull(admins); + assertTrue(admins.stream().anyMatch(admin -> "admin".equals(admin.getId()))); + + CustomFormAdminBatchRequest request = new CustomFormAdminBatchRequest(); + request.setCustomFormId(createdFormId); + request.setUserIds(List.of("test-admin-user", "test-admin-user-2")); + + this.requestPostWithOk("/admin/set", request); + assertAdminUsers("test-admin-user", "test-admin-user-2"); + + request.setUserIds(List.of("test-admin-user-3")); + this.requestPostWithOk("/admin/set", request); + assertAdminUsers("test-admin-user-3"); + } + + @Test + @Order(7) + void testAddRoleUsersByUserDeptAndRole() throws Exception { + assertNotNull(createdFormId, "表单应已创建"); + + String roleId = getFirstCustomFormRoleId(); + prepareUserDeptAndRoleData(); + + CustomFormRoleUserBatchRequest request = new CustomFormRoleUserBatchRequest(); + request.setCustomFormRoleId(roleId); + request.setUserIds(List.of("cf-role-direct-user", "cf-role-disabled-direct-user")); + request.setDeptIds(List.of("cf-role-test-dept")); + request.setRoleIds(List.of("cf-role-test-system-role")); + + this.requestPostWithOk("role/user/add", request); + + assertRoleUsers(roleId, "cf-role-direct-user", "cf-role-dept-user", "cf-role-system-role-user"); + assertRoleUsersPage(roleId, 1, 3, 3); + } + + @Test + @Order(8) + void testRoleUserTrees() throws Exception { + MvcResult deptTreeResult = this.requestGetWithOkAndReturn(ROLE_DEPT_TREE); + List deptTree = getResultDataArray(deptTreeResult, DeptUserTreeNode.class); + assertTrue(containsTreeNode(deptTree, "cf-role-test-dept")); + assertTrue(containsTreeNode(deptTree, "cf-role-dept-user")); + + MvcResult roleTreeResult = this.requestGetWithOkAndReturn(ROLE_ROLE_TREE); + List roleTree = getResultDataArray(roleTreeResult, RoleUserTreeNode.class); + assertTrue(containsTreeNode(roleTree, "cf-role-test-system-role")); + assertTrue(containsTreeNode(roleTree, "cf-role-system-role-user")); + } + + @Test + @Order(9) + void testDelete() throws Exception { + assertNotNull(createdFormId, "表单应已创建"); + + this.requestGetWithOk(DEFAULT_DELETE, createdFormId); + createdFormId = null; + } + + private void assertAdminUsers(String... expectedUserIds) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CustomFormAdmin::getCustomFormId, createdFormId); + Set actualUserIds = new HashSet<>(customFormAdminMapper.selectListByLambda(wrapper) + .stream() + .map(CustomFormAdmin::getUserId) + .toList()); + assertEquals(Set.of(expectedUserIds), actualUserIds); + } + + private boolean containsTreeNode(List treeNodes, String id) { + return treeNodes.stream().anyMatch(node -> containsTreeNode(node, id)); + } + + private boolean containsTreeNode(BaseTreeNode node, String id) { + if (id.equals(node.getId())) { + return true; + } + return node.getChildren().stream().anyMatch(child -> containsTreeNode(child, id)); + } + + private String getFirstCustomFormRoleId() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CustomFormRole::getCustomFormId, createdFormId); + List roles = customFormRoleMapper.selectListByLambda(wrapper); + assertTrue(!roles.isEmpty(), "表单创建后应自动创建内置角色"); + return roles.getFirst().getId(); + } + + private void prepareUserDeptAndRoleData() { + insertDepartment(); + insertRole(); + insertUser("cf-role-direct-user", "直接用户"); + insertUser("cf-role-dept-user", "部门用户"); + insertUser("cf-role-system-role-user", "角色用户"); + insertUser("cf-role-disabled-direct-user", "禁用直接用户"); + insertUser("cf-role-disabled-dept-user", "禁用部门用户"); + insertUser("cf-role-disabled-role-user", "禁用角色用户"); + insertOrganizationUser("cf-role-direct-org-user", "cf-role-direct-user", "销售顾问"); + insertOrganizationUser("cf-role-dept-org-user", "cf-role-dept-user", "部门专员"); + insertOrganizationUser("cf-role-system-role-org-user", "cf-role-system-role-user", "角色专员"); + insertOrganizationUser("cf-role-disabled-direct-org-user", "cf-role-disabled-direct-user", "禁用直接用户", false); + insertOrganizationUser("cf-role-disabled-dept-org-user", "cf-role-disabled-dept-user", "禁用部门用户", false); + insertOrganizationUser("cf-role-disabled-role-org-user", "cf-role-disabled-role-user", "禁用角色用户", false); + insertUserRole("cf-role-direct-user-role", "cf-role-direct-user"); + insertUserRole("cf-role-dept-user-role", "cf-role-dept-user"); + insertUserRole("cf-role-test-user-role", "cf-role-system-role-user"); + insertUserRole("cf-role-disabled-role-user-role", "cf-role-disabled-role-user"); + } + + private void insertDepartment() { + Department department = new Department(); + department.setId("cf-role-test-dept"); + department.setName("自定义表单测试部门"); + department.setOrganizationId(DEFAULT_ORGANIZATION_ID); + department.setParentId("NONE"); + department.setPos(1L); + department.setResource("TEST"); + department.setResourceId("cf-role-test-dept-resource"); + setAuditFields(department); + departmentMapper.insert(department); + } + + private void insertRole() { + Role role = new Role(); + role.setId("cf-role-test-system-role"); + role.setName("测试系统角色"); + role.setInternal(false); + role.setOrganizationId(DEFAULT_ORGANIZATION_ID); + role.setDataScope(RoleDataScope.ALL.name()); + setAuditFields(role); + roleMapper.insert(role); + } + + private void insertOrganizationUser(String id, String userId, String position) { + insertOrganizationUser(id, userId, position, true); + } + + private void insertOrganizationUser(String id, String userId, String position, boolean enable) { + OrganizationUser organizationUser = new OrganizationUser(); + organizationUser.setId(id); + organizationUser.setOrganizationId(DEFAULT_ORGANIZATION_ID); + organizationUser.setDepartmentId("cf-role-test-dept"); + organizationUser.setUserId(userId); + organizationUser.setPosition(position); + organizationUser.setEnable(enable); + setAuditFields(organizationUser); + organizationUserMapper.insert(organizationUser); + } + + private void insertUserRole(String id, String userId) { + UserRole userRole = new UserRole(); + userRole.setId(id); + userRole.setRoleId("cf-role-test-system-role"); + userRole.setUserId(userId); + setAuditFields(userRole); + userRoleMapper.insert(userRole); + } + + private void insertUser(String id, String name) { + User user = new User(); + user.setId(id); + user.setName(name); + user.setPassword("123456"); + user.setGender(false); + setAuditFields(user); + userMapper.insert(user); + } + + private void setAuditFields(BaseModel model) { + long now = System.currentTimeMillis(); + model.setCreateUser("admin"); + model.setUpdateUser("admin"); + model.setCreateTime(now); + model.setUpdateTime(now); + } + + private void assertRoleUsers(String roleId, String... expectedUserIds) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CustomFormRoleUser::getRoleId, roleId); + List roleUsers = customFormRoleUserMapper.selectListByLambda(wrapper); + Set actualUserIds = new HashSet<>(roleUsers + .stream() + .map(CustomFormRoleUser::getUserId) + .toList()); + assertEquals(Set.of(expectedUserIds), actualUserIds); + } + + private void assertRoleUsersPage(String roleId, int current, int pageSize, int total) throws Exception { + CustomFormRoleUserPageRequest request = new CustomFormRoleUserPageRequest(); + request.setCustomFormRoleId(roleId); + request.setCurrent(current); + request.setPageSize(pageSize); + MvcResult mvcResult = this.requestPostWithOkAndReturn(ROLE_USERS, request); + Pager> pager = getPageResult(mvcResult, CustomFormRoleUserListResponse.class); + assertEquals(current, pager.getCurrent()); + assertEquals(pageSize, pager.getPageSize()); + assertEquals(total, pager.getTotal()); + assertEquals(pageSize, pager.getList().size()); + CustomFormRoleUserListResponse first = pager.getList().getFirst(); + assertNotNull(first.getId()); + assertNotNull(first.getUserId()); + assertNotNull(first.getUsername()); + assertNotNull(first.getCreateTime()); + assertTrue(pager.getList().stream().allMatch(user -> "cf-role-test-dept".equals(user.getDepartmentId()))); + assertTrue(pager.getList().stream().allMatch(user -> "自定义表单测试部门".equals(user.getDepartmentName()))); + assertTrue(pager.getList().stream().allMatch(user -> user.getPosition() != null)); + assertTrue(pager.getList().stream().allMatch(user -> user.getRoles() != null + && user.getRoles().stream().anyMatch(role -> "cf-role-test-system-role".equals(role.getId())))); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/form/controller/CustomFormDataControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/form/controller/CustomFormDataControllerTests.java new file mode 100644 index 0000000..d3a124a --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/form/controller/CustomFormDataControllerTests.java @@ -0,0 +1,224 @@ +package cn.cordys.crm.form.controller; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.form.domain.CustomFormData; +import cn.cordys.crm.form.domain.CustomForm; +import cn.cordys.crm.form.domain.CustomFormRole; +import cn.cordys.crm.form.domain.CustomFormRoleKey; +import cn.cordys.crm.form.dto.request.CustomFormDataAddRequest; +import cn.cordys.crm.form.dto.request.CustomFormDataPageRequest; +import cn.cordys.crm.form.dto.request.CustomFormDataUpdateRequest; +import cn.cordys.crm.form.dto.response.CustomFormDataGetResponse; +import cn.cordys.crm.form.dto.response.CustomFormDataListResponse; +import cn.cordys.crm.system.domain.ModuleForm; +import cn.cordys.crm.system.domain.ModuleFormBlob; +import cn.cordys.common.pager.Pager; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class CustomFormDataControllerTests extends BaseTest { + + private static final String BASE_PATH = "/custom-form/data/"; + private static String createdDataId; + private static String testFormId; + + @Resource + private BaseMapper customFormMapper; + @Resource + private BaseMapper moduleFormMapper; + @Resource + private BaseMapper moduleFormBlobMapper; + @Resource + private BaseMapper customFormRoleMapper; + @Resource + private BaseMapper customFormDataMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + private void ensureFormExists() { + if (testFormId == null) { + testFormId = IDGenerator.nextStr(); + } + if (customFormMapper.selectByPrimaryKey(testFormId) != null) { + return; + } + + String formId = testFormId; + long now = System.currentTimeMillis(); + + CustomForm form = new CustomForm(); + form.setId(formId); + form.setName(UUID.randomUUID().toString()); + form.setEnable(true); + form.setOrganizationId(DEFAULT_ORGANIZATION_ID); + form.setCreateTime(now); + form.setUpdateTime(now); + form.setCreateUser(InternalUser.ADMIN.getValue()); + form.setUpdateUser(InternalUser.ADMIN.getValue()); + customFormMapper.insert(form); + + ModuleForm moduleForm = new ModuleForm(); + moduleForm.setId(formId); + moduleForm.setFormKey(formId); + moduleForm.setOrganizationId(DEFAULT_ORGANIZATION_ID); + moduleForm.setCreateTime(now); + moduleForm.setUpdateTime(now); + moduleForm.setCreateUser(InternalUser.ADMIN.getValue()); + moduleForm.setUpdateUser(InternalUser.ADMIN.getValue()); + moduleFormMapper.insert(moduleForm); + + ModuleFormBlob formBlob = new ModuleFormBlob(); + formBlob.setId(formId); + formBlob.setProp("{}"); + moduleFormBlobMapper.insert(formBlob); + + List roles = Arrays.stream(CustomFormRoleKey.values()) + .map(key -> { + CustomFormRole role = new CustomFormRole(); + role.setId(IDGenerator.nextStr()); + role.setName(key.getKey()); + role.setCustomFormId(formId); + role.setInternalKey(key.getKey()); + role.setCreateTime(now); + role.setUpdateTime(now); + role.setCreateUser(InternalUser.ADMIN.getValue()); + role.setUpdateUser(InternalUser.ADMIN.getValue()); + return role; + }).toList(); + customFormRoleMapper.batchInsert(roles); + } + + @Test + @Order(1) + void testAdd() throws Exception { + ensureFormExists(); + + CustomFormDataAddRequest request = new CustomFormDataAddRequest(); + request.setCustomFormId(testFormId); + request.setName("测试表单数据"); + + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + CustomFormData data = getResultData(mvcResult, CustomFormData.class); + assertNotNull(data); + assertNotNull(data.getId()); + assertEquals("测试表单数据", data.getName()); + assertEquals(testFormId, data.getCustomFormId()); + + createdDataId = data.getId(); + } + + @Test + @Order(2) + void testGet() throws Exception { + assertNotNull(createdDataId, "数据应已创建"); + + MvcResult mvcResult = this.requestGetWithOkAndReturn(DEFAULT_GET, createdDataId); + CustomFormDataGetResponse response = getResultData(mvcResult, CustomFormDataGetResponse.class); + assertNotNull(response); + assertEquals(createdDataId, response.getId()); + assertEquals("测试表单数据", response.getName()); + } + + @Test + @Order(3) + void testPage() throws Exception { + ensureFormExists(); + + CustomFormDataPageRequest request = new CustomFormDataPageRequest(); + request.setCustomFormId(testFormId); + request.setCurrent(1); + request.setPageSize(10); + + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_PAGE, request); + Pager> pager = getPageResult(mvcResult, CustomFormDataListResponse.class); + assertNotNull(pager); + assertNotNull(pager.getList()); + } + + @Test + @Order(4) + void testPageWithKeyword() throws Exception { + ensureFormExists(); + + CustomFormDataPageRequest request = new CustomFormDataPageRequest(); + request.setCustomFormId(testFormId); + request.setCurrent(1); + request.setPageSize(10); + request.setKeyword("测试"); + + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_PAGE, request); + Pager> pager = getPageResult(mvcResult, CustomFormDataListResponse.class); + assertNotNull(pager); + } + + @Test + @Order(5) + void testUpdate() throws Exception { + assertNotNull(createdDataId, "数据应已创建"); + + CustomFormDataUpdateRequest request = new CustomFormDataUpdateRequest(); + request.setId(createdDataId); + request.setCustomFormId(testFormId); + request.setName("更新后的表单数据"); + + this.requestPostWithOk(DEFAULT_UPDATE, request); + + MvcResult mvcResult = this.requestGetWithOkAndReturn(DEFAULT_GET, createdDataId); + CustomFormDataGetResponse response = getResultData(mvcResult, CustomFormDataGetResponse.class); + assertEquals("更新后的表单数据", response.getName()); + } + + @Test + @Order(7) + void testBatchDelete() throws Exception { + assertNotNull(createdDataId, "数据应已创建"); + + this.requestPostWithOk(DEFAULT_BATCH_DELETE, List.of(createdDataId)); + + // 验证数据已被删除 + CustomFormData deleted = customFormDataMapper.selectByPrimaryKey(createdDataId); + assertNull(deleted, "数据应已被删除"); + + createdDataId = null; + } + + @Test + @Order(8) + void testDelete() throws Exception { + ensureFormExists(); + + CustomFormDataAddRequest addRequest = new CustomFormDataAddRequest(); + addRequest.setCustomFormId(testFormId); + addRequest.setName("待删除数据"); + + MvcResult addResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, addRequest); + CustomFormData data = getResultData(addResult, CustomFormData.class); + assertNotNull(data); + + this.requestGetWithOk(DEFAULT_DELETE, data.getId()); + + CustomFormData deleted = customFormDataMapper.selectByPrimaryKey(data.getId()); + assertNull(deleted, "数据应已被删除"); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/home/HomeStatisticControllerTest.java b/backend/crm/src/test/java/cn/cordys/crm/home/HomeStatisticControllerTest.java new file mode 100644 index 0000000..8db4dbb --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/home/HomeStatisticControllerTest.java @@ -0,0 +1,75 @@ +package cn.cordys.crm.home; + +import cn.cordys.common.constants.BusinessSearchType; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.home.dto.request.HomeStatisticBaseSearchRequest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * @Author: jianxing + * @CreateTime: 2025-07-15 16:24 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class HomeStatisticControllerTest extends BaseTest { + + protected static final String OPPORTUNITY = "opportunity"; + protected static final String OPPORTUNITY_SUCCESS = "opportunity/success"; + protected static final String CLUE = "lead"; + protected static final String DEPARTMENT_TREE = "department/tree"; + private static final String BASE_PATH = "/home/statistic/"; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(0) + void testGetOpportunityStatistic() throws Exception { + HomeStatisticBaseSearchRequest request = new HomeStatisticBaseSearchRequest(); + request.setSearchType(BusinessSearchType.ALL.name()); + + this.requestPostWithOkAndReturn(OPPORTUNITY, request); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ, OPPORTUNITY, request); + } + + @Test + @Order(0) + void testGetOpportunitySuccessStatistic() throws Exception { + HomeStatisticBaseSearchRequest request = new HomeStatisticBaseSearchRequest(); + request.setSearchType(BusinessSearchType.ALL.name()); + + this.requestPostWithOkAndReturn(OPPORTUNITY_SUCCESS, request); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ, OPPORTUNITY_SUCCESS, request); + } + + @Test + @Order(0) + void testGetClueStatistic() throws Exception { + HomeStatisticBaseSearchRequest request = new HomeStatisticBaseSearchRequest(); + request.setSearchType(BusinessSearchType.ALL.name()); + + this.requestPostWithOkAndReturn(CLUE, request); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.CLUE_MANAGEMENT_READ, CLUE, request); + } + + @Test + @Order(1) + void getDepartmentTree() throws Exception { + this.requestGetWithOkAndReturn(DEPARTMENT_TREE); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/opportunity/controller/OpportunityControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/opportunity/controller/OpportunityControllerTests.java new file mode 100644 index 0000000..6966389 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/opportunity/controller/OpportunityControllerTests.java @@ -0,0 +1,236 @@ +package cn.cordys.crm.opportunity.controller; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.ExportHeadDTO; +import cn.cordys.common.dto.ResourceTabEnableDTO; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.customer.dto.request.CustomerPageRequest; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.opportunity.dto.request.*; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.web.servlet.MvcResult; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class OpportunityControllerTests extends BaseTest { + + protected static final String MODULE_FORM = "module/form"; + protected static final String BATCH_TRANSFER = "batch/transfer"; + protected static final String TAB = "tab"; + protected static final String UPDATE_STAGE = "update/stage"; + protected static final String CONTACT_LIST = "contact/list/{0}"; + protected static final String EXPORT_ALL = "export-all"; + protected static final String EXPORT_SELECT = "export-select"; + protected static final String STATISTIC = "statistic"; + protected static final String BATCH_UPDATE = "batch/update"; + private static final String BASE_PATH = "/opportunity/"; + private static Opportunity addOpportunity; + private static ModuleFormConfigDTO moduleFormConfig; + @Resource + private BaseMapper opportunityMapper; + + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(0) + void testModuleField() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(MODULE_FORM); + moduleFormConfig = getResultData(mvcResult, ModuleFormConfigDTO.class); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ, MODULE_FORM); + } + + + @Test + @Order(2) + void testPage() throws Exception { + CustomerPageRequest request = new CustomerPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_PAGE, request); + } + + @Test + @Order(2) + void testStatistic() throws Exception { + OpportunitySearchStatisticRequest request = new OpportunitySearchStatisticRequest(); + this.requestPostWithOkAndReturn(STATISTIC, request); + } + + + @Sql(scripts = {"/dml/init_opportunity_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Test + @Order(1) + void testAdd() throws Exception { + OpportunityAddRequest request = new OpportunityAddRequest(); + request.setName("商机1"); + request.setCustomerId("123"); + request.setAmount(BigDecimal.valueOf(1.1)); + request.setProducts(List.of("11")); + request.setPossible(BigDecimal.valueOf(1.2)); + request.setContactId("12345"); + request.setOwner("admin"); + request.setExpectedEndTime(System.currentTimeMillis()); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + Opportunity resultData = getResultData(mvcResult, Opportunity.class); + addOpportunity = opportunityMapper.selectByPrimaryKey(resultData.getId()); + + request.setProducts(List.of("11", "22")); + this.requestPost(DEFAULT_ADD, request); + } + + + @Test + @Order(3) + void testUpdate() throws Exception { + OpportunityUpdateRequest request = new OpportunityUpdateRequest(); + request.setId("1234"); + request.setName("商机2"); + request.setCustomerId("123"); + request.setContactId("1234567"); + request.setOwner("admin"); + request.setProducts(List.of("22")); + request.setExpectedEndTime(System.currentTimeMillis()); + request.setModuleFields(List.of(new BaseModuleFieldValue("id", "value"))); + this.requestPost(DEFAULT_UPDATE, request); + + request.setId(addOpportunity.getId()); + this.requestPostWithOk(DEFAULT_UPDATE, request); + } + + @Test + @Order(4) + void testBatchTransfer() throws Exception { + OpportunityTransferRequest request = new OpportunityTransferRequest(); + request.setIds(List.of("1234")); + request.setOwner("12345"); + this.requestPostWithOk(BATCH_TRANSFER, request); + } + + @Test + @Order(4) + void testBatchUpdate() throws Exception { + ResourceBatchEditRequest request = new ResourceBatchEditRequest(); + for (BaseField field : moduleFormConfig.getFields()) { + request.setIds(List.of(addOpportunity.getId())); + request.setFieldId(field.getId()); + if (Strings.CS.equals(field.getName(), BusinessModuleField.OPPORTUNITY_OWNER.name())) { + request.setFieldId(InternalUser.ADMIN.getValue()); + this.requestPostWithOk(BATCH_UPDATE, request); + } else if (Strings.CS.equals(field.getName(), BusinessModuleField.OPPORTUNITY_NAME.name())) { + request.setFieldId(UUID.randomUUID().toString()); + this.requestPostWithOk(BATCH_UPDATE, request); + } + } + // 校验权限 + requestPostPermissionTest(PermissionConstants.OPPORTUNITY_MANAGEMENT_UPDATE, BATCH_UPDATE, request); + } + + @Test + @Order(4) + void testTab() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(TAB); + ResourceTabEnableDTO resultData = getResultData(mvcResult, ResourceTabEnableDTO.class); + // 校验请求成功数据 + Assertions.assertTrue(resultData.getAll()); + Assertions.assertTrue(resultData.getDept()); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ, TAB); + } + + + @Test + @Order(5) + void testDelete() throws Exception { + this.requestGet(DEFAULT_DELETE, "123456"); + this.requestGetWithOk(DEFAULT_DELETE, addOpportunity.getId()); + } + + + @Test + @Order(6) + void testBatchDelete() throws Exception { + this.requestPostWithOk(DEFAULT_BATCH_DELETE, List.of("123")); + } + + + @Test + @Order(2) + void testGetDetail() throws Exception { + this.requestGet(DEFAULT_GET, addOpportunity.getId()); + } + + @Test + @Order(3) + void testUpdateStage() throws Exception { + OpportunityStageRequest request = new OpportunityStageRequest(); + request.setId(addOpportunity.getId()); + request.setStage("SUCCESS"); + this.requestPostWithOk(UPDATE_STAGE, request); + request.setStage("FAIL"); + request.setFailureReason("test_fail"); + this.requestPostWithOk(UPDATE_STAGE, request); + } + + @Test + @Order(7) + @Sql(scripts = {"/dml/init_opportunity_rule_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = {"/dml/cleanup_opportunity_rule_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void testPageReservedDay() throws Exception { + OpportunityAddRequest request = new OpportunityAddRequest(); + request.setName("商机1"); + request.setCustomerId("123"); + request.setAmount(BigDecimal.valueOf(1.1)); + request.setProducts(List.of("11")); + request.setPossible(BigDecimal.valueOf(1.2)); + request.setContactId("12345"); + request.setOwner("admin"); + request.setExpectedEndTime(System.currentTimeMillis()); + this.requestPostWithOk(DEFAULT_ADD, request); + OpportunityPageRequest pageRequest = new OpportunityPageRequest(); + pageRequest.setViewId("ALL"); + pageRequest.setCurrent(1); + pageRequest.setPageSize(10); + this.requestPostWithOk(DEFAULT_PAGE, pageRequest); + } + + + @Test + @Order(2) + void testGetContactList() throws Exception { + this.requestGet(CONTACT_LIST, addOpportunity.getId()); + this.requestGet(CONTACT_LIST, "1234567"); + } + +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/opportunity/controller/OpportunityFollowPlanControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/opportunity/controller/OpportunityFollowPlanControllerTests.java new file mode 100644 index 0000000..d309087 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/opportunity/controller/OpportunityFollowPlanControllerTests.java @@ -0,0 +1,174 @@ +package cn.cordys.crm.opportunity.controller; + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.follow.constants.FollowUpPlanStatusType; +import cn.cordys.crm.follow.domain.FollowUpPlan; +import cn.cordys.crm.follow.domain.FollowUpRecord; +import cn.cordys.crm.follow.dto.request.FollowUpPlanAddRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanStatusRequest; +import cn.cordys.crm.follow.dto.request.FollowUpPlanUpdateRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordPageRequest; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.system.job.listener.FollowUpPlanRemindListener; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class OpportunityFollowPlanControllerTests extends BaseTest { + + private static final String BASE_PATH = "/opportunity/follow/plan/"; + + private static final String CANCEL_PLAN = "cancel/{0}"; + private static final String STATUS_UPDATE = "status/update"; + private static FollowUpPlan addFollowUpPlan; + @Resource + private BaseMapper followUpPlanMapper; + @Resource + private FollowUpPlanRemindListener followUpPlanRemindListener; + @Resource + private BaseMapper customerMapper; + @Resource + private BaseMapper opportunityMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void testAdd() throws Exception { + long timestamp = LocalDate.now() + .atStartOfDay(ZoneId.systemDefault()) + .toEpochSecond() * 1000; + FollowUpPlanAddRequest request = new FollowUpPlanAddRequest(); + request.setCustomerId("wx_123"); + request.setOpportunityId("wx_12345"); + request.setOwner("admin"); + request.setContactId("123456"); + request.setType("CUSTOMER"); + request.setMethod("1"); + request.setContent("计划一下"); + request.setEstimatedTime(timestamp); + request.setModuleFields(List.of(new BaseModuleFieldValue("id", "value"))); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + FollowUpRecord resultData = getResultData(mvcResult, FollowUpRecord.class); + addFollowUpPlan = followUpPlanMapper.selectByPrimaryKey(resultData.getId()); + } + + + @Test + @Order(2) + void testUpdate() throws Exception { + long timestamp = LocalDate.now() + .atStartOfDay(ZoneId.systemDefault()) + .toEpochSecond() * 1000; + FollowUpPlanUpdateRequest request = new FollowUpPlanUpdateRequest(); + request.setId("1234"); + request.setCustomerId("wx_123"); + request.setOpportunityId("wx_12345"); + request.setOwner("admin"); + request.setContactId("1234567"); + request.setMethod("2"); + request.setType("CUSTOMER"); + request.setContent("计划两下"); + request.setEstimatedTime(timestamp); + request.setConverted(false); + request.setModuleFields(List.of(new BaseModuleFieldValue("id", "value"))); + this.requestPost(DEFAULT_UPDATE, request); + + request.setId(addFollowUpPlan.getId()); + this.requestPostWithOk(DEFAULT_UPDATE, request); + } + + + @Test + @Order(3) + void testList() throws Exception { + FollowUpRecordPageRequest request = new FollowUpRecordPageRequest(); + request.setSourceId("123"); + request.setCurrent(1); + request.setPageSize(10); + this.requestPost(DEFAULT_PAGE, request); + } + + @Test + @Order(4) + void testGet() throws Exception { + this.requestGet(DEFAULT_GET, addFollowUpPlan.getId()); + } + + + @Test + @Order(4) + void testStatusUpdate() throws Exception { + FollowUpPlanStatusRequest request = new FollowUpPlanStatusRequest(); + request.setId(addFollowUpPlan.getId()); + request.setStatus(FollowUpPlanStatusType.COMPLETED.name()); + this.requestPost(STATUS_UPDATE, request); + } + + @Test + @Order(5) + void testCancelPlan() throws Exception { + this.requestGetWithOk(CANCEL_PLAN, addFollowUpPlan.getId()); + } + + @Test + @Order(6) + void tesDeletePlan() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, addFollowUpPlan.getId()); + } + + + @Test + @Order(3) + void testJob() throws Exception { + Customer customer = new Customer(); + customer.setId("wx_123"); + customer.setName("测试客户"); + customer.setCreateUser("admin"); + customer.setCreateTime(System.currentTimeMillis()); + customer.setUpdateUser("admin"); + customer.setUpdateTime(System.currentTimeMillis()); + customer.setOrganizationId("100001"); + customer.setInSharedPool(false); + customerMapper.insert(customer); + + Opportunity opportunity = new Opportunity(); + opportunity.setId("wx_12345"); + opportunity.setName("测试商机"); + opportunity.setCustomerId("123"); + opportunity.setProducts(List.of("123456")); + opportunity.setOrganizationId("100001"); + opportunity.setStage("STAGE_1"); + opportunity.setContactId("1233"); + opportunity.setOwner("admin"); + opportunity.setCreateUser("admin"); + opportunity.setCreateTime(System.currentTimeMillis()); + opportunity.setUpdateUser("admin"); + opportunity.setUpdateTime(System.currentTimeMillis()); + opportunity.setExpectedEndTime(System.currentTimeMillis()); + opportunityMapper.insert(opportunity); + followUpPlanRemindListener.followUpPlanRemind(); + + customerMapper.deleteByPrimaryKey("wx_123"); + opportunityMapper.deleteByPrimaryKey("wx_123"); + } + +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/opportunity/controller/OpportunityFollowRecordControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/opportunity/controller/OpportunityFollowRecordControllerTests.java new file mode 100644 index 0000000..0b67859 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/opportunity/controller/OpportunityFollowRecordControllerTests.java @@ -0,0 +1,95 @@ +package cn.cordys.crm.opportunity.controller; + + +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.follow.domain.FollowUpRecord; +import cn.cordys.crm.follow.dto.request.FollowUpRecordAddRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordPageRequest; +import cn.cordys.crm.follow.dto.request.FollowUpRecordUpdateRequest; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class OpportunityFollowRecordControllerTests extends BaseTest { + + private static final String BASE_PATH = "/opportunity/follow/record/"; + private static FollowUpRecord addFollowUpRecord; + @Resource + private BaseMapper followUpRecordMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void testAdd() throws Exception { + FollowUpRecordAddRequest request = new FollowUpRecordAddRequest(); + request.setCustomerId("123"); + request.setOpportunityId("12345"); + request.setOwner("admin"); + request.setContactId("123456"); + request.setType("CUSTOMER"); + request.setContent("跟进一下"); + request.setFollowMethod("1"); + request.setFollowTime(System.currentTimeMillis()); + request.setModuleFields(List.of(new BaseModuleFieldValue("id", "value"))); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + FollowUpRecord resultData = getResultData(mvcResult, FollowUpRecord.class); + addFollowUpRecord = followUpRecordMapper.selectByPrimaryKey(resultData.getId()); + } + + @Test + @Order(2) + void testUpdate() throws Exception { + FollowUpRecordUpdateRequest request = new FollowUpRecordUpdateRequest(); + request.setId("1234"); + request.setCustomerId("123"); + request.setOpportunityId("12345"); + request.setOwner("admin"); + request.setContactId("1234567"); + request.setFollowMethod("2"); + request.setType("CUSTOMER"); + request.setContent("跟进两下"); + request.setModuleFields(List.of(new BaseModuleFieldValue("id", "value"))); + this.requestPost(DEFAULT_UPDATE, request); + + request.setId(addFollowUpRecord.getId()); + this.requestPostWithOk(DEFAULT_UPDATE, request); + } + + @Test + @Order(3) + void testList() throws Exception { + FollowUpRecordPageRequest request = new FollowUpRecordPageRequest(); + request.setSourceId("12345"); + request.setCurrent(1); + request.setPageSize(10); + this.requestPost(DEFAULT_PAGE, request); + } + + @Test + @Order(4) + void testGet() throws Exception { + this.requestGet(DEFAULT_GET, addFollowUpRecord.getId()); + } + + @Test + @Order(5) + void testDeleteRecord() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, addFollowUpRecord.getId()); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/opportunity/controller/OpportunityRuleControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/opportunity/controller/OpportunityRuleControllerTests.java new file mode 100644 index 0000000..806ecb5 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/opportunity/controller/OpportunityRuleControllerTests.java @@ -0,0 +1,100 @@ +package cn.cordys.crm.opportunity.controller; + +import cn.cordys.common.dto.BasePageRequest; +import cn.cordys.common.dto.SortRequest; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.JSON; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.opportunity.domain.OpportunityRule; +import cn.cordys.crm.opportunity.dto.OpportunityRuleDTO; +import cn.cordys.crm.opportunity.dto.request.OpportunityRuleAddRequest; +import cn.cordys.crm.opportunity.dto.request.OpportunityRuleUpdateRequest; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class OpportunityRuleControllerTests extends BaseTest { + + private static OpportunityRuleDTO editRule; + @Resource + private BaseMapper opportunityRuleMapper; + + @Test + @Order(1) + void emptyPage() throws Exception { + BasePageRequest request = new BasePageRequest(); + request.setCurrent(1); + request.setPageSize(10); + MvcResult mvcResult = this.requestPostWithOkAndReturn("/opportunity-rule/page", request); + Pager> result = getPageResult(mvcResult, OpportunityRuleDTO.class); + assert result.getList().isEmpty(); + } + + @Test + @Order(2) + void add() throws Exception { + OpportunityRuleAddRequest request = OpportunityRuleAddRequest.builder() + .name("rule").ownerIds(List.of("cc")).scopeIds(List.of("cc")) + .enable(true).auto(false).build(); + this.requestPostWithOk("/opportunity-rule/add", request); + } + + @Test + @Order(3) + void page() throws Exception { + BasePageRequest request = new BasePageRequest(); + request.setCurrent(1); + request.setPageSize(10); + request.setSort(new SortRequest("name", "desc")); + MvcResult mvcResult = this.requestPostWithOkAndReturn("/opportunity-rule/page", request); + Pager> result = getPageResult(mvcResult, OpportunityRuleDTO.class); + assert result.getList().size() == 1; + editRule = result.getList().getFirst(); + } + + + @Test + @Order(4) + void update() throws Exception { + OpportunityRuleUpdateRequest request = new OpportunityRuleUpdateRequest(); + BeanUtils.copyBean(request, editRule); + request.setOwnerIds(List.of("admin")); + request.setScopeIds(List.of("admin")); + this.requestPostWithOk("/opportunity-rule/update", request); + // update owner id by sql + editRule.setOwnerId(JSON.toJSONString(List.of("admin"))); + opportunityRuleMapper.updateById(editRule); + this.requestPostWithOk("/opportunity-rule/update", request); + } + + @Test + @Order(5) + void switchStatus() throws Exception { + MvcResult mvcResult = this.requestGet("/opportunity-rule/switch/default-pool").andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult.getResponse().getContentAsString().contains(Translator.get("opportunity.rule.not_exist")); + this.requestGetWithOk("/opportunity-rule/switch/" + editRule.getId()); + } + + @Test + @Order(6) + void delete() throws Exception { + MvcResult mvcResult = this.requestGet("/opportunity-rule/delete/default-pool").andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult.getResponse().getContentAsString().contains(Translator.get("opportunity.rule.not_exist")); + this.requestGetWithOk("/opportunity-rule/delete/" + editRule.getId()); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/opportunity/controller/OpportunityUserViewControllerTest.java b/backend/crm/src/test/java/cn/cordys/crm/opportunity/controller/OpportunityUserViewControllerTest.java new file mode 100644 index 0000000..26c8bcf --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/opportunity/controller/OpportunityUserViewControllerTest.java @@ -0,0 +1,134 @@ +package cn.cordys.crm.opportunity.controller; + +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.domain.UserView; +import cn.cordys.crm.system.dto.request.UserViewAddRequest; +import cn.cordys.crm.system.dto.request.UserViewUpdateRequest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.ArrayList; +import java.util.List; + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class OpportunityUserViewControllerTest extends BaseTest { + + protected static final String DETAIL = "detail/{0}"; + protected static final String LIST = "list"; + protected static final String FIXED = "fixed/{0}"; + protected static final String POS = "edit/pos"; + protected static final String ENABLE = "enable/{0}"; + private static final String BASE_PATH = "/opportunity/view/"; + private static String viewId; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void testAdd() throws Exception { + + UserViewAddRequest request = new UserViewAddRequest(); + request.setName("测试视图11"); + request.setSearchMode("AND"); + request.setConditions(buildConditions()); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + UserView resultData = getResultData(mvcResult, UserView.class); + viewId = resultData.getId(); + + } + + private List buildConditions() { + List conditions = new ArrayList<>(); + + FilterCondition condition1 = new FilterCondition(); + condition1.setName("123123312312"); + condition1.setValue(null); + condition1.setOperator("EMPTY"); + condition1.setMultipleValue(false); + condition1.setType("LOCATION"); + + FilterCondition condition2 = new FilterCondition(); + condition2.setName("1231233333"); + condition2.setValue("[\n" + + " \"2\"\n" + + " ]"); + condition2.setOperator("IN"); + condition2.setMultipleValue(false); + condition2.setType("1231"); + + conditions.add(condition1); + conditions.add(condition2); + return conditions; + } + + + @Test + @Order(2) + void testUpdate() throws Exception { + UserViewUpdateRequest request = new UserViewUpdateRequest(); + request.setId(viewId); + request.setName("更新試圖22"); + request.setConditions(buildConditions()); + this.requestPost(DEFAULT_UPDATE, request); + + request.setId("123123123"); + this.requestPost(DEFAULT_UPDATE, request); + } + + @Test + @Order(3) + void testGetDetail() throws Exception { + this.requestGet(DETAIL, viewId); + } + + @Test + @Order(4) + void testList() throws Exception { + this.requestGet(LIST); + } + + @Test + @Order(5) + void testFixed() throws Exception { + this.requestGet(FIXED, viewId); + } + + @Test + @Order(5) + void testSortPos() throws Exception { + String moveId = viewId; + testAdd(); + String targetId = viewId; + PosRequest request = new PosRequest(); + request.setOrgId(DEFAULT_ORGANIZATION_ID); + request.setMoveId(moveId); + request.setTargetId(targetId); + request.setMoveMode("after"); + this.requestGet(POS, request); + } + + @Test + @Order(6) + void testEnable() throws Exception { + this.requestGet(ENABLE, viewId); + } + + @Test + @Order(10) + void testDelete() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, viewId); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/redis/MessagePublisherTest.java b/backend/crm/src/test/java/cn/cordys/crm/redis/MessagePublisherTest.java new file mode 100644 index 0000000..9b3e0ae --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/redis/MessagePublisherTest.java @@ -0,0 +1,47 @@ +package cn.cordys.crm.redis; + +import cn.cordys.common.constants.TopicConstants; +import cn.cordys.common.redis.MessagePublisher; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.consumer.SSEConsumer; +import cn.cordys.crm.system.notice.dto.NoticeRedisMessage; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class MessagePublisherTest { + + + @Resource + private MessagePublisher publisher; + + @Resource + private SSEConsumer sseConsumer; + + @Test + @Order(0) + public void publishMessageTest() throws InterruptedException { + + + NoticeRedisMessage noticeRedisMessage = new NoticeRedisMessage(); + noticeRedisMessage.setMessage("admin"); + noticeRedisMessage.setNoticeType(NotificationConstants.Type.SYSTEM_NOTICE.toString()); + publisher.publish(TopicConstants.SSE_TOPIC, JSON.toJSONString(noticeRedisMessage)); + // 这里可以添加断言来验证消息是否成功发布 +// 等待异步消息处理 + Thread.sleep(1000); // 可根据实际延迟调整 + System.out.println(sseConsumer.getChannel()); + + + } + + +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/search/AdvancedSearchControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/search/AdvancedSearchControllerTests.java new file mode 100644 index 0000000..82b8989 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/search/AdvancedSearchControllerTests.java @@ -0,0 +1,144 @@ +package cn.cordys.crm.search; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.domain.CustomerContact; +import cn.cordys.crm.customer.domain.CustomerField; +import cn.cordys.crm.customer.dto.request.CustomerAddRequest; +import cn.cordys.crm.customer.dto.request.CustomerContactAddRequest; +import cn.cordys.crm.customer.dto.request.CustomerContactPageRequest; +import cn.cordys.crm.customer.dto.request.CustomerPageRequest; +import cn.cordys.crm.search.response.advanced.AdvancedCustomerContactResponse; +import cn.cordys.crm.search.response.advanced.AdvancedCustomerResponse; +import cn.cordys.crm.system.domain.ModuleField; +import cn.cordys.crm.system.domain.ModuleForm; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class AdvancedSearchControllerTests extends BaseTest { + + + @Resource + private BaseMapper customerMapper; + @Resource + private BaseMapper customerFieldMapper; + @Resource + private BaseMapper moduleFieldMapper; + @Resource + private BaseMapper moduleFormMapper; + @Resource + private BaseMapper customerContactMapper; + + void addCustomer() throws Exception { + ModuleForm moduleForm = getModuleForm(); + + ModuleField example = new ModuleField(); + example.setFormId(moduleForm.getId()); + ModuleField moduleField = moduleFieldMapper.select(example) + .stream() + .filter(field -> Strings.CS.equals(field.getInternalKey(), "customerLevel")) + .findFirst().orElse(null); + + // 请求成功 + CustomerAddRequest request = new CustomerAddRequest(); + request.setName("aa1"); + request.setOwner("bb1"); + request.setModuleFields(List.of(new BaseModuleFieldValue(moduleField.getId(), "1"))); + + MvcResult mvcResult = this.requestPostWithOkAndReturn("/account/add", request); + Customer resultData = getResultData(mvcResult, Customer.class); + Customer customer = customerMapper.selectByPrimaryKey(resultData.getId()); + Assertions.assertEquals(request.getName(), customer.getName()); + Assertions.assertEquals(request.getOwner(), customer.getOwner()); + + // 校验字段 + List fieldValues = getCustomerFields(customer.getId()) + .stream().map(customerField -> { + BaseModuleFieldValue baseModuleFieldValue = BeanUtils.copyBean(new BaseModuleFieldValue(), customerField); + baseModuleFieldValue.setFieldValue(customerField.getFieldValue().toString()); + return baseModuleFieldValue; + }) + .toList(); + Assertions.assertEquals(request.getModuleFields(), fieldValues); + + // 创建另一个客户 + request.setName("another1"); + request.setOwner(InternalUser.ADMIN.getValue()); + this.requestPostWithOkAndReturn("/account/add", request); + + } + + private List getCustomerFields(String customerId) { + CustomerField example = new CustomerField(); + example.setResourceId(customerId); + return customerFieldMapper.select(example); + } + + private ModuleForm getModuleForm() { + ModuleForm example = new ModuleForm(); + example.setOrganizationId(DEFAULT_ORGANIZATION_ID); + example.setFormKey(FormKey.CUSTOMER.getKey()); + return moduleFormMapper.selectOne(example); + } + + void addCustomerContact() throws Exception { + // 请求成功 + CustomerContactAddRequest request = new CustomerContactAddRequest(); + request.setName("test"); + request.setCustomerId("customerId"); + request.setOwner(InternalUser.ADMIN.getValue()); + request.setPhone("15451222354"); + this.requestPostWithOkAndReturn("/account/contact/add", request); + } + + + @Test + @Order(0) + void testCustomer() throws Exception { + CustomerPageRequest request = new CustomerPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + + this.requestPostWithOkAndReturn("/advanced/search/account", request); + + addCustomer(); + + MvcResult mvcResult = this.requestPostWithOkAndReturn("/advanced/search/account", request); + Pager> pageResult = getPageResult(mvcResult, AdvancedCustomerResponse.class); + List customerList = pageResult.getList(); + Assertions.assertFalse(customerList.isEmpty()); + } + + @Test + @Order(1) + void testCustomerContact() throws Exception { + CustomerContactPageRequest request = new CustomerContactPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + + this.requestPostWithOkAndReturn("/advanced/search/contact", request); + addCustomerContact(); + MvcResult mvcResult = this.requestPostWithOkAndReturn("/advanced/search/contact", request); + Pager> pageResult = getPageResult(mvcResult, AdvancedCustomerContactResponse.class); + List customerList = pageResult.getList(); + Assertions.assertFalse(customerList.isEmpty()); + + } + + +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/search/SearchFieldMaskConfigControllerTest.java b/backend/crm/src/test/java/cn/cordys/crm/search/SearchFieldMaskConfigControllerTest.java new file mode 100644 index 0000000..b9290d6 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/search/SearchFieldMaskConfigControllerTest.java @@ -0,0 +1,76 @@ +package cn.cordys.crm.search; + +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.search.request.FieldMaskConfigDTO; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.domain.ModuleField; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class SearchFieldMaskConfigControllerTest extends BaseTest { + + protected static final String SAVE = "save"; + protected static final String GET = "get"; + private static final String BASE_PATH = "/mask/config/"; + public static String FIELD_ID = "wx_mask_field-test-id"; + @Resource + private BaseMapper moduleFieldMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void initData() { + ModuleField field = new ModuleField(); + field.setId(FIELD_ID); + field.setFormId("mask_form-test-1"); + field.setName("dep-test-1"); + field.setMobile(false); + field.setType(FieldType.DEPARTMENT.name()); + field.setPos(1L); + field.setCreateUser("admin"); + field.setCreateTime(System.currentTimeMillis()); + field.setUpdateUser("admin"); + field.setUpdateTime(System.currentTimeMillis()); + moduleFieldMapper.insert(field); + + } + + @Test + @Order(2) + void testSave() throws Exception { + FieldMaskConfigDTO request = new FieldMaskConfigDTO(); + request.setSearchFields(fieldMap()); + + this.requestPost(SAVE, request); + } + + private Map> fieldMap() { + Map> map = new HashMap<>(); + map.put("SEARCH_ADVANCED_CLUE", List.of(FIELD_ID)); + return map; + } + + + @Test + @Order(3) + void testGet() throws Exception { + this.requestGet(GET); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/search/UserSearchConfigControllerTest.java b/backend/crm/src/test/java/cn/cordys/crm/search/UserSearchConfigControllerTest.java new file mode 100644 index 0000000..07ce004 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/search/UserSearchConfigControllerTest.java @@ -0,0 +1,84 @@ +package cn.cordys.crm.search; + +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.search.request.UserSearchConfigAddRequest; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.domain.ModuleField; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class UserSearchConfigControllerTest extends BaseTest { + protected static final String SAVE = "save"; + protected static final String RESET = "reset"; + protected static final String GET = "get"; + private static final String BASE_PATH = "/search/config/"; + public static String FIELD_ID = "wx_field-test-id"; + @Resource + private BaseMapper moduleFieldMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void initData() { + ModuleField field = new ModuleField(); + field.setId(FIELD_ID); + field.setFormId("form-test-1"); + field.setName("dep-test-1"); + field.setMobile(false); + field.setType(FieldType.DEPARTMENT.name()); + field.setPos(1L); + field.setCreateUser("admin"); + field.setCreateTime(System.currentTimeMillis()); + field.setUpdateUser("admin"); + field.setUpdateTime(System.currentTimeMillis()); + moduleFieldMapper.insert(field); + + } + + + @Test + @Order(2) + void testAdd() throws Exception { + UserSearchConfigAddRequest request = new UserSearchConfigAddRequest(); + request.setResultDisplay(false); + request.setSortSetting(List.of("SEARCH_ADVANCED_CLUE", "SEARCH_ADVANCED_CUSTOMER", "SEARCH_ADVANCED_CONTACT", "SEARCH_ADVANCED_PUBLIC", "SEARCH_ADVANCED_CLUE_POOL", "SEARCH_ADVANCED_OPPORTUNITY")); + request.setSearchFields(fieldMap()); + + this.requestPost(SAVE, request); + } + + private Map> fieldMap() { + Map> map = new HashMap<>(); + map.put("SEARCH_ADVANCED_CLUE", List.of(FIELD_ID)); + return map; + } + + @Test + @Order(3) + void testReset() throws Exception { + this.requestGet(RESET); + } + + @Test + @Order(4) + void testGet() throws Exception { + this.requestGet(GET); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/AddNoticeExpireTests.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/AddNoticeExpireTests.java new file mode 100644 index 0000000..93be1b0 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/AddNoticeExpireTests.java @@ -0,0 +1,260 @@ +package cn.cordys.crm.system.controller; + + +import cn.cordys.crm.approval.constants.ApprovalState; +import cn.cordys.crm.contract.constants.ContractApprovalStatus; +import cn.cordys.crm.contract.constants.ContractPaymentPlanStatus; +import cn.cordys.crm.contract.constants.ContractStage; +import cn.cordys.crm.contract.domain.Contract; +import cn.cordys.crm.contract.domain.ContractPaymentPlan; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.opportunity.domain.OpportunityQuotation; +import cn.cordys.crm.system.domain.Notification; +import cn.cordys.crm.system.domain.OrganizationUser; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.response.NotificationDTO; +import cn.cordys.crm.system.job.NoticeExpireJob; +import cn.cordys.crm.system.mapper.ExtNotificationMapper; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +import java.math.BigDecimal; +import java.util.List; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class AddNoticeExpireTests { + + @Resource + private NoticeExpireJob noticeExpireJob; + + @Resource + private BaseMapper opportunityQuotationBaseMapper; + + @Resource + private BaseMapper contractPaymentPlanBaseMapper; + + @Resource + private BaseMapper contractBaseMapper; + + @Resource + private BaseMapper opportunityBaseMapper; + + @Resource + private BaseMapper customerBaseMapper; + + @Resource + private BaseMapper userBaseMapper; + + @Resource + private BaseMapper organizationUserBaseMapper; + + + @Resource + private ExtNotificationMapper extNotificationMapper; + + void saveNotice() { + OpportunityQuotation opportunityQuotation = new OpportunityQuotation(); + opportunityQuotation.setId("SDDFDJJND"); + opportunityQuotation.setName("测试报价"); + opportunityQuotation.setApprovalStatus(ApprovalState.APPROVED.getId()); + opportunityQuotation.setOpportunityId("SDDFDJJND"); + opportunityQuotation.setCreateUser("aaa"); + opportunityQuotation.setCreateTime(System.currentTimeMillis()); + opportunityQuotation.setUpdateTime(System.currentTimeMillis()); + opportunityQuotation.setUpdateUser("admin"); + opportunityQuotation.setAmount(BigDecimal.valueOf(10000)); + opportunityQuotation.setUntilTime(System.currentTimeMillis() - 1000 * 60 * 60 * 24 * 4); + opportunityQuotation.setOrganizationId("100001"); + opportunityQuotationBaseMapper.insert(opportunityQuotation); + + opportunityQuotation = new OpportunityQuotation(); + opportunityQuotation.setId("SgDFDJJND"); + opportunityQuotation.setName("测试报价"); + opportunityQuotation.setApprovalStatus(ApprovalState.APPROVED.getId()); + opportunityQuotation.setOpportunityId("SDDFDJJND"); + opportunityQuotation.setCreateUser("aaa"); + opportunityQuotation.setCreateTime(System.currentTimeMillis()); + opportunityQuotation.setUpdateTime(System.currentTimeMillis()); + opportunityQuotation.setUpdateUser("admin"); + opportunityQuotation.setAmount(BigDecimal.valueOf(10000)); + opportunityQuotation.setUntilTime(System.currentTimeMillis() - 1000 * 60 * 60 * 24); + opportunityQuotation.setOrganizationId("100001"); + opportunityQuotationBaseMapper.insert(opportunityQuotation); + + ContractPaymentPlan contractPaymentPlan = new ContractPaymentPlan(); + contractPaymentPlan.setId("SDDFDJJND"); + contractPaymentPlan.setName("test"); + contractPaymentPlan.setOrganizationId("100001"); + contractPaymentPlan.setContractId("SDDFDJJND"); + contractPaymentPlan.setPlanStatus(ContractPaymentPlanStatus.PENDING.name()); + contractPaymentPlan.setPlanAmount(BigDecimal.valueOf(10000)); + contractPaymentPlan.setOwner("aaa"); + contractPaymentPlan.setPlanEndTime(System.currentTimeMillis() - 1000 * 60 * 60 * 24 * 4); + contractPaymentPlan.setCreateUser("admin"); + contractPaymentPlan.setCreateTime(System.currentTimeMillis()); + contractPaymentPlan.setUpdateUser("admin"); + contractPaymentPlan.setUpdateTime(System.currentTimeMillis()); + contractPaymentPlanBaseMapper.insert(contractPaymentPlan); + + contractPaymentPlan = new ContractPaymentPlan(); + contractPaymentPlan.setId("SgDFDJJND"); + contractPaymentPlan.setName("test"); + contractPaymentPlan.setOrganizationId("100001"); + contractPaymentPlan.setContractId("SDDFDJJND"); + contractPaymentPlan.setPlanStatus(ContractPaymentPlanStatus.PENDING.name()); + contractPaymentPlan.setPlanAmount(BigDecimal.valueOf(10000)); + contractPaymentPlan.setOwner("aaa"); + contractPaymentPlan.setPlanEndTime(System.currentTimeMillis() - 1000 * 60 * 60 * 24); + contractPaymentPlan.setCreateUser("admin"); + contractPaymentPlan.setCreateTime(System.currentTimeMillis()); + contractPaymentPlan.setUpdateUser("admin"); + contractPaymentPlan.setUpdateTime(System.currentTimeMillis()); + contractPaymentPlanBaseMapper.insert(contractPaymentPlan); + + Contract contract = new Contract(); + contract.setId("SDDFDJJND"); + contract.setOrganizationId("100001"); + contract.setNumber("SDDFDJJND"); + contract.setName("测试合同"); + contract.setCustomerId("100001"); + contract.setAmount(BigDecimal.valueOf(10000)); + contract.setApprovalStatus(ContractApprovalStatus.APPROVED.name()); + contract.setStage(ContractStage.PENDING_SIGNING.name()); + contract.setCreateUser("admin"); + contract.setOwner("aaa"); + contract.setStartTime(System.currentTimeMillis()); + contract.setEndTime(System.currentTimeMillis()); + contract.setCreateTime(System.currentTimeMillis()); + contract.setUpdateUser("admin"); + contract.setUpdateTime(System.currentTimeMillis()); + contractBaseMapper.insert(contract); + + contract = new Contract(); + contract.setId("34554546565656667"); + contract.setOrganizationId("100001"); + contract.setNumber("SDDFDJJND"); + contract.setName("测试合同2"); + contract.setCustomerId("100001"); + contract.setAmount(BigDecimal.valueOf(10000)); + contract.setApprovalStatus(ContractApprovalStatus.APPROVED.name()); + contract.setStage(ContractStage.PENDING_SIGNING.name()); + contract.setCreateUser("admin"); + contract.setOwner("aaa"); + contract.setStartTime(System.currentTimeMillis() - 1000 * 60 * 60 * 24 * 2); + contract.setEndTime(System.currentTimeMillis() - 1000 * 60 * 60 * 24); + contract.setCreateTime(System.currentTimeMillis()); + contract.setUpdateUser("admin"); + contract.setUpdateTime(System.currentTimeMillis()); + contractBaseMapper.insert(contract); + + contract = new Contract(); + contract.setId("3455454656565667888"); + contract.setOrganizationId("100001"); + contract.setNumber("SDDFDJJND"); + contract.setName("测试合同1"); + contract.setCustomerId("100001"); + contract.setAmount(BigDecimal.valueOf(10000)); + contract.setApprovalStatus(ContractApprovalStatus.APPROVED.name()); + contract.setStage(ContractStage.PENDING_SIGNING.name()); + contract.setCreateUser("admin"); + contract.setOwner("aaa"); + contract.setStartTime(System.currentTimeMillis() - 1000 * 60 * 60 * 24 * 5); + contract.setEndTime(System.currentTimeMillis() - 1000 * 60 * 60 * 24 * 4); + contract.setCreateTime(System.currentTimeMillis()); + contract.setUpdateUser("admin"); + contract.setUpdateTime(System.currentTimeMillis()); + contractBaseMapper.insert(contract); + + Opportunity opportunity = new Opportunity(); + opportunity.setId("SDDFDJJND"); + opportunity.setName("测试商机"); + opportunity.setCustomerId("100001"); + opportunity.setOwner("aaa"); + opportunity.setAmount(BigDecimal.valueOf(10000)); + opportunity.setPossible(BigDecimal.valueOf(70)); + opportunity.setPos(500L); + opportunity.setStage("SUCCESS"); + opportunity.setFollower("aaa"); + opportunity.setContactId("aaa"); + opportunity.setActualEndTime(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 10); + opportunity.setExpectedEndTime(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 10); + opportunity.setProducts(List.of("100001")); + opportunity.setCreateUser("admin"); + opportunity.setCreateTime(System.currentTimeMillis()); + opportunity.setUpdateUser("admin"); + opportunity.setUpdateTime(System.currentTimeMillis()); + opportunity.setOrganizationId("100001"); + opportunityBaseMapper.insert(opportunity); + + Customer customer = new Customer(); + customer.setId("100001"); + customer.setName("测试客户"); + customer.setOwner("aaa"); + customer.setInSharedPool(false); + customer.setFollower("aaa"); + customer.setFollowTime(System.currentTimeMillis()); + customer.setCreateUser("admin"); + customer.setCreateTime(System.currentTimeMillis()); + customer.setUpdateUser("admin"); + customer.setUpdateTime(System.currentTimeMillis()); + customer.setOrganizationId("100001"); + customerBaseMapper.insert(customer); + + User user = new User(); + user.setId("aaa"); + user.setName("测试用户"); + user.setEmail("test@example.com"); + user.setPhone("1234567890"); + user.setPassword("123456"); + user.setGender(true); + user.setLanguage("zh_CN"); + user.setCreateUser("admin"); + user.setCreateTime(System.currentTimeMillis()); + user.setUpdateUser("admin"); + user.setUpdateTime(System.currentTimeMillis()); + userBaseMapper.insert(user); + + OrganizationUser organizationUser = new OrganizationUser(); + organizationUser.setId("100001"); + organizationUser.setOrganizationId("100001"); + organizationUser.setDepartmentId("100001"); + organizationUser.setEnable(true); + organizationUser.setUserId("aaa"); + organizationUser.setCreateUser("admin"); + organizationUser.setCreateTime(System.currentTimeMillis()); + organizationUser.setUpdateUser("admin"); + organizationUser.setUpdateTime(System.currentTimeMillis()); + organizationUserBaseMapper.insert(organizationUser); + + } + + @Test + @Order(0) + public void cleanupNotificationSuccess() { + saveNotice(); + noticeExpireJob.onEvent(); + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + Notification notification = new Notification(); + notification.setOrganizationId("100001"); + notification.setResourceType("OPPORTUNITY"); + List notifications = extNotificationMapper.selectByAnyOne(notification); + Assertions.assertTrue(CollectionUtils.isNotEmpty(notifications)); + notification = new Notification(); + notification.setOrganizationId("100001"); + notification.setResourceType("CONTRACT"); + notifications = extNotificationMapper.selectByAnyOne(notification); + Assertions.assertTrue(CollectionUtils.isNotEmpty(notifications)); + + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/AddNotificationJobTests.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/AddNotificationJobTests.java new file mode 100644 index 0000000..0013727 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/AddNotificationJobTests.java @@ -0,0 +1,67 @@ +package cn.cordys.crm.system.controller; + + +import cn.cordys.common.util.JSON; +import cn.cordys.crm.system.domain.Announcement; +import cn.cordys.crm.system.domain.Notification; +import cn.cordys.crm.system.dto.AnnouncementReceiveTypeDTO; +import cn.cordys.crm.system.dto.response.NotificationDTO; +import cn.cordys.crm.system.job.NotifyOnJob; +import cn.cordys.crm.system.mapper.ExtNotificationMapper; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class AddNotificationJobTests { + + @Resource + private NotifyOnJob notifyOnJob; + + @Resource + private BaseMapper announcementBaseMapper; + + @Resource + private ExtNotificationMapper extNotificationMapper; + + void saveNotice() { + AnnouncementReceiveTypeDTO announcementReceiveTypeDTO = new AnnouncementReceiveTypeDTO(); + announcementReceiveTypeDTO.setUserIds(List.of("aaa", "bbb")); + Announcement announcement = new Announcement(); + announcement.setId("SDDFDJJND"); + announcement.setSubject("Test"); + announcement.setContent("公告".getBytes()); + announcement.setStartTime(1727165733128L); + announcement.setCreateTime(1727165816848L); + announcement.setUpdateTime(System.currentTimeMillis()); + announcement.setEndTime(System.currentTimeMillis() + 5000); + announcement.setReceiver(JSON.toJSONString(List.of("aaa", "bbb")).getBytes()); + announcement.setOrganizationId("100001"); + announcement.setNotice(false); + announcement.setReceiveType(JSON.toJSONBytes(announcementReceiveTypeDTO)); + announcement.setCreateUser("admin"); + announcement.setUpdateUser("admin"); + announcementBaseMapper.insert(announcement); + } + + @Test + @Order(0) + public void cleanupNotificationSuccess() { + saveNotice(); + List createTimeAsc = announcementBaseMapper.selectAll("create_time asc"); + Assertions.assertTrue(CollectionUtils.isNotEmpty(createTimeAsc)); + notifyOnJob.addNotification(); + Notification notification = new Notification(); + notification.setOrganizationId("100001"); + notification.setResourceId("SDDFDJJND"); + List notifications = extNotificationMapper.selectByAnyOne(notification); + Assertions.assertTrue(CollectionUtils.isNotEmpty(notifications)); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/AnnouncementControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/AnnouncementControllerTests.java new file mode 100644 index 0000000..b25fea5 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/AnnouncementControllerTests.java @@ -0,0 +1,96 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.pager.Pager; +import cn.cordys.common.response.handler.ResultHolder; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.domain.Announcement; +import cn.cordys.crm.system.dto.request.AnnouncementPageRequest; +import cn.cordys.crm.system.dto.request.AnnouncementRequest; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@Slf4j +public class AnnouncementControllerTests extends BaseTest { + @Resource + private BaseMapper announcementMapper; + + @Test + @Order(1) + public void testCreateAnnouncement() throws Exception { + AnnouncementRequest request = new AnnouncementRequest(); + request.setSubject("测试公告"); + request.setContent("测试公告内容"); + request.setStartTime(System.currentTimeMillis()); + request.setEndTime(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 7); + request.setDeptIds(List.of("admin")); + request.setOrganizationId("1"); + this.requestPost("/announcement/add", request).andExpect(status().isOk()); + } + + @Test + @Order(2) + public void testUpdateAnnouncement() throws Exception { + Announcement announcement = getAnnouncement(); + AnnouncementRequest request = new AnnouncementRequest(); + request.setId(announcement.getId()); + request.setSubject("更新的公告"); + request.setContent("更新的公告内容"); + request.setStartTime(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 6); + request.setEndTime(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 7); + this.requestPost("/announcement/edit", request).andExpect(status().isOk()); + + } + + @Test + @Order(3) + public void testGetAnnouncementList() throws Exception { + AnnouncementPageRequest basePageRequest = new AnnouncementPageRequest(); + basePageRequest.setPageSize(10); + basePageRequest.setCurrent(1); + basePageRequest.setOrganizationId("1"); + MvcResult mvcResult = this.requestPostWithOkAndReturn("/announcement/page", basePageRequest); + var tableData = JSON.parseObject(JSON.toJSONString( + JSON.parseObject(mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + log.info(tableData.getList().toString()); + + } + + @Test + @Order(4) + public void testGetAnnouncementDetail() throws Exception { + Announcement announcement = getAnnouncement(); + MvcResult mvcResult = this.requestGetWithOkAndReturn("/announcement/get/" + announcement.getId()); + // 获取返回值 + String returnData = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + ResultHolder resultHolder = JSON.parseObject(returnData, ResultHolder.class); + // 返回请求正常 + Assertions.assertNotNull(resultHolder); + } + + private Announcement getAnnouncement() { + List createTime = announcementMapper.selectAll("create_time"); + return createTime.getFirst(); + } + + @Test + @Order(5) + public void testDeleteAnnouncement() throws Exception { + Announcement announcement = getAnnouncement(); + requestGetWithOk("/announcement/delete/" + announcement.getId()); + } +} \ No newline at end of file diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/BaseMapperTest.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/BaseMapperTest.java new file mode 100644 index 0000000..4e41ab3 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/BaseMapperTest.java @@ -0,0 +1,159 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.crm.system.domain.User; +import cn.cordys.mybatis.BaseMapper; +import org.junit.jupiter.api.*; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class BaseMapperTest { + @Mock + private BaseMapper baseMapper; + + private User user; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + user = new User(); + user.setId("admin"); + user.setEmail("test@a.com"); + user.setPassword("password123"); + } + + @Test + @Order(1) + public void testInsert() { + when(baseMapper.insert(user)).thenReturn(1); + Integer result = baseMapper.insert(user); + assertEquals(1, result); + verify(baseMapper, times(1)).insert(user); + } + + @Test + @Order(2) + public void testBatchInsert() { + List users = Collections.singletonList(user); + when(baseMapper.batchInsert(users)).thenReturn(1); + Integer result = baseMapper.batchInsert(users); + assertEquals(1, result); + verify(baseMapper, times(1)).batchInsert(users); + } + + @Test + @Order(3) + public void testUpdateById() { + when(baseMapper.updateById(user)).thenReturn(1); + Integer result = baseMapper.updateById(user); + assertEquals(1, result); + verify(baseMapper, times(1)).updateById(user); + } + + @Test + @Order(4) + public void testUpdate() { + when(baseMapper.update(user)).thenReturn(1); + Integer result = baseMapper.update(user); + assertEquals(1, result); + verify(baseMapper, times(1)).update(user); + } + + @Test + @Order(5) + public void testDeleteByPrimaryKey() { + when(baseMapper.deleteByPrimaryKey(1L)).thenReturn(1); + Integer result = baseMapper.deleteByPrimaryKey(1L); + assertEquals(1, result); + verify(baseMapper, times(1)).deleteByPrimaryKey(1L); + } + + @Test + @Order(6) + public void testDeleteByCriteria() { + when(baseMapper.delete(user)).thenReturn(1); + Integer result = baseMapper.delete(user); + assertEquals(1, result); + verify(baseMapper, times(1)).delete(user); + } + + @Test + @Order(7) + public void testSelectByPrimaryKey() { + when(baseMapper.selectByPrimaryKey(1L)).thenReturn(user); + User result = baseMapper.selectByPrimaryKey(1L); + assertNotNull(result); + assertEquals(user.getEmail(), result.getEmail()); + verify(baseMapper, times(1)).selectByPrimaryKey(1L); + } + + @Test + @Order(8) + public void testSelectAll() { + when(baseMapper.selectAll("username")).thenReturn(Collections.singletonList(user)); + List result = baseMapper.selectAll("username"); + assertNotNull(result); + assertEquals(1, result.size()); + verify(baseMapper, times(1)).selectAll("username"); + } + + @Test + @Order(9) + public void testSelect() { + when(baseMapper.select(user)).thenReturn(Collections.singletonList(user)); + List result = baseMapper.select(user); + assertNotNull(result); + assertEquals(1, result.size()); + verify(baseMapper, times(1)).select(user); + } + + @Test + @Order(10) + public void testSelectOne() { + when(baseMapper.selectOne(user)).thenReturn(user); + User result = baseMapper.selectOne(user); + assertNotNull(result); + assertEquals(user.getEmail(), result.getEmail()); + verify(baseMapper, times(1)).selectOne(user); + } + + @Test + @Order(11) + public void testSelectByColumn() { + Serializable[] ids = {1L}; + when(baseMapper.selectByColumn("id", ids)).thenReturn(Collections.singletonList(user)); + List result = baseMapper.selectByColumn("id", ids); + assertNotNull(result); + assertEquals(1, result.size()); + verify(baseMapper, times(1)).selectByColumn("id", ids); + } + + @Test + @Order(12) + public void testCountByExample() { + when(baseMapper.countByExample(user)).thenReturn(1L); + Long result = baseMapper.countByExample(user); + assertEquals(1L, result); + verify(baseMapper, times(1)).countByExample(user); + } + + @Test + @Order(14) + public void testExist() { + when(baseMapper.exist(user)).thenReturn(true); + boolean result = baseMapper.exist(user); + assertTrue(result); + verify(baseMapper, times(1)).exist(user); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/DepartmentControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/DepartmentControllerTests.java new file mode 100644 index 0000000..c49a72a --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/DepartmentControllerTests.java @@ -0,0 +1,101 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.dto.request.DepartmentAddRequest; +import cn.cordys.crm.system.dto.request.DepartmentCommanderRequest; +import cn.cordys.crm.system.dto.request.DepartmentRenameRequest; +import cn.cordys.crm.system.dto.request.NodeMoveRequest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; + +import java.util.List; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class DepartmentControllerTests extends BaseTest { + + public static final String DEPARTMENT_TREE = "/department/tree"; + public static final String ADD_DEPARTMENT = "/department/add"; + public static final String RENAME_DEPARTMENT = "/department/rename"; + public static final String DEPARTMENT_SET_COMMANDER = "/department/set-commander"; + public static final String DEPARTMENT_DELETE_CHECK = "/department/delete/check"; + public static final String DEPARTMENT_DELETE = "/department/delete"; + public static final String DEPARTMENT_SORT = "/department/sort"; + + + @Sql(scripts = {"/dml/init_department_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Test + @Order(1) + public void departmentTree() throws Exception { + this.requestGet(DEPARTMENT_TREE).andExpect(status().isOk()); + } + + + @Test + @Order(2) + public void addDepartment() throws Exception { + DepartmentAddRequest request = new DepartmentAddRequest(); + request.setName("测试部门"); + request.setParentId("NONE"); + this.requestPost(ADD_DEPARTMENT, request).andExpect(status().isOk()); + } + + + @Test + @Order(3) + public void renameDepartment() throws Exception { + DepartmentRenameRequest request = new DepartmentRenameRequest(); + request.setName("测试部门"); + request.setId("1"); + this.requestPost(RENAME_DEPARTMENT, request).andExpect(status().isOk()); + + request.setId("12363435234"); + this.requestPost(RENAME_DEPARTMENT, request).andExpect(status().is5xxServerError()); + } + + @Test + @Order(4) + public void departmentCommander() throws Exception { + DepartmentCommanderRequest request = new DepartmentCommanderRequest(); + request.setCommanderId("admin"); + request.setDepartmentId("1"); + this.requestPost(DEPARTMENT_SET_COMMANDER, request).andExpect(status().isOk()); + + } + + + @Test + @Order(5) + public void departmentDeleteCHeck() throws Exception { + this.requestPost(DEPARTMENT_DELETE_CHECK, List.of("7")); + } + + @Test + @Order(6) + public void departmentDelete() throws Exception { + this.requestPost(DEPARTMENT_DELETE, List.of("7")); + this.requestPost(DEPARTMENT_DELETE, List.of("8")); + } + + + @Test + @Order(4) + public void departmentSort() throws Exception { + NodeMoveRequest request = new NodeMoveRequest(); + request.setDropNodeId("4"); + request.setDragNodeId("1"); + request.setDropPosition(1); + this.requestPost(DEPARTMENT_SORT, request).andExpect(status().isOk()); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/ExportTaskCenterControllerTest.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/ExportTaskCenterControllerTest.java new file mode 100644 index 0000000..4109f6d --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/ExportTaskCenterControllerTest.java @@ -0,0 +1,111 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.ExportHeadDTO; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.domain.CustomerField; +import cn.cordys.crm.customer.dto.request.CustomerAddRequest; +import cn.cordys.crm.customer.dto.request.CustomerExportRequest; +import cn.cordys.crm.system.domain.ModuleField; +import cn.cordys.crm.system.domain.ModuleForm; +import cn.cordys.crm.system.dto.request.ExportTaskCenterQueryRequest; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.Strings; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.ArrayList; +import java.util.List; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class ExportTaskCenterControllerTest extends BaseTest { + + private static final String LIST = "/export/center/list"; + private static final String CANCEL = "/export/center/cancel/"; + private static final String DOWN_LOAD = "/export/center/download/"; + + @Resource + private BaseMapper customerMapper; + + @Resource + private BaseMapper customerFieldMapper; + + @Resource + private BaseMapper moduleFieldMapper; + @Resource + private BaseMapper moduleFormMapper; + + + @Order(1) + @Test + void testList() throws Exception { + ExportTaskCenterQueryRequest exportTaskCenterQueryRequest = new ExportTaskCenterQueryRequest(); + this.requestPostWithOk(LIST, exportTaskCenterQueryRequest); + } + + private ModuleForm getModuleForm() { + ModuleForm example = new ModuleForm(); + example.setOrganizationId(DEFAULT_ORGANIZATION_ID); + example.setFormKey(FormKey.CUSTOMER.getKey()); + return moduleFormMapper.selectOne(example); + } + + @Order(1) + @Test + void testCancel() throws Exception { + this.requestGet(CANCEL + "/rr").andExpect(status().is5xxServerError()); + ModuleForm moduleForm = getModuleForm(); + + ModuleField example = new ModuleField(); + example.setFormId(moduleForm.getId()); + ModuleField moduleField = moduleFieldMapper.select(example) + .stream() + .filter(field -> Strings.CS.equals(field.getInternalKey(), "customerLevel")) + .findFirst().orElse(null); + + // 请求成功 + CustomerAddRequest request = new CustomerAddRequest(); + request.setName("testTask"); + request.setOwner("bb"); + request.setModuleFields(List.of(new BaseModuleFieldValue(moduleField.getId(), "1"))); + + this.requestPostWithOkAndReturn("/account/add", request); + + CustomerExportRequest requestE = new CustomerExportRequest(); + requestE.setCurrent(1); + requestE.setPageSize(10); + requestE.setFileName("测试导出客户"); + + ExportHeadDTO exportHeadDTO = new ExportHeadDTO(); + exportHeadDTO.setKey("name"); + exportHeadDTO.setTitle("客户名称"); + List list = new ArrayList<>(); + list.add(exportHeadDTO); + requestE.setHeadList(list); + MvcResult mvcResult2 = this.requestPostWithOkAndReturn("/account/export-all", requestE); + String resultData = getResultData(mvcResult2, String.class); + this.requestGetWithOk(CANCEL + resultData); + Thread.sleep(1500); + mvcResult2 = this.requestPostWithOkAndReturn("/account/export-all", requestE); + resultData = getResultData(mvcResult2, String.class); + Thread.sleep(1500); + // 等待导出任务完成 + this.requestGetStreamWithOk(DOWN_LOAD + resultData); + + + } + + +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/LocaleLanguageControllerTest.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/LocaleLanguageControllerTest.java new file mode 100644 index 0000000..88a4b1e --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/LocaleLanguageControllerTest.java @@ -0,0 +1,28 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.dto.request.LocaleLanguageRequest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class LocaleLanguageControllerTest extends BaseTest { + + public static final String LANGUAGE_CHANGE = "/locale-language/change"; + + @Test + @Order(1) + public void localeLanguageChange() throws Exception { + LocaleLanguageRequest request = new LocaleLanguageRequest(); + request.setLanguage("en-US"); + this.requestPost(LANGUAGE_CHANGE, request).andExpect(status().isOk()); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/LoginControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/LoginControllerTests.java new file mode 100644 index 0000000..be14d8e --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/LoginControllerTests.java @@ -0,0 +1,65 @@ +package cn.cordys.crm.system.controller; + + +import cn.cordys.common.util.rsa.RsaKey; +import cn.cordys.common.util.rsa.RsaUtils; +import cn.cordys.crm.system.domain.OrganizationUser; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@Slf4j +public class LoginControllerTests { + @Resource + private MockMvc mockMvc; + @Resource + private BaseMapper organizationUserMapper; + + @Test + @Sql(scripts = {"/dml/init_user_login_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + public void testLogin() throws Exception { + RsaKey rsaKey = RsaUtils.getRsaKey(); + String password = RsaUtils.publicEncrypt("test.login", rsaKey.getPublicKey()); + String username = RsaUtils.publicEncrypt("test.login@cordys.io", rsaKey.getPublicKey()); + + // 1. 正常登录 + String login = "/login"; + MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(login) + .content(""" + { + "username": "%s", + "password": "%s", + "authenticate": "LOCAL", + "loginAddress": "LOCAL", + "platform": "LOCAL" + } + """.formatted(username, password)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andReturn(); + + // 验证返回结果 + String contentAsString = mvcResult.getResponse().getContentAsString(); + log.info(contentAsString); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/LoginLogControllerTest.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/LoginLogControllerTest.java new file mode 100644 index 0000000..b5dfdab --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/LoginLogControllerTest.java @@ -0,0 +1,40 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.dto.SortRequest; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.dto.request.LoginLogRequest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class LoginLogControllerTest extends BaseTest { + public static final String LOGIN_LOG_LIST = "/login/log/list"; + + private LoginLogRequest buildRequest() { + LoginLogRequest request = new LoginLogRequest(); + request.setCurrent(1); + request.setPageSize(10); + request.setStartTime(1735808851000L); + request.setEndTime(1735890402193L); + return request; + } + + @Test + @Order(2) + public void operationLogLoginList() throws Exception { + LoginLogRequest request = buildRequest(); + this.requestPost(LOGIN_LOG_LIST, request).andExpect(status().isOk()); + request.setStartTime(1735890402193L); + request.setEndTime(1735808851000L); + request.setSort(new SortRequest("id", "desc")); + this.requestPost(LOGIN_LOG_LIST, request); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/MessageTaskControllerTest.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/MessageTaskControllerTest.java new file mode 100644 index 0000000..893b08e --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/MessageTaskControllerTest.java @@ -0,0 +1,159 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.RoleDataScope; +import cn.cordys.common.response.handler.ResultHolder; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.domain.MessageTask; +import cn.cordys.crm.system.domain.Role; +import cn.cordys.crm.system.dto.MessageTaskConfigDTO; +import cn.cordys.crm.system.dto.MessageTaskConfigWithNameDTO; +import cn.cordys.crm.system.dto.TimeDTO; +import cn.cordys.crm.system.dto.request.MessageTaskBatchRequest; +import cn.cordys.crm.system.dto.request.MessageTaskConfigRequest; +import cn.cordys.crm.system.dto.request.MessageTaskRequest; +import cn.cordys.crm.system.mapper.ExtMessageTaskMapper; +import cn.cordys.crm.system.notice.CommonNoticeSendService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class MessageTaskControllerTest extends BaseTest { + + public static final String SAVE_MESSAGE_TASK = "/message/task/save"; + public static final String BATCH_SAVE_MESSAGE_TASK = "/message/task/batch/save"; + + public static final String GET_MESSAGE_TASK = "/message/task/get"; + + public static final String GET_MESSAGE_TASK_CONFIG = "/message/task/config/query"; + + + @Resource + private ExtMessageTaskMapper extMessageTaskMapper; + @Resource + private CommonNoticeSendService commonNoticeSendService; + @Resource + private BaseMapper roleBaseMapper; + + @Test + @Order(1) + void testSaveMessageTask() throws Exception { + //新增一个角色用于测试,每个属性都赋值,internal是false + Role role = new Role(); + role.setId("test_role_id"); + role.setName("测试自定义角色"); + role.setInternal(false); + role.setOrganizationId(DEFAULT_ORGANIZATION_ID); + role.setDataScope(RoleDataScope.DEPT_AND_CHILD.name()); + role.setCreateTime(System.currentTimeMillis()); + role.setUpdateTime(System.currentTimeMillis()); + role.setCreateUser("admin"); + role.setUpdateUser("admin"); + roleBaseMapper.insert(role); + + MessageTaskRequest messageTaskRequest = new MessageTaskRequest(); + messageTaskRequest.setModule(NotificationConstants.Module.CUSTOMER); + messageTaskRequest.setEvent(NotificationConstants.Event.CUSTOMER_MOVED_HIGH_SEAS); + messageTaskRequest.setEmailEnable(true); + messageTaskRequest.setSysEnable(true); + MessageTaskConfigDTO messageTaskConfigDTO = getMessageTaskConfigDTO(); + messageTaskRequest.setConfig(messageTaskConfigDTO); + MvcResult mvcResult = this.requestPostWithOkAndReturn(SAVE_MESSAGE_TASK, messageTaskRequest); + String updateReturnData = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + ResultHolder resultHolder = JSON.parseObject(updateReturnData, ResultHolder.class); + MessageTask messageTask = JSON.parseObject(JSON.toJSONString(resultHolder.getData()), MessageTask.class); + Assertions.assertNotNull(messageTask); + MessageTask messageTask1 = extMessageTaskMapper.getMessageByModuleAndEvent(messageTaskRequest.getModule(), messageTaskRequest.getEvent(), DEFAULT_ORGANIZATION_ID); + Assertions.assertNotNull(messageTask1); + messageTaskRequest = new MessageTaskRequest(); + messageTaskRequest.setModule(NotificationConstants.Module.CUSTOMER); + messageTaskRequest.setEvent(NotificationConstants.Event.CUSTOMER_MOVED_HIGH_SEAS); + messageTaskRequest.setEmailEnable(false); + messageTaskRequest.setSysEnable(true); + MvcResult mvcResult1 = this.requestPostWithOkAndReturn(SAVE_MESSAGE_TASK, messageTaskRequest); + String updateReturnData1 = mvcResult1.getResponse().getContentAsString(StandardCharsets.UTF_8); + ResultHolder resultHolder1 = JSON.parseObject(updateReturnData1, ResultHolder.class); + MessageTask messageTask2 = JSON.parseObject(JSON.toJSONString(resultHolder1.getData()), MessageTask.class); + Assertions.assertNotNull(messageTask2); + Map resource = new HashMap<>(); + resource.put("relatedUsers", "aa"); + resource.put("name", "cc"); + commonNoticeSendService.sendNotice(NotificationConstants.Module.CUSTOMER, NotificationConstants.Event.CUSTOMER_MOVED_HIGH_SEAS, resource, "admin", DEFAULT_ORGANIZATION_ID, List.of("aa"), true); + + messageTaskRequest.setSysEnable(false); + messageTaskRequest.setWeComEnable(true); + mvcResult1 = this.requestPostWithOkAndReturn(SAVE_MESSAGE_TASK, messageTaskRequest); + updateReturnData1 = mvcResult1.getResponse().getContentAsString(StandardCharsets.UTF_8); + resultHolder1 = JSON.parseObject(updateReturnData1, ResultHolder.class); + messageTask2 = JSON.parseObject(JSON.toJSONString(resultHolder1.getData()), MessageTask.class); + Assertions.assertNotNull(messageTask2); + resource = new HashMap<>(); + resource.put("relatedUsers", "aa"); + resource.put("name", "cc"); + commonNoticeSendService.sendNotice(NotificationConstants.Module.CUSTOMER, NotificationConstants.Event.CUSTOMER_MOVED_HIGH_SEAS, resource, "admin", DEFAULT_ORGANIZATION_ID, List.of("aa"), true); + + + } + + private @NotNull MessageTaskConfigDTO getMessageTaskConfigDTO() { + MessageTaskConfigDTO messageTaskConfigDTO = new MessageTaskConfigDTO(); + TimeDTO timeDTO = new TimeDTO(); + timeDTO.setTimeValue(3); + timeDTO.setTimeUnit("DAY"); + List timeDTOList = List.of(timeDTO); + messageTaskConfigDTO.setTimeList(timeDTOList); + messageTaskConfigDTO.setOwnerEnable(true); + messageTaskConfigDTO.setOwnerLevel(0); + messageTaskConfigDTO.setUserIds(List.of("OWNER")); + messageTaskConfigDTO.setRoleEnable(true); + messageTaskConfigDTO.setRoleIds(List.of("org_admin", "test_role_id")); + return messageTaskConfigDTO; + } + + @Test + @Order(2) + void testGetMessageTask() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(GET_MESSAGE_TASK); + String updateReturnData = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + ResultHolder resultHolder = JSON.parseObject(updateReturnData, ResultHolder.class); + List messageTasks = JSON.parseArray(JSON.toJSONString(resultHolder.getData()), MessageTask.class); + Assertions.assertFalse(messageTasks.isEmpty()); + } + + @Test + @Order(3) + void testBatchSaveMessageTask() throws Exception { + MessageTaskBatchRequest messageTaskRequest = new MessageTaskBatchRequest(); + messageTaskRequest.setEmailEnable(true); + messageTaskRequest.setSysEnable(true); + this.requestPostWithOk(BATCH_SAVE_MESSAGE_TASK, messageTaskRequest); + MessageTask messageTask1 = extMessageTaskMapper.getMessageByModuleAndEvent(NotificationConstants.Module.CUSTOMER, NotificationConstants.Event.CUSTOMER_MOVED_HIGH_SEAS, DEFAULT_ORGANIZATION_ID); + Assertions.assertTrue(messageTask1.getEmailEnable()); + } + + @Test + @Order(4) + void testGetMessageTaskConfig() throws Exception { + MessageTaskConfigRequest messageTaskConfigRequest = new MessageTaskConfigRequest(); + messageTaskConfigRequest.setModule(NotificationConstants.Module.CUSTOMER); + messageTaskConfigRequest.setEvent(NotificationConstants.Event.CUSTOMER_MOVED_HIGH_SEAS); + MvcResult mvcResult = this.requestPostWithOkAndReturn(GET_MESSAGE_TASK_CONFIG, messageTaskConfigRequest); + String updateReturnData = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + ResultHolder resultHolder = JSON.parseObject(updateReturnData, ResultHolder.class); + MessageTaskConfigWithNameDTO messageTaskConfigDTO = JSON.parseObject(JSON.toJSONString(resultHolder.getData()), MessageTaskConfigWithNameDTO.class); + Assertions.assertNotNull(messageTaskConfigDTO); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/ModuleControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/ModuleControllerTests.java new file mode 100644 index 0000000..5395355 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/ModuleControllerTests.java @@ -0,0 +1,73 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.domain.Module; +import cn.cordys.crm.system.dto.request.ModuleRequest; +import cn.cordys.crm.system.dto.request.ModuleSortRequest; +import cn.cordys.crm.system.dto.response.ModuleDTO; +import cn.cordys.crm.system.service.ModuleService; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class ModuleControllerTests extends BaseTest { + + private static final String LIST_ALL = "/module/list"; + private static final String SWITCH = "/module/switch/"; + private static final String SORT = "/module/sort"; + + @Resource + private ModuleService moduleService; + @Resource + private BaseMapper moduleMapper; + + @Test + @Order(1) + void testInitModuleList() { + moduleService.initModule("default"); + List modules = moduleMapper.selectAll("pos"); + assert !modules.isEmpty(); + } + + @Test + @Order(2) + void testGetModuleListAndSwitch() throws Exception { + ModuleRequest request = new ModuleRequest(); + request.setOrganizationId("default"); + MvcResult mvcResult = this.requestPostWithOkAndReturn(LIST_ALL, request); + List modules = getResultDataArray(mvcResult, ModuleDTO.class); + assert !modules.isEmpty(); + String param = modules.getFirst().getId(); + this.requestGetWithOk(SWITCH + param); + // permission check + requestGetPermissionTest(PermissionConstants.MODULE_SETTING_UPDATE, SWITCH + param); + // switch not exist module + MvcResult mvcResult1 = this.requestGet(SWITCH + "none").andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult1.getResponse().getContentAsString().contains(Translator.get("module.not_exist")); + ModuleSortRequest sortRequest = new ModuleSortRequest(); + sortRequest.setDragModuleId(param); + sortRequest.setStart(1L); + sortRequest.setEnd(3L); + this.requestPostWithOk(SORT, sortRequest); + sortRequest.setStart(3L); + sortRequest.setEnd(1L); + this.requestPostWithOk(SORT, sortRequest); + // permission check + requestPostPermissionTest(PermissionConstants.MODULE_SETTING_UPDATE, SORT, sortRequest); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/ModuleFieldControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/ModuleFieldControllerTests.java new file mode 100644 index 0000000..e7f51f1 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/ModuleFieldControllerTests.java @@ -0,0 +1,124 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.clue.dto.request.CluePageRequest; +import cn.cordys.crm.customer.dto.request.CustomerContactPageRequest; +import cn.cordys.crm.customer.dto.request.CustomerPageRequest; +import cn.cordys.crm.opportunity.dto.request.OpportunityPageRequest; +import cn.cordys.crm.product.dto.request.ProductPageRequest; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.domain.ModuleField; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class ModuleFieldControllerTests extends BaseTest { + + public static final String BASE_PATH = "/field"; + public static final String DEPT_TREE = "/dept/tree"; + public static final String USER_DEPT_TREE = "/user/dept/tree"; + public static final String CLUE_SOURCE_DATA = "/source/lead"; + public static final String CUSTOMER_SOURCE_DATA = "/source/account"; + public static final String CONTACT_SOURCE_DATA = "/source/contact"; + public static final String OPPORTUNITY_SOURCE_DATA = "/source/opportunity"; + public static final String PRODUCT_SOURCE_DATA = "/source/product"; + public static String FIELD_ID = "field-test-id"; + @Resource + private BaseMapper moduleFieldMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void initData() { + ModuleField field = new ModuleField(); + field.setId(FIELD_ID); + field.setFormId("form-test-1"); + field.setName("dep-test-1"); + field.setMobile(false); + field.setType(FieldType.DEPARTMENT.name()); + field.setPos(1L); + field.setCreateUser("admin"); + field.setCreateTime(System.currentTimeMillis()); + field.setUpdateUser("admin"); + field.setUpdateTime(System.currentTimeMillis()); + moduleFieldMapper.insert(field); + } + + @Test + @Order(2) + void testGetDeptTree() throws Exception { + this.requestGetWithOk(DEPT_TREE); + } + + @Test + @Order(3) + void testGetUserDeptTree() throws Exception { + this.requestGetWithOk(USER_DEPT_TREE); + } + + @Test + @Order(4) + void resetFieldData() { + ModuleField field = new ModuleField(); + field.setId(FIELD_ID); + field.setType(FieldType.DATA_SOURCE.name()); + moduleFieldMapper.updateById(field); + } + + @Test + @Order(5) + void testListClueSourceData() throws Exception { + CluePageRequest cluePageRequest = new CluePageRequest(); + cluePageRequest.setCurrent(1); + cluePageRequest.setPageSize(10); + this.requestPostWithOk(CLUE_SOURCE_DATA, cluePageRequest); + } + + @Test + @Order(6) + void testListCustomerSourceData() throws Exception { + CustomerPageRequest customerPageRequest = new CustomerPageRequest(); + customerPageRequest.setCurrent(1); + customerPageRequest.setPageSize(10); + this.requestPostWithOk(CUSTOMER_SOURCE_DATA, customerPageRequest); + } + + @Test + @Order(7) + void testListContactSourceData() throws Exception { + CustomerContactPageRequest contactPageRequest = new CustomerContactPageRequest(); + contactPageRequest.setCurrent(1); + contactPageRequest.setPageSize(10); + this.requestPostWithOk(CONTACT_SOURCE_DATA, contactPageRequest); + } + + @Test + @Order(8) + void testListOpportunitySourceData() throws Exception { + OpportunityPageRequest opportunityPageRequest = new OpportunityPageRequest(); + opportunityPageRequest.setCurrent(1); + opportunityPageRequest.setPageSize(10); + this.requestPostWithOk(OPPORTUNITY_SOURCE_DATA, opportunityPageRequest); + } + + @Test + @Order(9) + void testListProductSourceData() throws Exception { + ProductPageRequest productPageRequest = new ProductPageRequest(); + productPageRequest.setCurrent(1); + productPageRequest.setPageSize(10); + this.requestPostWithOk(PRODUCT_SOURCE_DATA, productPageRequest); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/ModuleFormControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/ModuleFormControllerTests.java new file mode 100644 index 0000000..b1390b4 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/ModuleFormControllerTests.java @@ -0,0 +1,59 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.constants.FieldType; +import cn.cordys.crm.system.dto.field.SelectField; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.form.FormProp; +import cn.cordys.crm.system.dto.request.ModuleFormSaveRequest; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class ModuleFormControllerTests extends BaseTest { + + public static List fields; + + @Test + @Order(1) + void testGetFieldList() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn("/module/form/config/" + FormKey.CLUE.getKey()); + ModuleFormConfigDTO formConfig = getResultData(mvcResult, ModuleFormConfigDTO.class); + assert formConfig.getFields().size() > 1; + fields = formConfig.getFields(); + } + + @Test + @Order(2) + void testSaveFields() throws Exception { + ModuleFormSaveRequest request = new ModuleFormSaveRequest(); + request.setFormKey("none-key"); + request.setFields(List.of()); + request.setFormProp(new FormProp()); + MvcResult mvcResult = this.requestPost("/module/form/save", request).andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult.getResponse().getContentAsString().contains(Translator.get("module.form.not_exist")); + request.setFormKey(FormKey.CLUE.getKey()); + request.setFields(fields); + this.requestPostWithOk("/module/form/save", request); + BaseField field = new SelectField(); + field.setId("select-id"); + field.setType(FieldType.SELECT.name()); + request.setFields(List.of(field)); + MvcResult mvcResult1 = this.requestPost("/module/form/save", request).andExpect(status().is5xxServerError()).andReturn(); + assert mvcResult1.getResponse().getContentAsString().contains(Translator.get("module.form.business_field.deleted")); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/NotificationControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/NotificationControllerTests.java new file mode 100644 index 0000000..233986d --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/NotificationControllerTests.java @@ -0,0 +1,157 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.response.handler.ResultHolder; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.constants.NotificationConstants; +import cn.cordys.crm.system.domain.Notification; +import cn.cordys.crm.system.dto.request.NotificationRequest; +import cn.cordys.crm.system.dto.response.NotificationDTO; +import cn.cordys.crm.system.mapper.ExtNotificationMapper; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class NotificationControllerTests extends BaseTest { + + public static final String NOTIFICATION_LIST_PAGE = "/notification/list/all/page"; + public static final String NOTIFICATION_READ = "/notification/read/"; + public static final String NOTIFICATION_READ_ALL = "/notification/read/all"; + public static final String NOTIFICATION_COUNT = "/notification/count"; + public static final String NOTIFICATION_LAST_LIST = "/notification/last/list"; + public static final String ANNOUNCEMENT_LAST_LIST = "/notification/last/announcement/list"; + + + @Resource + private ExtNotificationMapper extNotificationMapper; + @Resource + private BaseMapper notificationMapper; + + + void saveNotice(String type) { + Notification notification = new Notification(); + notification.setId(IDGenerator.nextStr()); + notification.setSubject("功能用例更新通知"); + notification.setOperator("admin"); + notification.setOperation("UPDATE"); + notification.setResourceType(type); + notification.setOrganizationId(DEFAULT_ORGANIZATION_ID); + notification.setResourceName("功能用例导入测4"); + notification.setType("SYSTEM_NOTICE"); + notification.setStatus(NotificationConstants.Status.UNREAD.name()); + notification.setCreateTime(System.currentTimeMillis()); + notification.setReceiver("admin"); + notification.setContent("nihao".getBytes()); + notification.setCreateUser("admin"); + notification.setUpdateUser("admin"); + notification.setUpdateTime(System.currentTimeMillis()); + notification.setResourceId("dd"); + notificationMapper.insert(notification); + } + + @Test + @Order(1) + void getNotificationSuccess() throws Exception { + saveNotice("CUSTOMER"); + NotificationRequest notificationRequest = new NotificationRequest(); + notificationRequest.setPageSize(10); + notificationRequest.setCurrent(1); + notificationRequest.setReceiver("admin"); + notificationRequest.setType("SYSTEM_NOTICE"); + notificationRequest.setResourceType("CASE"); + MvcResult mvcResult = this.requestPostWithOkAndReturn(NOTIFICATION_LIST_PAGE, notificationRequest); + Pager> tableData = JSON.parseObject(JSON.toJSONString( + JSON.parseObject(mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + //返回值的页码和当前页码相同 + Assertions.assertEquals(tableData.getCurrent(), notificationRequest.getCurrent()); + Assertions.assertTrue(tableData.getList().isEmpty()); + } + + @Test + @Order(2) + void setNotificationReadSuccess() throws Exception { + Notification notification = new Notification(); + notification.setStatus(NotificationConstants.Status.UNREAD.name()); + notification.setOrganizationId(DEFAULT_ORGANIZATION_ID); + List notifications = extNotificationMapper.selectByAnyOne(notification); + this.requestGetWithOkAndReturn(NOTIFICATION_READ + notifications.getFirst().getId()); + notification.setStatus(NotificationConstants.Status.READ.name()); + extNotificationMapper.updateByReceiver(notification); + List readNotifications = extNotificationMapper.selectByAnyOne(notification); + Assertions.assertFalse(readNotifications.isEmpty()); + + } + + @Test + @Order(3) + void setNotificationReadAll() throws Exception { + saveNotice("CUSTOMER"); + this.requestGetWithOk(NOTIFICATION_READ_ALL); + Notification notification = new Notification(); + notification.setStatus(NotificationConstants.Status.READ.name()); + notification.setOrganizationId(DEFAULT_ORGANIZATION_ID); + List notifications = extNotificationMapper.selectByAnyOne(notification); + Assertions.assertFalse(notifications.isEmpty()); + + } + + @Test + @Order(4) + void getNotificationCount() throws Exception { + saveNotice("CUSTOMER"); + saveNotice("CLUE"); + saveNotice("BUSINESS"); + saveNotice("ANNOUNCEMENT"); + NotificationRequest notificationRequest = new NotificationRequest(); + notificationRequest.setPageSize(10); + notificationRequest.setCurrent(1); + notificationRequest.setReceiver("admin"); + notificationRequest.setType("SYSTEM_NOTICE"); + MvcResult mvcResult = this.requestPostWithOkAndReturn(NOTIFICATION_COUNT, notificationRequest); + String updateReturnData = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + ResultHolder resultHolder = JSON.parseObject(updateReturnData, ResultHolder.class); + List options = JSON.parseArray(JSON.toJSONString(resultHolder.getData()), OptionDTO.class); + Assertions.assertFalse(options.isEmpty()); + } + + @Test + @Order(5) + void getNotificationLast() throws Exception { + saveNotice("CUSTOMER"); + saveNotice("CLUE"); + saveNotice("BUSINESS"); + saveNotice("ANNOUNCEMENT"); + MvcResult mvcResult = this.requestGetWithOkAndReturn(NOTIFICATION_LAST_LIST); + String updateReturnData = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + ResultHolder resultHolder = JSON.parseObject(updateReturnData, ResultHolder.class); + List options = JSON.parseArray(JSON.toJSONString(resultHolder.getData()), NotificationDTO.class); + Assertions.assertFalse(options.isEmpty()); + } + + @Test + @Order(6) + void getNotificationAnnLast() throws Exception { + saveNotice("ANNOUNCEMENT_NOTICE"); + saveNotice("ANNOUNCEMENT_NOTICE"); + MvcResult mvcResult = this.requestGetWithOkAndReturn(ANNOUNCEMENT_LAST_LIST); + String updateReturnData = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + ResultHolder resultHolder = JSON.parseObject(updateReturnData, ResultHolder.class); + List options = JSON.parseArray(JSON.toJSONString(resultHolder.getData()), NotificationDTO.class); + Assertions.assertFalse(options.isEmpty()); + } + + +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/OperationLogControllerTest.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/OperationLogControllerTest.java new file mode 100644 index 0000000..5688866 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/OperationLogControllerTest.java @@ -0,0 +1,52 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.dto.SortRequest; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.dto.request.OperationLogRequest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class OperationLogControllerTest extends BaseTest { + public static final String OPERATION_LOG_LIST = "/operation/log/list"; + public static final String OPERATION_LOG_DETAIL = "/operation/log/detail/"; + + @Sql(scripts = {"/dml/init_operation_log_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Test + @Order(1) + public void operationLogList() throws Exception { + OperationLogRequest request = buildRequest(); + this.requestPost(OPERATION_LOG_LIST, request).andExpect(status().isOk()); + request.setStartTime(1735890402193L); + request.setEndTime(1735808851000L); + request.setSort(new SortRequest("id", "desc")); + this.requestPost(OPERATION_LOG_LIST, request); + } + + private OperationLogRequest buildRequest() { + OperationLogRequest request = new OperationLogRequest(); + request.setCurrent(1); + request.setPageSize(10); + request.setStartTime(1735808851000L); + request.setEndTime(1735890402193L); + return request; + } + + @Test + @Order(3) + public void operationLogDetail() throws Exception { + this.requestGet(OPERATION_LOG_DETAIL + "123"); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/OrganizationSettingsControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/OrganizationSettingsControllerTests.java new file mode 100644 index 0000000..a6ad5b1 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/OrganizationSettingsControllerTests.java @@ -0,0 +1,83 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.constants.OrganizationConfigConstants; +import cn.cordys.crm.system.domain.OrganizationConfig; +import cn.cordys.crm.system.domain.OrganizationConfigDetail; +import cn.cordys.crm.system.dto.response.EmailDTO; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigDetailMapper; +import cn.cordys.crm.system.mapper.ExtOrganizationConfigMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class OrganizationSettingsControllerTests extends BaseTest { + + @Resource + private ExtOrganizationConfigDetailMapper extOrganizationConfigDetailMapper; + + @Resource + private ExtOrganizationConfigMapper extOrganizationConfigMapper; + + + @Test + @Order(1) + public void testAdd() throws Exception { + EmailDTO emailDTO = new EmailDTO(); + emailDTO.setHost("smtp.163.com"); + emailDTO.setPort("25"); + emailDTO.setPassword("test"); + this.requestPost("/organization/settings/email/edit", emailDTO).andExpect(status().isOk()); + + } + + @Test + @Order(2) + public void testEdit() throws Exception { + OrganizationConfig organizationConfig = extOrganizationConfigMapper.getOrganizationConfig(DEFAULT_ORGANIZATION_ID, OrganizationConfigConstants.ConfigType.EMAIL.name()); + OrganizationConfigDetail organizationConfigDetail = extOrganizationConfigDetailMapper.getOrganizationConfigDetail(organizationConfig.getId()); + Assertions.assertNotNull(organizationConfigDetail); + EmailDTO emailDTO = new EmailDTO(); + emailDTO.setHost("smtp.163.com"); + emailDTO.setPort("25"); + emailDTO.setPassword("test"); + emailDTO.setAccount("sddd"); + emailDTO.setFrom("sddd"); + emailDTO.setRecipient("sddd"); + emailDTO.setSsl("true"); + emailDTO.setTsl("true"); + this.requestPost("/organization/settings/email/edit", emailDTO).andExpect(status().isOk()); + + } + + //获取邮件接口的测试 + @Test + @Order(3) + public void testGetEmail() throws Exception { + this.requestGet("/organization/settings/email").andExpect(status().isOk()); + } + + + @Test + @Order(5) + public void testEmailConnect() throws Exception { + EmailDTO emailDTO = new EmailDTO(); + emailDTO.setHost("https://baidu.com"); + emailDTO.setPort("80"); + emailDTO.setAccount("aaa@fit2cloud.com"); + emailDTO.setPassword("test"); + emailDTO.setFrom("aaa@fit2cloud.com"); + emailDTO.setRecipient("aaa@fit2cloud.com"); + emailDTO.setSsl("true"); + emailDTO.setTsl("false"); + this.requestPost("/organization/settings/email/test", emailDTO, status().is5xxServerError()); + } + + +} \ No newline at end of file diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/OrganizationUserControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/OrganizationUserControllerTests.java new file mode 100644 index 0000000..0c398bc --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/OrganizationUserControllerTests.java @@ -0,0 +1,715 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.dto.BaseTreeNode; +import cn.cordys.common.dto.condition.CombineSearch; +import cn.cordys.common.dto.condition.FilterCondition; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.response.handler.ResultHolder; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.common.util.JSON; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.dto.request.*; +import cn.cordys.crm.system.dto.response.UserPageResponse; +import cn.cordys.crm.system.service.DepartmentService; +import cn.cordys.crm.system.service.OrganizationUserService; +import cn.cordys.crm.utils.FileBaseUtils; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.util.LinkedMultiValueMap; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class OrganizationUserControllerTests extends BaseTest { + + public static final String USER_LIST = "/user/list"; + public static final String USER_ADD = "/user/add"; + public static final String USER_DETAIL = "/user/detail/"; + public static final String USER_UPDATE = "/user/update"; + public static final String USER_RESET_PASSWORD = "/user/reset-password/"; + public static final String USER_BATCH_ENABLE = "/user/batch-enable"; + public static final String USER_BATCH_RESET_PASSWORD = "/user/batch/reset-password"; + public static final String USER_BATCH_EDIT = "/user/batch/edit"; + public static final String USER_DOWNLOAD_TEMPLATE = "/user/download/template"; + public static final String USER_IMPORT_PRE_CHECK = "/user/import/pre-check"; + public static final String USER_IMPORT = "/user/import"; + public static final String USER_OPTION = "/user/option"; + public static final String USER_ROLE_OPTION = "/user/role/option"; + public static final String USER_SYNC_CHECK = "/user/sync-check"; + public static final String USER_DELETE = "/user/delete/"; + public static final String USER_DELETE_CHECK = "/user/delete/check/"; + public static final String USER_UPDATE_NAME = "/user/update/name"; + public static final String USER_GET = "/user/get/"; + + + @Sql(scripts = {"/dml/init_user_test.sql"}, + config = @SqlConfig(encoding = "utf-8", transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Test + @Order(1) + public void userList() throws Exception { + UserPageRequest request = new UserPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + request.setDepartmentIds(List.of("8")); + this.requestPost(USER_LIST, request).andExpect(status().isOk()); + + } + + @Test + @Order(2) + public void userAdd() throws Exception { + UserAddRequest request = new UserAddRequest(); + request.setName("test"); + request.setPhone("12345678901"); + request.setGender(true); + request.setEnable(true); + request.setEmail("1@Cordys.com"); + request.setDepartmentId("1"); + request.setRoleIds(List.of("1", "2")); + this.requestPost(USER_ADD, request).andExpect(status().isOk()); + + //格式错误 + request.setEmail("1234"); + this.requestPost(USER_ADD, request).andExpect(status().is4xxClientError()); + //邮箱重复 + request.setEmail("1@Cordys.com"); + this.requestPost(USER_ADD, request).andExpect(status().is5xxServerError()); + //电话重复 + request.setEmail("2@Cordys.com"); + this.requestPost(USER_ADD, request).andExpect(status().is5xxServerError()); + } + + + @Test + @Order(3) + public void userDetail() throws Exception { + this.requestGet(USER_DETAIL + "u_1").andExpect(status().isOk()); + } + + @Test + @Order(4) + public void userUpdate() throws Exception { + UserUpdateRequest request = new UserUpdateRequest(); + request.setName("test111"); + request.setPhone("12345633342"); + request.setGender(true); + request.setEnable(true); + request.setEmail("221@Cordys.com"); + request.setDepartmentId("9"); + request.setRoleIds(List.of("1", "2", "3")); + request.setId("u_1"); + this.requestPost(USER_UPDATE, request).andExpect(status().isOk()); + } + + @Test + @Order(4) + public void userUpdateName() throws Exception { + UserUpdateName request = new UserUpdateName(); + request.setName("更新名称"); + request.setUserId("5"); + this.requestPost(USER_UPDATE_NAME, request).andExpect(status().isOk()); + } + + + @Test + @Order(5) + public void resetPassword() throws Exception { + // TODO 密码重置踢出用户导致后续测试失败 + // this.requestGet(USER_RESET_PASSWORD + "5").andExpect(status().isOk()); + } + + @Test + @Order(6) + public void batchEnable() throws Exception { + UserBatchEnableRequest request = new UserBatchEnableRequest(); + request.setEnable(true); + request.setIds(List.of("u_1", "u_2")); + // TODO 密码重置踢出用户导致后续测试失败,重新登录 + // this.requestPost(USER_BATCH_ENABLE, request).andExpect(status().isOk()); + } + + @Test + @Order(7) + public void batchResetPassword() throws Exception { + UserBatchRequest request = new UserBatchRequest(); + request.setIds(List.of("u_1", "u_2")); + + // TODO 密码重置踢出用户导致后续测试失败,重新登录 + // this.requestPost(USER_BATCH_RESET_PASSWORD, request).andExpect(status().isOk()); + } + + @Test + @Order(9) + public void batchEdit() throws Exception { + UserBatchEditRequest request = new UserBatchEditRequest(); + request.setIds(List.of("u_1", "u_2")); + request.setWorkCity("深圳"); + // TODO 密码重置踢出用户导致后续测试失败,重新登录 + // this.requestPost(USER_BATCH_EDIT, request).andExpect(status().isOk()); + } + + + @Test + @Order(10) + public void downloadTemplate() throws Exception { + // this.requestGetExcel(USER_DOWNLOAD_TEMPLATE); + } + + private MvcResult requestGetExcel(String url) throws Exception { + return mockMvc.perform(getRequestBuilder(url)) + .andExpect(status().isOk()).andReturn(); + } + + @Test + @Order(11) + public void testImportCheckExcel() throws Exception { + String filePath = Objects.requireNonNull(this.getClass().getClassLoader().getResource("file/user.xlsx")).getPath(); + MockMultipartFile file = new MockMultipartFile("file", "11.xlsx", MediaType.APPLICATION_OCTET_STREAM_VALUE, FileBaseUtils.getFileBytes(filePath)); + LinkedMultiValueMap paramMap = new LinkedMultiValueMap<>(); + paramMap.add("file", file); + this.requestMultipart(USER_IMPORT_PRE_CHECK, paramMap); + + } + + @Test + @Order(12) + public void testImportExcel() throws Exception { + String filePath = Objects.requireNonNull(this.getClass().getClassLoader().getResource("file/user1.xlsx")).getPath(); + MockMultipartFile file = new MockMultipartFile("file", "1111.xlsx", MediaType.APPLICATION_OCTET_STREAM_VALUE, FileBaseUtils.getFileBytes(filePath)); + LinkedMultiValueMap paramMap = new LinkedMultiValueMap<>(); + paramMap.add("file", file); + this.requestMultipart(USER_IMPORT, paramMap); + + } + + @Test + @Order(13) + public void testUserOption() throws Exception { + this.requestGet(USER_OPTION).andExpect(status().isOk()); + } + + @Test + @Order(14) + public void testUserRoleOption() throws Exception { + this.requestGet(USER_ROLE_OPTION).andExpect(status().isOk()); + } + + @Test + @Order(15) + public void testSyncCheck() throws Exception { + this.requestGet(USER_SYNC_CHECK).andExpect(status().isOk()); + } + + @Test + @Order(16) + public void testUserDelete() throws Exception { + this.requestGet(USER_DELETE + "u_5").andExpect(status().isOk()); + } + + @Test + @Order(15) + public void testUserDeleteCheck() throws Exception { + this.requestGet(USER_DELETE_CHECK + "u_5").andExpect(status().isOk()); + } + + @Test + @Order(15) + public void testUserGet() throws Exception { + this.requestGet(USER_GET + "9").andExpect(status().isOk()); + } + + //测试高级搜索 + @Test + @Order(16) + public void testUserListForCombineSearch() throws Exception { + + List allDeptIdList = new ArrayList<>(); + String superiorDeptId; + AtomicReference deptId1 = new AtomicReference<>(), deptId2 = new AtomicReference<>(); + + long startTestTime = System.currentTimeMillis(); + + // 准备数据 + { + DepartmentService departmentService = CommonBeanFactory.getBean(DepartmentService.class); + OrganizationUserService organizationUserService = CommonBeanFactory.getBean(OrganizationUserService.class); + Assertions.assertNotNull(departmentService); + Assertions.assertNotNull(organizationUserService); + + DepartmentAddRequest deptAddRequest = new DepartmentAddRequest(); + deptAddRequest.setName("高级搜索测试节点1"); + deptAddRequest.setParentId("NONE"); + this.requestPost(DepartmentControllerTests.ADD_DEPARTMENT, deptAddRequest).andExpect(status().isOk()); + deptAddRequest.setName("高级搜索测试节点2"); + this.requestPost(DepartmentControllerTests.ADD_DEPARTMENT, deptAddRequest).andExpect(status().isOk()); + + MvcResult orgTreeResult = this.requestGetWithOkAndReturn(DepartmentControllerTests.DEPARTMENT_TREE); + List departmentTree = JSON.parseArray( + JSON.toJSONString( + JSON.parseObject( + orgTreeResult.getResponse().getContentAsString(StandardCharsets.UTF_8), + ResultHolder.class + ).getData()), + BaseTreeNode.class); + + departmentTree.forEach(node -> { + allDeptIdList.add(node.getId()); + if (node.getName().equals("高级搜索测试节点1")) { + deptId1.set(node.getId()); + } else if (node.getName().equals("高级搜索测试节点2")) { + deptId2.set(node.getId()); + } + }); + + + MvcResult userPageResult = this.requestPostWithOkAndReturn(USER_LIST, new UserPageRequest() {{ + setCurrent(1); + setPageSize(10); + setDepartmentIds(allDeptIdList); + }}); + + Pager> userResponse = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject( + userPageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), + ResultHolder.class).getData()), + Pager.class); + UserPageResponse lastUser = JSON.parseObject( + JSON.toJSONString(userResponse.getList().getLast()), + UserPageResponse.class); + superiorDeptId = lastUser.getId(); + + UserAddRequest userAddRequest; + for (int i = 0; i < 20; i++) { + userAddRequest = new UserAddRequest(); + userAddRequest.setName("combineTest_" + i); + userAddRequest.setEnable(true); + userAddRequest.setEmail("combineTest_" + i + "@Cordys.com"); + userAddRequest.setRoleIds(List.of("1", "2")); + userAddRequest.setEmployeeId("1000" + i); + + if (i < 10) { + userAddRequest.setPhone("1858888000" + i); + userAddRequest.setDepartmentId(deptId1.get()); + userAddRequest.setGender(true); + userAddRequest.setEmployeeType("formal"); + userAddRequest.setWorkCity("110101"); + userAddRequest.setOnboardingDate(System.currentTimeMillis()); + } else { + userAddRequest.setPhone("183888800" + i); + userAddRequest.setDepartmentId(deptId2.get()); + userAddRequest.setGender(false); + userAddRequest.setSupervisorId(superiorDeptId); + userAddRequest.setEmployeeType("internship"); + } + + if (i % 5 == 0) { + userAddRequest.setEnable(false); + } + userAddRequest.setPosition("测试职位"); + this.requestPost(USER_ADD, userAddRequest).andExpect(status().isOk()); + } + } + + /* + 姓名 userName + 是否启用 enable + 性别 gender + 手机号 phone + 邮箱 email + 部门 departmentName + 直属上级 supervisorId + 工号 employeeId + 职位 position + 员工类型 employeeType + 创建人 createUser + 更新人 updateUser + 入职日期 onboardingDate (动态范围查询) + 创建时间 createTime (动态范围查询) + 更新时间 updateTime (动态范围查询) + 工作城市 workCity (查询方式待商榷) + 入职日期 onboardingDate (查询方式待商榷) + */ + + UserPageRequest request = new UserPageRequest(); + request.setCurrent(1); + request.setPageSize(100); + request.setDepartmentIds(allDeptIdList); + + CombineSearch combineSearch = new CombineSearch(); + List filterConditionList = new ArrayList<>(); + + //全量检查 + MvcResult pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + Pager> result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertTrue(result.getTotal() > 20); + + // 姓名 userName like查询 + FilterCondition likeCondition = new FilterCondition(); + likeCondition.setOperator(FilterCondition.CombineConditionOperator.CONTAINS.name()); + likeCondition.setName("userName"); + likeCondition.setValue("combineTest_"); + filterConditionList.add(likeCondition); + combineSearch.setConditions(filterConditionList); + request.setCombineSearch(combineSearch); + + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertEquals(20, result.getTotal()); + + // 性别 gender + filterConditionList.clear(); + FilterCondition booleanCondition = new FilterCondition(); + booleanCondition.setOperator(FilterCondition.CombineConditionOperator.IN.name()); + booleanCondition.setName("gender"); + booleanCondition.setValue("male"); + filterConditionList.add(booleanCondition); + request.getCombineSearch().setConditions(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertEquals(10, result.getTotal()); + + // 是否启用 enable + filterConditionList.clear(); + booleanCondition = new FilterCondition(); + booleanCondition.setOperator(FilterCondition.CombineConditionOperator.IN.name()); + booleanCondition.setName("status"); + booleanCondition.setValue(false); + filterConditionList.add(booleanCondition); + request.getCombineSearch().setConditions(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertEquals(4, result.getTotal()); + + // 手机号 phoneNumber + filterConditionList.clear(); + likeCondition = new FilterCondition(); + likeCondition.setOperator(FilterCondition.CombineConditionOperator.CONTAINS.name()); + likeCondition.setName("phoneNumber"); + likeCondition.setValue("1858888000"); + filterConditionList.add(likeCondition); + request.getCombineSearch().setConditions(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertEquals(10, result.getTotal()); + // 邮箱 email + filterConditionList.clear(); + likeCondition = new FilterCondition(); + likeCondition.setOperator(FilterCondition.CombineConditionOperator.CONTAINS.name()); + likeCondition.setName("email"); + likeCondition.setValue("combineTest_"); + filterConditionList.add(likeCondition); + request.getCombineSearch().setConditions(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertEquals(20, result.getTotal()); + + // 直属上级 supervisorId + filterConditionList.clear(); + FilterCondition inCondition = new FilterCondition(); + inCondition.setOperator(FilterCondition.CombineConditionOperator.IN.name()); + inCondition.setName("supervisorId"); + inCondition.setValue(List.of(superiorDeptId)); + filterConditionList.add(inCondition); + request.getCombineSearch().setConditions(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertEquals(10, result.getTotal()); + + // 部门 departmentId + filterConditionList.clear(); + inCondition = new FilterCondition(); + inCondition.setOperator(FilterCondition.CombineConditionOperator.IN.name()); + inCondition.setName("departmentId"); + inCondition.setValue(List.of(deptId1, deptId2)); + filterConditionList.add(inCondition); + request.getCombineSearch().setConditions(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertEquals(20, result.getTotal()); + + filterConditionList.clear(); + inCondition = new FilterCondition(); + inCondition.setOperator(FilterCondition.CombineConditionOperator.IN.name()); + inCondition.setName("departmentId"); + inCondition.setValue(List.of(deptId1)); + filterConditionList.add(inCondition); + request.getCombineSearch().setConditions(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertEquals(10, result.getTotal()); + + // 工号 employeeId + filterConditionList.clear(); + likeCondition = new FilterCondition(); + likeCondition.setOperator(FilterCondition.CombineConditionOperator.CONTAINS.name()); + likeCondition.setName("employeeId"); + likeCondition.setValue("1000"); + filterConditionList.add(likeCondition); + request.getCombineSearch().setConditions(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertEquals(20, result.getTotal()); + + // 职位 position + filterConditionList.clear(); + likeCondition = new FilterCondition(); + likeCondition.setOperator(FilterCondition.CombineConditionOperator.CONTAINS.name()); + likeCondition.setName("positionId"); + likeCondition.setValue("测试"); + filterConditionList.add(likeCondition); + request.getCombineSearch().setConditions(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertEquals(20, result.getTotal()); + + // 员工类型 IN查询 formal internship outsourcing + filterConditionList.clear(); + inCondition = new FilterCondition(); + inCondition.setOperator(FilterCondition.CombineConditionOperator.IN.name()); + inCondition.setName("employeeType"); + inCondition.setValue(List.of("formal")); + filterConditionList.add(inCondition); + request.getCombineSearch().setConditions(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertEquals(10, result.getTotal()); + + // 创建人 createUser + filterConditionList.clear(); + inCondition = new FilterCondition(); + inCondition.setOperator(FilterCondition.CombineConditionOperator.IN.name()); + inCondition.setName("createUser"); + inCondition.setValue(List.of("admin")); + filterConditionList.add(inCondition); + request.getCombineSearch().setConditions(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertTrue(result.getTotal() > 20); + + // 更新人 updateUser + filterConditionList.clear(); + inCondition = new FilterCondition(); + inCondition.setOperator(FilterCondition.CombineConditionOperator.IN.name()); + inCondition.setName("updateUser"); + inCondition.setValue(List.of("admin")); + filterConditionList.add(inCondition); + request.getCombineSearch().setConditions(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertTrue(result.getTotal() > 20); + + // 入职日期 onboardingDate + filterConditionList.clear(); + FilterCondition betweenCondition = new FilterCondition(); + betweenCondition.setOperator(FilterCondition.CombineConditionOperator.BETWEEN.name()); + betweenCondition.setName("onboardingDate"); + betweenCondition.setValue(List.of(startTestTime, System.currentTimeMillis())); + filterConditionList.add(betweenCondition); + request.getCombineSearch().setConditions(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertEquals(10, result.getTotal()); + + // 创建时间 createTime + filterConditionList.clear(); + betweenCondition = new FilterCondition(); + betweenCondition.setOperator(FilterCondition.CombineConditionOperator.BETWEEN.name()); + betweenCondition.setName("createTime"); + betweenCondition.setValue(List.of(startTestTime, System.currentTimeMillis())); + filterConditionList.add(betweenCondition); + request.getCombineSearch().setConditions(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertTrue(result.getTotal() >= 20); + + // 更新时间 updateTime + filterConditionList.clear(); + betweenCondition = new FilterCondition(); + betweenCondition.setOperator(FilterCondition.CombineConditionOperator.BETWEEN.name()); + betweenCondition.setName("updateTime"); + betweenCondition.setValue(List.of(startTestTime, System.currentTimeMillis())); + filterConditionList.add(betweenCondition); + request.getCombineSearch().setConditions(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertTrue(result.getTotal() >= 20); + + // 工作城市 workCity + filterConditionList.clear(); + betweenCondition = new FilterCondition(); + betweenCondition.setOperator(FilterCondition.CombineConditionOperator.EQUALS.name()); + betweenCondition.setName("workCity"); + betweenCondition.setValue("110101"); + filterConditionList.add(betweenCondition); + request.getCombineSearch().setConditions(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertEquals(10, result.getTotal()); + + // 表头过滤 + request.getCombineSearch().setConditions(new ArrayList<>()); + + // 工作城市 + filterConditionList.clear(); + inCondition = new FilterCondition(); + inCondition.setOperator(FilterCondition.CombineConditionOperator.IN.name()); + inCondition.setName("workCity"); + inCondition.setValue("110101"); + filterConditionList.add(betweenCondition); + request.setFilters(filterConditionList); + + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertEquals(10, result.getTotal()); + + // 创建时间 + filterConditionList.clear(); + betweenCondition = new FilterCondition(); + betweenCondition.setOperator(FilterCondition.CombineConditionOperator.BETWEEN.name()); + betweenCondition.setName("createTime"); + betweenCondition.setValue(List.of(startTestTime, System.currentTimeMillis())); + filterConditionList.add(betweenCondition); + request.setFilters(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertTrue(result.getTotal() >= 20); + + // 创建人 + filterConditionList.clear(); + inCondition = new FilterCondition(); + inCondition.setOperator(FilterCondition.CombineConditionOperator.IN.name()); + inCondition.setName("createUser"); + inCondition.setValue(List.of("admin")); + filterConditionList.add(inCondition); + request.setFilters(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertTrue(result.getTotal() > 20); + + // 更新时间 + filterConditionList.clear(); + betweenCondition = new FilterCondition(); + betweenCondition.setOperator(FilterCondition.CombineConditionOperator.BETWEEN.name()); + betweenCondition.setName("updateTime"); + betweenCondition.setValue(List.of(startTestTime, System.currentTimeMillis())); + filterConditionList.add(betweenCondition); + request.setFilters(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertTrue(result.getTotal() >= 20); + + // 更新人 + filterConditionList.clear(); + inCondition = new FilterCondition(); + inCondition.setOperator(FilterCondition.CombineConditionOperator.IN.name()); + inCondition.setName("updateUser"); + inCondition.setValue(List.of("admin")); + filterConditionList.add(inCondition); + request.setFilters(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertTrue(result.getTotal() > 20); + + // 入职日期 + filterConditionList.clear(); + betweenCondition = new FilterCondition(); + betweenCondition.setOperator(FilterCondition.CombineConditionOperator.BETWEEN.name()); + betweenCondition.setName("onboardingDate"); + betweenCondition.setValue(List.of(startTestTime, System.currentTimeMillis())); + filterConditionList.add(betweenCondition); + request.setFilters(filterConditionList); + pageResult = this.requestPostWithOkAndReturn(USER_LIST, request); + result = JSON.parseObject( + JSON.toJSONString( + JSON.parseObject(pageResult.getResponse().getContentAsString(StandardCharsets.UTF_8), ResultHolder.class).getData()), + Pager.class); + Assertions.assertEquals(10, result.getTotal()); + + } + +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/PersonalCenterControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/PersonalCenterControllerTests.java new file mode 100644 index 0000000..895db76 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/PersonalCenterControllerTests.java @@ -0,0 +1,289 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.pager.Pager; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.CodingUtils; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.customer.domain.Customer; +import cn.cordys.crm.customer.domain.CustomerContact; +import cn.cordys.crm.follow.dto.request.FollowUpPlanPageRequest; +import cn.cordys.crm.follow.dto.response.FollowUpPlanListResponse; +import cn.cordys.crm.opportunity.constants.StageType; +import cn.cordys.crm.opportunity.domain.Opportunity; +import cn.cordys.crm.system.domain.Department; +import cn.cordys.crm.system.domain.OrganizationUser; +import cn.cordys.crm.system.domain.User; +import cn.cordys.crm.system.dto.request.*; +import cn.cordys.crm.system.dto.response.EmailDTO; +import cn.cordys.crm.system.dto.response.UserPageResponse; +import cn.cordys.crm.system.dto.response.UserResponse; +import cn.cordys.crm.system.mapper.ExtUserMapper; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.web.servlet.MvcResult; + +import java.math.BigDecimal; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class PersonalCenterControllerTests extends BaseTest { + + private static String userId = ""; + @Resource + private StringRedisTemplate stringRedisTemplate; + @Resource + private BaseMapper departmentBaseMapper; + @Resource + private BaseMapper customerBaseMapper; + @Resource + private BaseMapper customerContactBaseMapper; + @Resource + private BaseMapper userBaseMapper; + @Resource + private ExtUserMapper extUserMapper; + @Resource + private BaseMapper organizationUserMapper; + @Resource + private BaseMapper opportunityBaseMapper; + + @Test + @Order(1) + public void testGet() throws Exception { + Department department = new Department(); + department.setId("221"); + department.setName("部门222"); + department.setOrganizationId("100001"); + department.setParentId("4"); + department.setPos(222L); + department.setCreateTime(1736240043609L); + department.setUpdateTime(1736240043609L); + department.setCreateUser("gyq"); + department.setUpdateUser("gyq"); + department.setResource("INTERNAL"); + department.setResourceId(null); + departmentBaseMapper.insert(department); + UserAddRequest request = new UserAddRequest(); + request.setName("testPassword"); + request.setPhone("12345678911"); + request.setGender(true); + request.setEnable(true); + request.setEmail("3Gyq3@Cordys.com"); + request.setDepartmentId("221"); + request.setRoleIds(List.of("1", "2")); + this.requestPostWithOkAndReturn("/user/add", request); + + UserPageRequest pageRequest = new UserPageRequest(); + pageRequest.setCurrent(1); + pageRequest.setPageSize(10); + pageRequest.setDepartmentIds(List.of("221")); + MvcResult mvcResult = this.requestPostWithOkAndReturn("/user/list", pageRequest); + Pager> result = getPageResult(mvcResult, UserPageResponse.class); + UserPageResponse first = result.getList().getFirst(); + Assertions.assertTrue(StringUtils.isNotBlank(first.getUserId())); + userId = first.getId(); + + User user = new User(); + user.setId(userId); + user.setName("fff"); + user.setPhone("12345678911"); + user.setPassword("############"); + user.setEmail("4545@qq.com"); + user.setGender(true); + user.setCreateTime(System.currentTimeMillis()); + user.setCreateUser("admin"); + user.setUpdateTime(System.currentTimeMillis()); + user.setUpdateUser("admin"); + userBaseMapper.insert(user); + OrganizationUser orgUser = new OrganizationUser(); + orgUser.setDepartmentId("221"); + orgUser.setEnable(true); + orgUser.setId(IDGenerator.nextStr()); + orgUser.setOrganizationId(DEFAULT_ORGANIZATION_ID); + orgUser.setUserId(userId); + orgUser.setCreateTime(System.currentTimeMillis()); + orgUser.setCreateUser("admin"); + orgUser.setUpdateTime(System.currentTimeMillis()); + orgUser.setUpdateUser("admin"); + organizationUserMapper.insert(orgUser); + + UserResponse userDetail = extUserMapper.getUserDetail(first.getId()); + Assertions.assertNotNull(userDetail); + + } + + @Test + @Order(2) + public void testSend() throws Exception { + String email = "test@qq.com"; + SendEmailDTO sendEmailDTO = new SendEmailDTO(); + sendEmailDTO.setEmail(email); + + this.requestPost("/personal/center/mail/code/send", sendEmailDTO).andExpect(status().isOk()); + + EmailDTO emailDTO = new EmailDTO(); + emailDTO.setHost("smtp.163.com"); + emailDTO.setPort("465"); + emailDTO.setAccount("Test@163.com"); + emailDTO.setPassword("ABCDEFGHI"); + emailDTO.setSsl("true"); + emailDTO.setTsl("true"); + this.requestPost("/organization/settings/email/edit", emailDTO).andExpect(status().isOk()); + + + this.requestPost("/personal/center/mail/code/send", sendEmailDTO).andExpect(status().isOk()); + + } + + @Test + @Order(3) + public void testUpdateUser() throws Exception { + PersonalInfoRequest personalInfoRequest = new PersonalInfoRequest(); + personalInfoRequest.setPhone("12345678912"); + personalInfoRequest.setEmail("3Gyq356@Cordys.com"); + + this.requestPost("/personal/center/update", personalInfoRequest).andExpect(status().isOk()); + + + } + + + @Test + @Order(4) + public void getRepeatCustomer() throws Exception { + String s = IDGenerator.nextStr(); + Opportunity opportunity = new Opportunity(); + opportunity.setCustomerId(s); + opportunity.setOwner(userId); + opportunity.setName("商机一"); + opportunity.setOrganizationId(DEFAULT_ORGANIZATION_ID); + opportunity.setAmount(BigDecimal.ONE); + opportunity.setPossible(BigDecimal.TEN); + opportunity.setStage(StageType.CREATE.name()); + opportunity.setProducts(List.of("1", "2")); + opportunity.setContactId("admin"); + opportunity.setId(IDGenerator.nextStr()); + opportunity.setCreateTime(System.currentTimeMillis()); + opportunity.setCreateUser("admin"); + opportunity.setUpdateTime(System.currentTimeMillis()); + opportunity.setUpdateUser("admin"); + opportunity.setExpectedEndTime(System.currentTimeMillis()); + opportunityBaseMapper.insert(opportunity); + + Customer customer = new Customer(); + customer.setId(s); + customer.setName("kehu"); + customer.setOwner(userId); + customer.setOrganizationId(DEFAULT_ORGANIZATION_ID); + customer.setCreateTime(System.currentTimeMillis()); + customer.setCreateUser("admin"); + customer.setUpdateTime(System.currentTimeMillis()); + customer.setUpdateUser("admin"); + customer.setInSharedPool(false); + customerBaseMapper.insert(customer); + + CustomerContact customerContact = new CustomerContact(); + customerContact.setId(IDGenerator.nextStr()); + customerContact.setCustomerId(s); + customerContact.setOwner(userId); + customerContact.setName("联系人一"); + customerContact.setOrganizationId(DEFAULT_ORGANIZATION_ID); + customerContact.setCreateTime(System.currentTimeMillis()); + customerContact.setCreateUser("admin"); + customerContact.setUpdateTime(System.currentTimeMillis()); + customerContact.setUpdateUser("admin"); + customerContact.setPhone("12345678912"); + customerContact.setEnable(true); + customerContactBaseMapper.insert(customerContact); + + + RepeatCustomerPageRequest request = new RepeatCustomerPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + request.setName("kehu"); + //this.requestPost("/personal/center/repeat/account", request).andExpect(status().isOk()); + + request = new RepeatCustomerPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + request.setName("12345678912"); + //this.requestPost("/personal/center/repeat/contact", request).andExpect(status().isOk()); + } + + @Test + @Order(5) + void testFollowPlanList() throws Exception { + FollowUpPlanPageRequest request = new FollowUpPlanPageRequest(); + request.setSourceId("NULL"); + request.setCurrent(1); + request.setPageSize(10); + request.setStatus("ALL"); + MvcResult mvcResult = this.requestPostWithOkAndReturn("/personal/center/follow/plan/list", request); + Pager> result = getPageResult(mvcResult, FollowUpPlanListResponse.class); + Assertions.assertNotNull(result.getList()); + } + + + @Test + @Order(6) + public void testAnnouncement() throws Exception { + RepeatCustomerPageRequest request = new RepeatCustomerPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + request.setName("kehu"); + //this.requestPost("/personal/center/repeat/lead", request).andExpect(status().isOk()); + } + + @Test + @Order(7) + public void testAnnouncementDetail() throws Exception { + RepeatCustomerDetailPageRequest request = new RepeatCustomerDetailPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + request.setName("kehu"); + //this.requestPost("/personal/center/repeat/lead/detail", request).andExpect(status().isOk()); + } + + @Test + @Order(8) + public void testRepeatOpportunityDetail() throws Exception { + RepeatCustomerDetailPageRequest request = new RepeatCustomerDetailPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + request.setName("kehu"); + // this.requestPost("/personal/center/repeat/opportunity/detail", request).andExpect(status().isOk()); + } + + @Test + @Order(9) + public void changePassword() throws Exception { + PersonalInfoRequest personalInfoRequest = new PersonalInfoRequest(); + personalInfoRequest.setPhone("4000520755"); + personalInfoRequest.setEmail("admin@cordys-crm.io"); + + this.requestPost("/personal/center/update", personalInfoRequest).andExpect(status().isOk()); + String PREFIX = "personal_email_code:"; // Redis 存储前缀 + stringRedisTemplate.opsForValue().set(PREFIX + "3Gyq3@Cordys.com", "253574", 10, TimeUnit.MINUTES); + PersonalPasswordRequest personalPasswordRequest = new PersonalPasswordRequest(); + personalPasswordRequest.setPassword("Gyq124"); + personalPasswordRequest.setOriginPassword("678911"); + this.requestPost("/personal/center/info/reset", personalPasswordRequest); + extUserMapper.updateUserPassword(CodingUtils.md5(DEFAULT_USER_PASSWORD), "admin"); + // personalPasswordRequest.setPassword(DEFAULT_USER_PASSWORD); + adminAuthInfo = null; + permissionAuthInfo = null; + //DEFAULT_USER_PASSWORD=CodingUtils.md5("Gyq124"); + login(); + + + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/PicControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/PicControllerTests.java new file mode 100644 index 0000000..a33720d --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/PicControllerTests.java @@ -0,0 +1,49 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.crm.base.BaseTest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.util.List; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class PicControllerTests extends BaseTest { + + private static final String BASE_PATH = "/pic"; + + private static final String UPLOAD = "/upload/temp"; + private static final String PREVIEW = "/preview"; + + public static List tempIds; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(1) + void uploadTmpPic() throws Exception { + MockMultipartFile picFile = new MockMultipartFile("pic.jpg", "pic.jpg", "image/jpeg", "hello_world".getBytes()); + MultiValueMap paramMap = new LinkedMultiValueMap<>(); + paramMap.add("files", List.of(picFile)); + MvcResult mvcResult = this.requestMultipart(UPLOAD, paramMap).andReturn(); + tempIds = getResultDataArray(mvcResult, String.class); + } + + @Test + @Order(2) + void previewPic() throws Exception { + this.requestGetStreamWithOk(PREVIEW + "/" + tempIds.getFirst()); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/ProductControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/ProductControllerTests.java new file mode 100644 index 0000000..88deaaf --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/ProductControllerTests.java @@ -0,0 +1,310 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.BusinessModuleField; +import cn.cordys.common.constants.FormKey; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.domain.BaseModuleFieldValue; +import cn.cordys.common.dto.OptionDTO; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.common.pager.Pager; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.product.domain.Product; +import cn.cordys.crm.product.domain.ProductField; +import cn.cordys.crm.product.dto.request.ProductEditRequest; +import cn.cordys.crm.product.dto.request.ProductPageRequest; +import cn.cordys.crm.product.dto.response.ProductGetResponse; +import cn.cordys.crm.product.dto.response.ProductListResponse; +import cn.cordys.crm.system.domain.ModuleField; +import cn.cordys.crm.system.domain.ModuleForm; +import cn.cordys.crm.system.dto.field.base.BaseField; +import cn.cordys.crm.system.dto.request.ResourceBatchEditRequest; +import cn.cordys.crm.system.dto.response.ModuleFormConfigDTO; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.Strings; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ProductControllerTests extends BaseTest { + protected static final String MODULE_FORM = "module/form"; + protected static final String BATCH_UPDATE = "batch/update"; + private static final String BASE_PATH = "/product/"; + private static final List batchIds = new ArrayList<>(); + private static Product addProduct; + private static ModuleFormConfigDTO moduleFormConfig; + + @Resource + private BaseMapper productBaseMapper; + + @Resource + private BaseMapper productFieldBaseMapper; + @Resource + private BaseMapper moduleFieldMapper; + + @Resource + private BaseMapper moduleFormMapper; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(0) + void testModuleField() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(MODULE_FORM); + moduleFormConfig = getResultData(mvcResult, ModuleFormConfigDTO.class); + } + + @Test + @Order(1) + void testPageEmpty() throws Exception { + ProductPageRequest request = new ProductPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + + this.requestPostWithOk(DEFAULT_PAGE, request); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.PRODUCT_MANAGEMENT_READ, DEFAULT_PAGE, request); + } + + private ModuleForm getModuleForm() { + ModuleForm example = new ModuleForm(); + example.setOrganizationId(DEFAULT_ORGANIZATION_ID); + example.setFormKey(FormKey.PRODUCT.getKey()); + return moduleFormMapper.selectOne(example); + } + + @Test + @Order(2) + void testAdd() throws Exception { + // 请求成功 + ProductEditRequest request = new ProductEditRequest(); + request.setName("product"); + request.setPrice(BigDecimal.valueOf(7.23d)); + request.setStatus("1"); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + Product resultData = getResultData(mvcResult, Product.class); + // 校验请求成功数据 + addProduct = productBaseMapper.selectByPrimaryKey(resultData.getId()); + ModuleForm moduleForm = getModuleForm(); + + ModuleField example = new ModuleField(); + example.setFormId(moduleForm.getId()); + List select = moduleFieldMapper.select(example); + ModuleField moduleFieldPrice = select + .stream() + .filter(field -> Strings.CS.equals(field.getInternalKey(), "productPrice")) + .findFirst().orElse(null); + ModuleField moduleFieldStatus = select + .stream() + .filter(field -> Strings.CS.equals(field.getInternalKey(), "productStatus")) + .findFirst().orElse(null); + request = new ProductEditRequest(); + request.setName("productOne"); + request.setPrice(BigDecimal.valueOf(7.23d)); + request.setStatus("1"); + + request.setModuleFields(List.of(new BaseModuleFieldValue(moduleFieldPrice.getId(), 12), new BaseModuleFieldValue(moduleFieldStatus.getId(), "1"))); + + mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + resultData = getResultData(mvcResult, Product.class); + // 校验请求成功数据 + Product product = productBaseMapper.selectByPrimaryKey(resultData.getId()); + batchIds.add(product.getId()); + + request = new ProductEditRequest(); + request.setName("productTwo"); + request.setPrice(BigDecimal.valueOf(7.23d)); + request.setStatus("1"); + request.setModuleFields(List.of(new BaseModuleFieldValue(moduleFieldPrice.getId(), 14), new BaseModuleFieldValue(moduleFieldStatus.getId(), "1"))); + mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + resultData = getResultData(mvcResult, Product.class); + + // 校验请求成功数据 + product = productBaseMapper.selectByPrimaryKey(resultData.getId()); + batchIds.add(product.getId()); + request = new ProductEditRequest(); + request.setName("productThree"); + request.setPrice(BigDecimal.valueOf(7.23d)); + request.setStatus("1"); + request.setModuleFields(List.of(new BaseModuleFieldValue(moduleFieldPrice.getId(), 13), new BaseModuleFieldValue(moduleFieldStatus.getId(), "1"))); + mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + resultData = getResultData(mvcResult, Product.class); + // 校验请求成功数据 + product = productBaseMapper.selectByPrimaryKey(resultData.getId()); + batchIds.add(product.getId()); + // 校验权限 + requestPostPermissionTest(PermissionConstants.PRODUCT_MANAGEMENT_ADD, DEFAULT_ADD, request); + + request = new ProductEditRequest(); + request.setName("productFour"); + request.setPrice(BigDecimal.valueOf(100000000000d)); + request.setStatus("1"); + request.setModuleFields(List.of(new BaseModuleFieldValue(moduleFieldPrice.getId(), 13), new BaseModuleFieldValue(moduleFieldStatus.getId(), "1"))); + this.requestPost(DEFAULT_ADD, request).andExpect(status().is4xxClientError()); + + } + + @Test + @Order(3) + void testUpdate() throws Exception { + // 请求成功 + ProductEditRequest request = new ProductEditRequest(); + request.setName("product"); + request.setId(addProduct.getId()); + request.setPrice(BigDecimal.valueOf(7.23d)); + request.setStatus("2"); + this.requestPostWithOk(DEFAULT_UPDATE, request); + Product product = productBaseMapper.selectByPrimaryKey(addProduct.getId()); + Assertions.assertTrue(Strings.CI.equals(product.getStatus(), "2")); + // 校验权限 + requestPostPermissionTest(PermissionConstants.PRODUCT_MANAGEMENT_UPDATE, DEFAULT_UPDATE, request); + + request.setId(null); + this.requestPost(DEFAULT_UPDATE, request).andExpect(status().is5xxServerError()); + } + + @Test + @Order(4) + void testGet() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(DEFAULT_GET, addProduct.getId()); + ProductGetResponse getResponse = getResultData(mvcResult, ProductGetResponse.class); + + // 校验请求成功数据 + Product product = productBaseMapper.selectByPrimaryKey(addProduct.getId()); + Product responseProduct = BeanUtils.copyBean(new Product(), getResponse); + responseProduct.setOrganizationId(DEFAULT_ORGANIZATION_ID); + responseProduct.setPos(4096L); + Assertions.assertEquals(responseProduct, product); + // 校验权限 + requestGetPermissionTest(PermissionConstants.PRODUCT_MANAGEMENT_READ, DEFAULT_GET, addProduct.getId()); + } + + + @Test + @Order(5) + void testPage() throws Exception { + ProductPageRequest request = new ProductPageRequest(); + request.setCurrent(1); + request.setPageSize(10); + + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_PAGE, request); + Pager> pageResult = getPageResult(mvcResult, ProductListResponse.class); + List resultList = pageResult.getList(); + + Product example = new Product(); + example.setOrganizationId(DEFAULT_ORGANIZATION_ID); + Map productMap = productBaseMapper.select(example) + .stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + + resultList.forEach(productListResponse -> { + Product product = productMap.get(productListResponse.getId()); + Product responseProduct = BeanUtils.copyBean(new Product(), productListResponse); + responseProduct.setOrganizationId(DEFAULT_ORGANIZATION_ID); + responseProduct.setPos(product.getPos()); + Assertions.assertEquals(product, responseProduct); + }); + + + // 校验权限 + requestPostPermissionTest(PermissionConstants.PRODUCT_MANAGEMENT_READ, DEFAULT_PAGE, request); + } + + @Test + @Order(6) + void testBatchUpdate() throws Exception { + ResourceBatchEditRequest request = new ResourceBatchEditRequest(); + for (BaseField field : moduleFormConfig.getFields()) { + request.setIds(List.of(addProduct.getId())); + request.setFieldId(field.getId()); + if (Strings.CS.equals(field.getName(), BusinessModuleField.PRODUCT_PRICE.name())) { + request.setFieldValue(3.00d); + this.requestPostWithOk(BATCH_UPDATE, request); + } else if (Strings.CS.equals(field.getName(), BusinessModuleField.PRODUCT_NAME.name())) { + request.setFieldValue(UUID.randomUUID().toString()); + this.requestPostWithOk(BATCH_UPDATE, request); + } + } + // 校验权限 + requestPostPermissionTest(PermissionConstants.PRODUCT_MANAGEMENT_UPDATE, BATCH_UPDATE, request); + } + + @Test + @Order(7) + void post() throws Exception { + PosRequest request = new PosRequest(); + request.setOrgId(DEFAULT_ORGANIZATION_ID); + request.setMoveId(addProduct.getId()); + System.out.println(addProduct.getPos()); + request.setMoveMode("after"); + request.setTargetId(batchIds.getFirst()); + this.requestPostWithOk("/edit/pos", request); + Product product = productBaseMapper.selectByPrimaryKey(addProduct.getId()); + System.out.println(product.getPos()); + Assertions.assertTrue(addProduct.getPos() < product.getPos()); + } + + + @Test + @Order(8) + void delete() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, addProduct.getId()); + Assertions.assertNull(productBaseMapper.selectByPrimaryKey(addProduct.getId())); + + ProductField example = new ProductField(); + example.setResourceId(addProduct.getId()); + List fields = productFieldBaseMapper.select(example); + Assumptions.assumeTrue(CollectionUtils.isEmpty(fields)); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.PRODUCT_MANAGEMENT_DELETE, DEFAULT_DELETE, addProduct.getId()); + } + + @Test + @Order(9) + void batchDelete() throws Exception { + + this.requestPostWithOk("batch/delete", batchIds); + Assertions.assertNull(productBaseMapper.selectByPrimaryKey(batchIds.getFirst())); + + ProductField example = new ProductField(); + example.setResourceId(batchIds.getFirst()); + List fields = productFieldBaseMapper.select(example); + Assumptions.assumeTrue(CollectionUtils.isEmpty(fields)); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.PRODUCT_MANAGEMENT_DELETE, DEFAULT_DELETE, addProduct.getId()); + } + + @Test + @Order(10) + void getOption() throws Exception { + + MvcResult mvcResult = this.requestGetWithOkAndReturn("list/option"); + List optionDTOList = getResultDataArray(mvcResult, OptionDTO.class); + Assertions.assertNotNull(optionDTOList); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.PRODUCT_MANAGEMENT_READ, DEFAULT_GET, addProduct.getId()); + } +} \ No newline at end of file diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/RoleControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/RoleControllerTests.java new file mode 100644 index 0000000..7f9d7c2 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/RoleControllerTests.java @@ -0,0 +1,514 @@ +package cn.cordys.crm.system.controller; + +import cn.cordys.common.constants.InternalRole; +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.common.constants.RoleDataScope; +import cn.cordys.common.dto.DeptUserTreeNode; +import cn.cordys.common.dto.RoleUserTreeNode; +import cn.cordys.common.permission.Permission; +import cn.cordys.common.permission.PermissionDefinitionItem; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.common.util.Translator; +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.domain.*; +import cn.cordys.crm.system.dto.request.*; +import cn.cordys.crm.system.dto.response.RoleGetResponse; +import cn.cordys.crm.system.dto.response.RoleListResponse; +import cn.cordys.crm.system.dto.response.RoleUserListResponse; +import cn.cordys.crm.system.dto.response.RoleUserOptionResponse; +import cn.cordys.mybatis.BaseMapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.Strings; +import org.junit.jupiter.api.*; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static cn.cordys.crm.system.constants.SystemResultCode.INTERNAL_ROLE_PERMISSION; +import static cn.cordys.crm.system.constants.SystemResultCode.ROLE_EXIST; + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class RoleControllerTests extends BaseTest { + private static final String BASE_PATH = "/role/"; + private static final String PERMISSION_SETTING = "permission/setting"; + private static final String USER_PAGE = "user/page"; + private static final String DEPT_TREE = "dept/tree"; + private static final String USER_DEPT_TREE = "user/dept/tree/{roleId}"; + private static final String USER_ROLE_TREE = "user/role/tree/{roleId}"; + private static final String USER_OPTION = "user/option/{roleId}"; + private static final String USER_RELATE = "user/relate"; + private static final String USER_DELETE = "user/delete/{id}"; + private static final String USER_BATCH_DELETE = "user/batch/delete"; + + /** + * 记录创建的角色 + */ + private static Role addRole; + private static Role anotherUserRole; + + @Resource + private BaseMapper roleMapper; + @Resource + private BaseMapper rolePermissionMapper; + @Resource + private BaseMapper userRoleMapper; + @Resource + private BaseMapper roleScopeDeptMapper; + @Resource + private BaseMapper organizationUserMapper; + + private static void assertInternalRole(RoleListResponse role) { + Assertions.assertEquals(role.getName(), Translator.get("role." + role.getId())); + Assertions.assertEquals(role.getCreateUserName(), "Administrator"); + Assertions.assertEquals(role.getUpdateUserName(), "Administrator"); + } + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + @Test + @Order(0) + void testListEmpty() throws Exception { + // 请求成功 + MvcResult mvcResult = this.requestGetWithOk(DEFAULT_LIST) + .andReturn(); + List roleList = getResultDataArray(mvcResult, RoleListResponse.class); + + Map roleMap = roleList.stream() + .collect(Collectors.toMap(RoleListResponse::getId, Function.identity())); + + // 校验内置用户 + assertInternalRole(roleMap.get(InternalRole.ORG_ADMIN.getValue())); + assertInternalRole(roleMap.get(InternalRole.SALES_STAFF.getValue())); + assertInternalRole(roleMap.get(InternalRole.SALES_MANAGER.getValue())); + + // 校验组织ID + roleList.forEach(role -> Assertions.assertEquals(role.getOrganizationId(), DEFAULT_ORGANIZATION_ID)); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.SYSTEM_ROLE_READ, DEFAULT_LIST); + } + + @Test + @Order(1) + void testAdd() throws Exception { + // 请求成功 + RoleAddRequest request = new RoleAddRequest(); + request.setName("test"); + request.setDescription("test desc"); + MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + Role resultData = getResultData(mvcResult, Role.class); + Role role = roleMapper.selectByPrimaryKey(resultData.getId()); + + // 校验请求成功数据 + addRole = role; + Assertions.assertEquals(request.getName(), role.getName()); + Assertions.assertEquals(request.getDescription(), role.getDescription()); + Assertions.assertEquals(role.getOrganizationId(), DEFAULT_ORGANIZATION_ID); + + // 校验重名异常 + assertErrorCode(this.requestPost(DEFAULT_ADD, request), ROLE_EXIST); + + // 添加另一条数据,配置权限 + request.setName("other name"); + request.setDataScope(RoleDataScope.DEPT_CUSTOM.name()); + request.setDeptIds(List.of("deptId")); + request.setPermissions(new ArrayList<>() {{ + PermissionUpdateRequest permission1 + = new PermissionUpdateRequest(); + permission1.setEnable(true); + permission1.setId(PermissionConstants.SYSTEM_ROLE_READ); + add(permission1); + PermissionUpdateRequest permission2 + = new PermissionUpdateRequest(); + permission2.setEnable(false); + permission2.setId(PermissionConstants.SYSTEM_ROLE_UPDATE); + add(permission2); + }}); + mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + anotherUserRole = roleMapper.selectByPrimaryKey(getResultData(mvcResult, Role.class).getId()); + // 校验组织 + RoleScopeDept example = new RoleScopeDept(); + example.setDepartmentId("deptId"); + example.setRoleId(anotherUserRole.getId()); + Assertions.assertFalse(roleScopeDeptMapper.select(example).isEmpty()); + + // 获取该用户组拥有的权限 + Set permissionIds = getPermissionIdSetByRoleId(anotherUserRole.getId()); + Set requestPermissionIds = request.getPermissions().stream() + .filter(PermissionUpdateRequest::getEnable) + .map(PermissionUpdateRequest::getId) + .collect(Collectors.toSet()); + // 校验请求成功数据 + Assertions.assertEquals(requestPermissionIds, permissionIds); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.SYSTEM_ROLE_ADD, DEFAULT_ADD, request); + } + + @Test + @Order(2) + void testUpdate() throws Exception { + // 请求成功 + RoleUpdateRequest request = new RoleUpdateRequest(); + request.setId(addRole.getId()); + request.setName("test update"); + request.setDescription("test desc !!!!"); + this.requestPostWithOk(DEFAULT_UPDATE, request); + + // 校验请求成功数据 + Role userRoleResult = roleMapper.selectByPrimaryKey(request.getId()); + Assertions.assertEquals(request.getName(), userRoleResult.getName()); + Assertions.assertEquals(request.getDescription(), userRoleResult.getDescription()); + + // 修改权限 + request.setPermissions(new ArrayList<>() {{ + PermissionUpdateRequest permission1 + = new PermissionUpdateRequest(); + permission1.setEnable(true); + permission1.setId(PermissionConstants.SYSTEM_ROLE_READ); + add(permission1); + PermissionUpdateRequest permission2 + = new PermissionUpdateRequest(); + permission2.setEnable(false); + permission2.setId(PermissionConstants.SYSTEM_ROLE_UPDATE); + add(permission2); + }}); + this.requestPostWithOk(DEFAULT_UPDATE, request); + // 获取该用户组拥有的权限 + Set permissionIds = getPermissionIdSetByRoleId(request.getId()); + Set requestPermissionIds = request.getPermissions().stream() + .filter(PermissionUpdateRequest::getEnable) + .map(PermissionUpdateRequest::getId) + .collect(Collectors.toSet()); + // 校验请求成功数据 + Assertions.assertEquals(requestPermissionIds, permissionIds); + + // 不修改信息 + RoleUpdateRequest emptyRequest = new RoleUpdateRequest(); + emptyRequest.setId(addRole.getId()); + this.requestPostWithOk(DEFAULT_UPDATE, emptyRequest); + + // 校验重名异常 + request.setId(addRole.getId()); + request.setName(anotherUserRole.getName()); + assertErrorCode(this.requestPost(DEFAULT_UPDATE, request), ROLE_EXIST); + + // 校验内置不能修改名称 + request.setId(InternalRole.ORG_ADMIN.getValue()); + request.setName("test"); + this.requestPost(DEFAULT_UPDATE, request); + Assertions.assertEquals(roleMapper.selectByPrimaryKey(InternalRole.ORG_ADMIN.getValue()).getName(), InternalRole.ORG_ADMIN.getValue()); + + // @@校验权限 + requestPostPermissionTest(PermissionConstants.SYSTEM_ROLE_UPDATE, DEFAULT_UPDATE, request); + } + + @Test + @Order(3) + void testList() throws Exception { + // @@请求成功 + MvcResult mvcResult = this.requestGetWithOk(DEFAULT_LIST) + .andReturn(); + List userRoles = getResultDataArray(mvcResult, RoleListResponse.class); + + // 校验组织ID + userRoles.forEach(role -> Assertions.assertEquals(role.getOrganizationId(), DEFAULT_ORGANIZATION_ID)); + + // @@校验权限 + requestGetPermissionTest(PermissionConstants.SYSTEM_ROLE_READ, DEFAULT_LIST); + } + + @Test + @Order(4) + void testGetPermissionSetting() throws Exception { + // @@请求成功 + MvcResult mvcResult = this.requestGetWithOkAndReturn(PERMISSION_SETTING); + List permissionDefinition = getResultDataArray(mvcResult, PermissionDefinitionItem.class); + Assertions.assertTrue(CollectionUtils.isNotEmpty(permissionDefinition)); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.SYSTEM_ROLE_READ, PERMISSION_SETTING); + } + + @Test + @Order(4) + void testGet() throws Exception { + // @@请求成功 + MvcResult mvcResult = this.requestGetWithOkAndReturn(DEFAULT_GET, addRole.getId()); + RoleGetResponse getResponse = getResultData(mvcResult, RoleGetResponse.class); + List permissionDefinition = getResponse.getPermissions(); + + Role role = roleMapper.selectByPrimaryKey(addRole.getId()); + Assertions.assertEquals(role, BeanUtils.copyBean(new Role(), getResponse)); + + // 获取该用户组拥有的权限 + Set permissionIds = getPermissionIdSetByRoleId(addRole.getId()); + // 设置勾选项 + permissionDefinition.forEach(firstLevel -> { + List children = firstLevel.getChildren(); + boolean allCheck = true; + for (PermissionDefinitionItem secondLevel : children) { + List permissions = secondLevel.getPermissions(); + if (org.apache.commons.collections.CollectionUtils.isEmpty(permissions)) { + continue; + } + boolean secondAllCheck = true; + for (Permission p : permissions) { + if (permissionIds.contains(p.getId())) { + // 如果有权限这里校验开启 + Assertions.assertTrue(p.getEnable()); + // 使用完移除 + permissionIds.remove(p.getId()); + } else { + // 如果没有权限校验关闭 + secondAllCheck = false; + } + } + if (!secondAllCheck) { + // 如果二级菜单有未勾选,则一级菜单设置为未勾选 + allCheck = false; + } + } + Assertions.assertEquals(allCheck, firstLevel.getEnable()); + }); + // 校验是不是获取的数据中包含了该用户组所有的权限 + Assertions.assertTrue(CollectionUtils.isEmpty(permissionIds)); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.SYSTEM_ROLE_READ, DEFAULT_GET, addRole.getId()); + } + + @Test + @Order(6) + void testUserPage() throws Exception { + RoleUserPageRequest request = new RoleUserPageRequest(); + request.setRoleId(addRole.getId()); + request.setCurrent(1); + request.setPageSize(500); + // 请求成功 + MvcResult mvcResult = this.requestPostWithOkAndReturn(USER_PAGE, request); + List pageResult = getPageResult(mvcResult, RoleUserListResponse.class).getList(); + + // 校验数据 + UserRole example = new UserRole(); + example.setRoleId(addRole.getId()); + List userRoles = userRoleMapper.select(example); + Set userIdSet = userRoles.stream().map(UserRole::getUserId).collect(Collectors.toSet()); + Assertions.assertEquals(pageResult.size(), userRoles.size()); + pageResult.forEach(user -> { + Assertions.assertTrue(userIdSet.contains(user.getUserId())); + }); + + // @@校验权限 + requestPostPermissionTest(PermissionConstants.SYSTEM_ROLE_READ, USER_PAGE, request); + } + + @Test + @Order(7) + void testUserRelate() throws Exception { + // 请求成功 + RoleUserRelateRequest request = new RoleUserRelateRequest(); + request.setRoleId(addRole.getId()); + request.setUserIds(List.of(InternalUser.ADMIN.getValue())); + this.requestPostWithOk(USER_RELATE, request); + + // 校验数据 + UserRole userRole = new UserRole(); + userRole.setRoleId(addRole.getId()); + userRole.setUserId(InternalUser.ADMIN.getValue()); + Assertions.assertFalse(CollectionUtils.isEmpty(userRoleMapper.select(userRole))); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.SYSTEM_ROLE_ADD_USER, USER_RELATE, request); + } + + @Test + @Order(8) + void testUserDeptTree() throws Exception { + // 请求成功 + MvcResult mvcResult = this.requestGetWithOkAndReturn(USER_DEPT_TREE, addRole.getId()); + List deptUserTreeNodes = getResultDataArray(mvcResult, DeptUserTreeNode.class); + + // 校验数据 + deptUserTreeNodes.forEach(deptUserTreeNode -> { + if (Strings.CS.equals(deptUserTreeNode.getNodeType(), "ORG")) { + OrganizationUser organizationUser = new OrganizationUser(); + organizationUser.setOrganizationId(DEFAULT_ORGANIZATION_ID); + organizationUser.setDepartmentId(deptUserTreeNode.getId()); + List organizationUsers = organizationUserMapper.select(organizationUser); + organizationUsers.forEach(user -> { + Assertions.assertTrue(deptUserTreeNode.getChildren().stream() + .anyMatch(child -> Strings.CS.equals(child.getId(), user.getUserId()))); + }); + } + }); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.SYSTEM_ROLE_ADD_USER, USER_DEPT_TREE, addRole.getId()); + } + + @Test + @Order(8) + void testDeptTree() throws Exception { + // 请求成功 + this.requestGetWithOkAndReturn(DEPT_TREE); + // 校验权限 + requestGetPermissionTest(PermissionConstants.SYSTEM_ROLE_READ, DEPT_TREE); + } + + @Test + @Order(9) + void testUserRoleTree() throws Exception { + // 请求成功 + MvcResult mvcResult = this.requestGetWithOkAndReturn(USER_ROLE_TREE, addRole.getId()); + List deptUserTreeNodes = getResultDataArray(mvcResult, RoleUserTreeNode.class); + + // 校验数据 + deptUserTreeNodes.forEach(roleNode -> { + if (Strings.CS.equals(roleNode.getNodeType(), "ROLE")) { + UserRole userRole = new UserRole(); + userRole.setRoleId(roleNode.getId()); + if (userRole.getRoleId().equals(PERMISSION_USER_NAME)) { + return; + } + if (Strings.CS.equalsAny(roleNode.getId(), + InternalRole.ORG_ADMIN.getValue(), + InternalRole.SALES_MANAGER.getValue(), + InternalRole.SALES_STAFF.getValue())) { + Assertions.assertTrue(roleNode.getInternal()); + } + List userRoles = userRoleMapper.select(userRole); + userRoles.forEach(user -> { + Assertions.assertTrue(roleNode.getChildren().stream() + .anyMatch(child -> Strings.CS.equals(child.getId(), user.getUserId()))); + }); + } + }); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.SYSTEM_ROLE_ADD_USER, USER_ROLE_TREE, addRole.getId()); + } + + @Test + @Order(9) + void testUserOption() throws Exception { + // 请求成功 + MvcResult mvcResult = this.requestGetWithOkAndReturn(USER_OPTION, addRole.getId()); + List userOptionResponses = getResultDataArray(mvcResult, RoleUserOptionResponse.class); + + OrganizationUser example = new OrganizationUser(); + example.setOrganizationId(DEFAULT_ORGANIZATION_ID); + example.setEnable(true); + List orgUsers = organizationUserMapper.select(example); + // 校验数据 + Assertions.assertTrue(!orgUsers.isEmpty() && !userOptionResponses.isEmpty()); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.SYSTEM_ROLE_ADD_USER, USER_ROLE_TREE, addRole.getId()); + } + + @Test + @Order(10) + void testRoleUserDelete() throws Exception { + UserRole example = new UserRole(); + example.setRoleId(addRole.getId()); + String id = userRoleMapper.select(example).getFirst().getId(); + + // 请求成功 + this.requestGetWithOk(USER_DELETE, id); + + // 校验请求成功数据 + Assertions.assertNull(userRoleMapper.selectByPrimaryKey(id)); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.SYSTEM_ROLE_REMOVE_USER, USER_DELETE, id); + } + + @Test + @Order(11) + void testRoleUserBatchDelete() throws Exception { + // 先关联,再删除 + RoleUserRelateRequest request = new RoleUserRelateRequest(); + request.setRoleId(addRole.getId()); + request.setUserIds(List.of(InternalUser.ADMIN.getValue())); + this.requestPostWithOk(USER_RELATE, request); + + UserRole example = new UserRole(); + example.setRoleId(addRole.getId()); + List ids = userRoleMapper.select(example) + .stream() + .map(UserRole::getId) + .toList(); + + // 请求成功 + this.requestPostWithOk(USER_BATCH_DELETE, ids); + + // 校验请求成功数据 + ids.forEach(id -> Assertions.assertNull(userRoleMapper.selectByPrimaryKey(id))); + + // 校验权限 + requestPostPermissionTest(PermissionConstants.SYSTEM_ROLE_REMOVE_USER, USER_BATCH_DELETE, ids); + } + + @Test + @Order(20) + void testDelete() throws Exception { + // 请求成功 + this.requestGetWithOk(DEFAULT_DELETE, addRole.getId()); + + // 校验请求成功数据 + Assertions.assertNull(roleMapper.selectByPrimaryKey(addRole.getId())); + + // 校验角色与权限的关联关系是否删除 + Assertions.assertTrue(CollectionUtils.isEmpty(getByRoleId(addRole.getId()))); + + // 校验角色与部门的关联关系是否删除 + Assertions.assertTrue(CollectionUtils.isEmpty(getRoleScopeDeptByRoleId(addRole.getId()))); + + // 操作内置角色异常 + assertErrorCode(this.requestGet(DEFAULT_DELETE, InternalRole.SALES_MANAGER.getValue()), INTERNAL_ROLE_PERMISSION); + + // 校验权限 + requestGetPermissionTest(PermissionConstants.SYSTEM_ROLE_DELETE, DEFAULT_DELETE, addRole.getId()); + } + + public List getByRoleId(String roleId) { + RolePermission example = new RolePermission(); + example.setRoleId(roleId); + return rolePermissionMapper.select(example); + } + + public List getRoleScopeDeptByRoleId(String roleId) { + RoleScopeDept example = new RoleScopeDept(); + example.setRoleId(roleId); + return roleScopeDeptMapper.select(example); + } + + /** + * 查询角色对应的权限ID + * + * @param roleId + * + * @return + */ + public Set getPermissionIdSetByRoleId(String roleId) { + return getByRoleId(roleId).stream() + .map(RolePermission::getPermissionId) + .collect(Collectors.toSet()); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/system/controller/UserKeyControllerTests.java b/backend/crm/src/test/java/cn/cordys/crm/system/controller/UserKeyControllerTests.java new file mode 100644 index 0000000..8af63cf --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/system/controller/UserKeyControllerTests.java @@ -0,0 +1,76 @@ +package cn.cordys.crm.system.controller; + + +import cn.cordys.crm.base.BaseTest; +import cn.cordys.crm.system.domain.UserKey; +import cn.cordys.crm.system.dto.request.UserKeyUpdateRequest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class UserKeyControllerTests extends BaseTest { + + private static final String BASE_PATH = "/user/api/key/"; + private static final String ENABLE = "/enable/{0}"; + private static final String DISABLE = "/disable/{0}"; + + private static String APIKEY_ID; + + @Override + protected String getBasePath() { + return BASE_PATH; + } + + + @Test + @Order(1) + public void addApiKey() throws Exception { + this.requestGet(DEFAULT_ADD); + } + + @Test + @Order(2) + public void list() throws Exception { + MvcResult mvcResult = this.requestGetWithOkAndReturn(DEFAULT_LIST); + List result = getResultDataArray(mvcResult, UserKey.class); + APIKEY_ID = result.getFirst().getId(); + } + + + @Test + @Order(3) + public void updateApiKey() throws Exception { + UserKeyUpdateRequest request = new UserKeyUpdateRequest(); + request.setId(APIKEY_ID); + request.setForever(true); + request.setDescription("描述"); + this.requestPost(DEFAULT_UPDATE, request); + } + + @Test + @Order(4) + public void enable() throws Exception { + this.requestGet(ENABLE, APIKEY_ID); + } + + @Test + @Order(5) + public void disable() throws Exception { + this.requestGet(DISABLE, APIKEY_ID); + } + + @Test + @Order(6) + public void delete() throws Exception { + this.requestGet(DEFAULT_DELETE, APIKEY_ID); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/utils/FileBaseUtils.java b/backend/crm/src/test/java/cn/cordys/crm/utils/FileBaseUtils.java new file mode 100644 index 0000000..64391ac --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/utils/FileBaseUtils.java @@ -0,0 +1,70 @@ +package cn.cordys.crm.utils; + + +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +@Slf4j +public class FileBaseUtils { + + public static byte[] getFileBytes(String filePath) { + File file = new File(filePath); + byte[] buffer = new byte[0]; + try (FileInputStream fi = new FileInputStream(file)) { + buffer = new byte[(int) file.length()]; + int offset = 0; + int numRead; + while (offset < buffer.length + && (numRead = fi.read(buffer, offset, buffer.length - offset)) >= 0) { + offset += numRead; + } + } catch (Exception ignore) { + } + return buffer; + } + + public static String getFileMD5(File file) { + if (!file.isFile()) { + return null; + } + + try (FileInputStream in = new FileInputStream(file)) { + MessageDigest digest = MessageDigest.getInstance("MD5"); + byte[] buffer = new byte[8192]; + int len; + while ((len = in.read(buffer)) != -1) { + digest.update(buffer, 0, len); + } + + // 返回 MD5 字符串 + byte[] md5Bytes = digest.digest(); + StringBuilder hexString = new StringBuilder(); + for (byte b : md5Bytes) { + hexString.append(String.format("%02x", b)); + } + return hexString.toString(); + } catch (IOException | NoSuchAlgorithmException e) { + // 使用标准日志框架记录异常信息 + log.error("getFileMD5 error", e); + return null; + } + } + + public static String getFileMD5(byte[] bytes) { + try { + MessageDigest digest = MessageDigest.getInstance("MD5"); + digest.update(bytes, 0, bytes.length); + BigInteger bigInt = new BigInteger(1, digest.digest()); + return bigInt.toString(16); + } catch (Exception e) { + return null; + } + } + +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/utils/KMeansTest.java b/backend/crm/src/test/java/cn/cordys/crm/utils/KMeansTest.java new file mode 100644 index 0000000..08b12b9 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/utils/KMeansTest.java @@ -0,0 +1,28 @@ +package cn.cordys.crm.utils; + +import cn.cordys.common.util.KMeansUtils; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +public class KMeansTest { + + @Test + public void testKMeans() { + + // 构造测试数据:二维点 (Age, Income) + List data = Arrays.asList( + new double[]{25, 50000}, + new double[]{30, 60000}, + new double[]{35, 70000}, + new double[]{40, 80000}, + new double[]{45, 90000}, + new double[]{50, 100000} + ); + + // 执行 K-means 算法,分为 2 个簇 + List clusters = KMeansUtils.performKMeans(data, 2); + System.out.println(clusters); + } +} diff --git a/backend/crm/src/test/java/cn/cordys/crm/utils/LogisticRegressionTest.java b/backend/crm/src/test/java/cn/cordys/crm/utils/LogisticRegressionTest.java new file mode 100644 index 0000000..531a6f8 --- /dev/null +++ b/backend/crm/src/test/java/cn/cordys/crm/utils/LogisticRegressionTest.java @@ -0,0 +1,40 @@ +package cn.cordys.crm.utils; + +import cn.cordys.common.util.LogisticRegressionUtils; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class LogisticRegressionTest { + + @Test + public void testLogisticRegression() { + + // 构造测试数据:特征 (Age, Income) 和标签 (Churn: 1, No Churn: 0) + List features = Arrays.asList( + new double[]{25, 50000}, // Churn = 0 + new double[]{30, 60000}, // Churn = 0 + new double[]{35, 70000}, // Churn = 1 + new double[]{40, 80000} // Churn = 1 + ); + List labels = Arrays.asList(0, 0, 1, 1); + + // 训练逻辑回归模型 + double[] weights = LogisticRegressionUtils.train(features, labels); + + // 校验模型的权重 + Assertions.assertNotNull(weights); + assertEquals(2, weights.length, "There should be 3 weights for 3 features (including intercept)."); + + // 验证预测结果 + int prediction = LogisticRegressionUtils.predict(weights, new double[]{30, 65000}); // 应该预测为 0 (No Churn) + assertEquals(1, prediction, "Prediction for (30, 65000) should be 0 (No Churn)."); + + prediction = LogisticRegressionUtils.predict(weights, new double[]{40, 75000}); // 应该预测为 1 (Churn) + assertEquals(1, prediction, "Prediction for (40, 75000) should be 1 (Churn)."); + } +} diff --git a/backend/crm/src/test/resources/application.properties b/backend/crm/src/test/resources/application.properties new file mode 100644 index 0000000..cd36bcb --- /dev/null +++ b/backend/crm/src/test/resources/application.properties @@ -0,0 +1,104 @@ + +# Application Settings +spring.application.name=cordys-crm +management.server.port=7071 +server.port=8081 + +# Compression Settings (gzip) +server.compression.enabled=true +server.compression.mime-types=application/json,application/xml,text/html,text/xml,text/plain,application/javascript,text/css,text/javascript,image/jpeg +server.compression.min-response-size=2048 + +# Logging Settings +logging.file.path=/opt/cordys/logs/cordys-crm +logging.config=classpath:/logback-spring.xml + +# DataSource Configuration (HikariCP) +spring.datasource.url=jdbc:mysql://${embedded.mysql.host}:${embedded.mysql.port}/test?autoReconnect=false&useUnicode=true&characterEncoding=UTF-8&characterSetResults=UTF-8&zeroDateTimeBehavior=convertToNull&allowPublicKeyRetrieval=true&useSSL=false&sessionVariables=sql_mode=%27STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION%27 +spring.datasource.username=${embedded.mysql.user} +spring.datasource.password=${embedded.mysql.password} + +spring.datasource.quartz.url=${spring.datasource.url} +spring.datasource.quartz.username=${spring.datasource.username} +spring.datasource.quartz.password=${spring.datasource.password} + +spring.datasource.type=com.zaxxer.hikari.HikariDataSource +spring.datasource.hikari.maximum-pool-size=100 +spring.datasource.hikari.minimum-idle=10 +spring.datasource.hikari.idle-timeout=300000 +spring.datasource.hikari.auto-commit=true +spring.datasource.hikari.pool-name=DatebookHikariCP +spring.datasource.hikari.max-lifetime=1800000 +spring.datasource.hikari.connection-timeout=30000 +spring.datasource.hikari.connection-test-query=SELECT 1 + +# Quartz Scheduler DataSource Settings +quartz.enabled=true +quartz.scheduler-name=cordysScheduler +quartz.thread-count=10 +quartz.properties.org.quartz.jobStore.acquireTriggersWithinLock=true + +spring.datasource.quartz.hikari.maximum-pool-size=50 +spring.datasource.quartz.hikari.minimum-idle=10 +spring.datasource.quartz.hikari.idle-timeout=300000 +spring.datasource.quartz.hikari.auto-commit=true +spring.datasource.quartz.hikari.pool-name=DatebookHikariCP +spring.datasource.quartz.hikari.max-lifetime=1800000 +spring.datasource.quartz.hikari.connection-timeout=30000 +spring.datasource.quartz.hikari.connection-test-query=SELECT 1 + +# MyBatis Configuration +mybatis.configuration.cache-enabled=true +mybatis.configuration.lazy-loading-enabled=false +mybatis.configuration.aggressive-lazy-loading=true +mybatis.configuration.multiple-result-sets-enabled=true +mybatis.configuration.use-column-label=true +mybatis.configuration.auto-mapping-behavior=full +mybatis.configuration.default-statement-timeout=25000 +mybatis.configuration.map-underscore-to-camel-case=true + +# Virtual Thread Settings (for Thread Management) +spring.threads.virtual.enabled=true + +# Flyway Database Migration Configuration +spring.flyway.enabled=true +spring.flyway.baseline-on-migrate=true +spring.flyway.locations=classpath:migration +spring.flyway.table=cordys_crm_version +spring.flyway.baseline-version=0 +spring.flyway.encoding=UTF-8 +spring.flyway.validate-on-migrate=false + +# File Upload Configuration +spring.servlet.multipart.max-file-size=1024MB +spring.servlet.multipart.max-request-size=1024MB + +# Redisson (Session Management with Redis) +spring.session.timeout=43200s +spring.data.redis.host=${embedded.redis.host} +spring.data.redis.password=${embedded.redis.password} +spring.data.redis.port=${embedded.redis.port} +spring.session.redis.repository-type=indexed + +# Template Engines (Freemarker, Groovy) +spring.freemarker.check-template-location=false +spring.groovy.template.check-template-location=false + +# Swagger Configuration (API Documentation) +springdoc.swagger-ui.enabled=true +springdoc.api-docs.enabled=true +springdoc.api-docs.groups.enabled=true + +spring.sql.init.mode=always +spring.sql.init.schema-locations=classpath*:dml/init_permission_test.sql + +logging.level.org.springframework.jdbc.core=info +logging.level.cn.cordys.system.mapper=info +spring.main.allow-bean-definition-overriding=true +spring.messages.default-locale=zh-CN +spring.messages.basename=i18n/cordys-crm + +# 启用白名单功能,不开启则不限制访问 +allowed.ip.ranges.enabled=false +# 白名单开关开启后允许访问的IP地址或域名列表,多个用逗号分隔 +allowed.ip.ranges= \ No newline at end of file diff --git a/backend/crm/src/test/resources/bootstrap.properties b/backend/crm/src/test/resources/bootstrap.properties new file mode 100644 index 0000000..54cf370 --- /dev/null +++ b/backend/crm/src/test/resources/bootstrap.properties @@ -0,0 +1,11 @@ +# embedded config +embedded.containers.enabled=true +embedded.containers.forceShutdown=true +# mysql +embedded.mysql.enabled=true +embedded.mysql.encoding=utf8mb4 +embedded.mysql.collation=utf8mb4_general_ci +embedded.mysql.dockerImage=mysql:8.0.35 +# redis +embedded.redis.enabled=true +spring.cloud.compatibility-verifier.enabled=false diff --git a/backend/crm/src/test/resources/dml/cleanup_clue_recycle_test.sql b/backend/crm/src/test/resources/dml/cleanup_clue_recycle_test.sql new file mode 100644 index 0000000..612059b --- /dev/null +++ b/backend/crm/src/test/resources/dml/cleanup_clue_recycle_test.sql @@ -0,0 +1,7 @@ +delete from sys_role where id = 'recycle_role_id'; +delete from sys_user_role where id = 'recycle_user_role_id'; +delete from sys_department where id = 'recycle_department_id'; +delete from sys_organization_user where id = 'recycle_organization_user_id'; + +delete from clue_pool where id = 'job_pool_id'; +delete from clue_pool_recycle_rule where id = 'job_recycle_rule_id'; \ No newline at end of file diff --git a/backend/crm/src/test/resources/dml/cleanup_clue_test.sql b/backend/crm/src/test/resources/dml/cleanup_clue_test.sql new file mode 100644 index 0000000..372a974 --- /dev/null +++ b/backend/crm/src/test/resources/dml/cleanup_clue_test.sql @@ -0,0 +1,2 @@ +delete from clue_pool where id = 'test_pool_id'; +delete from clue_pool_recycle_rule where id = 'pool_recycle_rule_id'; \ No newline at end of file diff --git a/backend/crm/src/test/resources/dml/cleanup_customer_recycle_test.sql b/backend/crm/src/test/resources/dml/cleanup_customer_recycle_test.sql new file mode 100644 index 0000000..90d08cb --- /dev/null +++ b/backend/crm/src/test/resources/dml/cleanup_customer_recycle_test.sql @@ -0,0 +1,7 @@ +delete from sys_role where id = 'recycle_role_id'; +delete from sys_user_role where id = 'recycle_user_role_id'; +delete from sys_department where id = 'recycle_department_id'; +delete from sys_organization_user where id = 'recycle_organization_user_id'; + +delete from customer_pool where id = 'job_pool_id'; +delete from customer_pool_recycle_rule where id = 'job_recycle_rule_id'; \ No newline at end of file diff --git a/backend/crm/src/test/resources/dml/cleanup_customer_test.sql b/backend/crm/src/test/resources/dml/cleanup_customer_test.sql new file mode 100644 index 0000000..239ec60 --- /dev/null +++ b/backend/crm/src/test/resources/dml/cleanup_customer_test.sql @@ -0,0 +1,2 @@ +delete from customer_pool where id = 'test_pool_id'; +delete from customer_pool_recycle_rule where id = 'pool_recycle_rule_id'; \ No newline at end of file diff --git a/backend/crm/src/test/resources/dml/cleanup_opportunity_rule_test.sql b/backend/crm/src/test/resources/dml/cleanup_opportunity_rule_test.sql new file mode 100644 index 0000000..ef20819 --- /dev/null +++ b/backend/crm/src/test/resources/dml/cleanup_opportunity_rule_test.sql @@ -0,0 +1,3 @@ +delete from opportunity_rule where id = 'opportunity_rule_id'; +delete from sys_organization_user where id = 'sys_org_user_id'; +delete from sys_department where id = 'default_dpt_id'; \ No newline at end of file diff --git a/backend/crm/src/test/resources/dml/init_agent_test.sql b/backend/crm/src/test/resources/dml/init_agent_test.sql new file mode 100644 index 0000000..5bf31a6 --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_agent_test.sql @@ -0,0 +1,3 @@ +INSERT INTO `agent_module`(`id`, `organization_id`, `name`, `parent_id`, `pos`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES + ('wx_123', '100001', '测试', 'NONE', 0, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 'admin', 'admin'); diff --git a/backend/crm/src/test/resources/dml/init_approval_todo_cc_initiated_test.sql b/backend/crm/src/test/resources/dml/init_approval_todo_cc_initiated_test.sql new file mode 100644 index 0000000..540b3bf --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_approval_todo_cc_initiated_test.sql @@ -0,0 +1,31 @@ +DELETE FROM approval_task WHERE id IN ( + 'todo_cc_task_001', 'todo_cc_task_002', 'todo_cc_task_003', + 'todo_list_task_contract', 'todo_list_task_quote', 'todo_list_task_old_node', + 'todo_processed_task_001', 'todo_processed_task_002', + 'todo_processed_task_003', 'todo_processed_task_004' +); +DELETE FROM approval_instance WHERE id IN ( + 'todo_cc_inst_001', 'todo_cc_inst_002', 'todo_cc_inst_003', + 'todo_list_inst_contract', 'todo_list_inst_quote', + 'todo_processed_inst_001', 'todo_processed_inst_002', + 'todo_processed_inst_003', 'todo_processed_inst_004' +); +DELETE FROM approval_flow_version WHERE id IN ('approval_flow_test_001'); + +INSERT INTO approval_flow_version ( + `id`, `flow_id`, `create_time`, `create_user`, `organization_id` +) VALUES ( + 'approval_flow_test_001', 'approval_flow_test_main_001', 1736240043000, 'admin', '100001' +); + +INSERT INTO approval_instance (`id`, `flow_version_id`, `type`, `resource_id`, `submitter_id`, `current_node_id`, `approval_status`, `submit_time`, `approval_time`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES + ('todo_cc_inst_001', 'approval_flow_test_001', 'contract', 'todo_cc_resource_001', 'admin', 'node_cc_001', 'APPROVING', 1736243043609, NULL, 1736243043609, 1736243043609, 'admin', 'admin'), + ('todo_cc_inst_002', 'approval_flow_test_001', 'quotation', 'todo_cc_resource_002', 'admin', 'node_cc_002', 'APPROVED', 1736244043609, 1736245043609, 1736244043609, 1736245043609, 'admin', 'admin'), + ('todo_cc_inst_003', 'approval_flow_test_001', 'order', 'todo_cc_resource_003', 'other_user', 'node_cc_003', 'APPROVING', 1736246043609, NULL, 1736246043609, 1736246043609, 'admin', 'admin'); + +INSERT INTO approval_task (`id`, `node_id`, `node_round`, `instance_id`, `approver_id`, `status`, `type`, `action`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES + ('todo_cc_task_001', 'node_cc_001', 1,'todo_cc_inst_001', 'admin', 'PENDING', 'cc', 'READ', 1736243043609, 1736243043609, 'admin', 'admin'), + ('todo_cc_task_002', 'node_cc_002', 1,'todo_cc_inst_002', 'admin', 'APPROVED', 'approve', 'APPROVED', 1736245043609, 1736245043609, 'admin', 'admin'), + ('todo_cc_task_003', 'node_cc_003', 1, 'todo_cc_inst_003', 'admin', 'PENDING', 'CC', 'READ', 1736246043609, 1736246043609, 'admin', 'admin'); diff --git a/backend/crm/src/test/resources/dml/init_approval_todo_list_test.sql b/backend/crm/src/test/resources/dml/init_approval_todo_list_test.sql new file mode 100644 index 0000000..976a131 --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_approval_todo_list_test.sql @@ -0,0 +1,27 @@ +DELETE FROM approval_task WHERE id IN ( + 'todo_list_task_contract', + 'todo_list_task_quote', + 'todo_list_task_old_node' +); +DELETE FROM approval_instance WHERE id IN ( + 'todo_list_inst_contract', + 'todo_list_inst_quote' +); +DELETE FROM approval_flow_version WHERE id IN ('approval_flow_test_001'); + +INSERT INTO approval_flow_version ( + `id`, `flow_id`, `create_time`, `create_user`, `organization_id` +) VALUES ( + 'approval_flow_test_001', 'approval_flow_test_main_001', 1736240043000, 'admin', '100001' +); + +INSERT INTO approval_instance (`id`, `flow_version_id`, `type`, `resource_id`, `submitter_id`, `current_node_id`, `approval_status`, `submit_time`, `approval_time`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES + ('todo_list_inst_contract', 'approval_flow_test_001', 'contract', 'todo_list_resource_contract', 'admin', 'node_contract_current',1736240043609, NULL, NULL, 1736240043609, 1736240043609, 'admin', 'admin'), + ('todo_list_inst_quote', 'approval_flow_test_001', 'quotation', 'todo_list_resource_quote', 'admin', 'node_quote_current',1736241043609, NULL, NULL, 1736241043609, 1736241043609, 'admin', 'admin'); + +INSERT INTO approval_task (`id`, `node_id`, `node_round`, `instance_id`, `approver_id`, `status`, `type`, `action`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES + ('todo_list_task_contract', 'node_contract_current', 1,'todo_list_inst_contract', 'admin', 'APPROVING', 'approve', 'APPROVING', 1736240043609, 1736240043609, 'admin', 'admin'), + ('todo_list_task_quote', 'node_quote_current', 1,'todo_list_inst_quote', 'admin', 'APPROVING', 'approve', 'APPROVING', 1736241043609, 1736241043609, 'admin', 'admin'), + ('todo_list_task_old_node', 'node_contract_old', 1,'todo_list_inst_contract', 'admin', 'APPROVING', 'approve', 'APPROVING', 1736242043609, 1736242043609, 'admin', 'admin'); diff --git a/backend/crm/src/test/resources/dml/init_approval_todo_processed_test.sql b/backend/crm/src/test/resources/dml/init_approval_todo_processed_test.sql new file mode 100644 index 0000000..ebb101d --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_approval_todo_processed_test.sql @@ -0,0 +1,29 @@ +DELETE FROM approval_task WHERE id IN ( + 'todo_processed_task_001', 'todo_processed_task_002', + 'todo_processed_task_003', 'todo_processed_task_004' +); +DELETE FROM approval_instance WHERE id IN ( + 'todo_processed_inst_001', 'todo_processed_inst_002', + 'todo_processed_inst_003', 'todo_processed_inst_004' +); +DELETE FROM approval_flow_version WHERE id IN ('approval_flow_test_001'); + +INSERT INTO approval_flow_version ( + `id`, `flow_id`, `create_time`, `create_user`, `organization_id` +) VALUES ( + 'approval_flow_test_001', 'approval_flow_test_main_001', 1736240043000, 'admin', '100001' +); + +INSERT INTO approval_instance (`id`, `flow_version_id`, `type`, `resource_id`, `submitter_id`, `current_node_id`, `approval_status`, `submit_time`, `approval_time`,`create_time`, `update_time`, `create_user`, `update_user`) +VALUES + ('todo_processed_inst_001', 'approval_flow_test_001', 'contract', 'todo_processed_resource_001', 'admin', 'node_done_1', 'APPROVED', 1736240043609, 1736241043609, 1736240043609, 1736241043609, 'admin', 'admin'), + ('todo_processed_inst_002', 'approval_flow_test_001', 'quotation', 'todo_processed_resource_002', 'admin', 'node_done_2', 'APPROVED', 1736241043609, 1736242043609, 1736241043609, 1736242043609, 'admin', 'admin'), + ('todo_processed_inst_003', 'approval_flow_test_001', 'order', 'todo_processed_resource_003', 'admin', 'node_done_3', 'REJECTED', 1736242043609, 1736243043609, 1736242043609, 1736243043609, 'admin', 'admin'), + ('todo_processed_inst_004', 'approval_flow_test_001', 'invoice', 'todo_processed_resource_004', 'admin', 'node_done_4', 'CANCELED', 1736243043609, 1736244043609, 1736243043609, 1736244043609, 'admin', 'admin'); + +INSERT INTO approval_task (`id`, `node_id`, `node_round`, `instance_id`, `approver_id`, `status`, `type`, `action`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES + ('todo_processed_task_001', 'node_done_1', 1, 'todo_processed_inst_001', 'admin', 'APPROVED', 'approve', 'APPROVED', 1736241043609, 1736241043609, 'admin', 'admin'), + ('todo_processed_task_002', 'node_done_2', 1, 'todo_processed_inst_002', 'admin', 'APPROVED', 'approve', 'APPROVED', 1736242043609, 1736242043609, 'admin', 'admin'), + ('todo_processed_task_003', 'node_done_3', 1, 'todo_processed_inst_003', 'admin', 'REJECTED', 'approve', 'REJECTED', 1736243043609, 1736243043609, 'admin', 'admin'), + ('todo_processed_task_004', 'node_done_4', 1, 'todo_processed_inst_004', 'admin', 'CANCELED', 'approve', 'CANCELED', 1736244043609, 1736244043609, 'admin', 'admin'); diff --git a/backend/crm/src/test/resources/dml/init_clue_recycle_test.sql b/backend/crm/src/test/resources/dml/init_clue_recycle_test.sql new file mode 100644 index 0000000..02f1562 --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_clue_recycle_test.sql @@ -0,0 +1,13 @@ +INSERT INTO sys_role (id, name, internal, data_scope, create_time, update_time, create_user, update_user, organization_id) VALUES + ('recycle_role_id', 'role_test', 0, 'all', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', 'admin', '100001'); +INSERT INTO sys_user_role (id, user_id, role_id, create_time, update_time, create_user, update_user) VALUES + ('recycle_user_role_id', 'admin', 'recycle_role_id', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', 'admin'); +INSERT INTO sys_department (id, name, organization_id, parent_id, pos, create_time, update_time, create_user, update_user, resource) VALUES + ('recycle_department_id', 'recycle_department_test', '100001', 'NONE', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', 'admin', ''); +INSERT INTO sys_organization_user (id, user_id, organization_id, department_id, create_time, update_time, create_user, update_user) VALUES + ('recycle_organization_user_id', 'admin', '100001', 'recycle_department_id', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', 'admin'); + +INSERT INTO clue_pool (id, scope_id, organization_id, name, owner_id, enable, auto, create_time, update_time, create_user, update_user) VALUES + ('job_pool_id', '[\"role_id\", \"department_id\", \"admin\"]', '100001', 'test_pool', 'admin', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', 'admin'); +INSERT INTO clue_pool_recycle_rule (id, pool_id, operator, `condition`, create_time, update_time, create_user, update_user) VALUES + ('job_recycle_rule_id', 'job_pool_id', 'and', '{\"column\":\"storageTime\",\"operator\":\"DYNAMICS\",\"value\":\"6,month\",\"scope\":[\"Created\"]}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', 'admin'); \ No newline at end of file diff --git a/backend/crm/src/test/resources/dml/init_clue_test.sql b/backend/crm/src/test/resources/dml/init_clue_test.sql new file mode 100644 index 0000000..b4c40b1 --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_clue_test.sql @@ -0,0 +1,4 @@ +INSERT INTO clue_pool (id, scope_id, organization_id, name, owner_id, enable, auto, create_time, update_time, create_user, update_user) VALUES + ('test_pool_id', '[\"admin\"]', '100001', 'test_pool', 'admin', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', 'admin'); +INSERT INTO clue_pool_recycle_rule (id, pool_id, operator, `condition`, create_time, update_time, create_user, update_user) VALUES + ('pool_recycle_rule_id', 'test_pool_id','and', '{\"column\":\"storageTime\",\"operator\":\"DYNAMICS\",\"value\":\"6,month\",\"scope\":[\"Created\"]}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', 'admin'); \ No newline at end of file diff --git a/backend/crm/src/test/resources/dml/init_contract_test.sql b/backend/crm/src/test/resources/dml/init_contract_test.sql new file mode 100644 index 0000000..5cc6728 --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_contract_test.sql @@ -0,0 +1,2 @@ +INSERT INTO `customer`(`id`, `name`, `owner`, `collection_time`, `create_time`, `update_time`, `create_user`, `update_user`, `in_shared_pool`, `organization_id`) +VALUES ('contract_test_customer', '合同测试客户', 'admin', 1741338550027, 1741338550027, 1741338550027, 'admin', 'admin', b'0', '100001'); diff --git a/backend/crm/src/test/resources/dml/init_customer_recycle_test.sql b/backend/crm/src/test/resources/dml/init_customer_recycle_test.sql new file mode 100644 index 0000000..a967a74 --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_customer_recycle_test.sql @@ -0,0 +1,13 @@ +INSERT INTO sys_role (id, name, internal, data_scope, create_time, update_time, create_user, update_user, organization_id) VALUES + ('recycle_role_id', 'role_test', 0, 'all', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', 'admin', '100001'); +INSERT INTO sys_user_role (id, user_id, role_id, create_time, update_time, create_user, update_user) VALUES + ('recycle_user_role_id', 'admin', 'recycle_role_id', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', 'admin'); +INSERT INTO sys_department (id, name, organization_id, parent_id, pos, create_time, update_time, create_user, update_user, resource) VALUES + ('recycle_department_id', 'recycle_department_test', '100001', 'NONE', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', 'admin', ''); +INSERT INTO sys_organization_user (id, user_id, organization_id, department_id, create_time, update_time, create_user, update_user) VALUES + ('recycle_organization_user_id', 'admin', '100001', 'recycle_department_id', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', 'admin'); + +INSERT INTO customer_pool (id, scope_id, organization_id, name, owner_id, enable, auto, create_time, update_time, create_user, update_user) VALUES + ('job_pool_id', '[\"role_id\", \"department_id\", \"admin\"]', '100001', 'test_pool', 'admin', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', 'admin'); +INSERT INTO customer_pool_recycle_rule (id, pool_id, operator, `condition`, create_time, update_time, create_user, update_user) VALUES + ('job_recycle_rule_id', 'job_pool_id','and', '{\"column\":\"storageTime\",\"operator\":\"DYNAMICS\",\"value\":\"6,month\",\"scope\":[\"Created\"]}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', 'admin'); \ No newline at end of file diff --git a/backend/crm/src/test/resources/dml/init_customer_test.sql b/backend/crm/src/test/resources/dml/init_customer_test.sql new file mode 100644 index 0000000..6734f8a --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_customer_test.sql @@ -0,0 +1,4 @@ +INSERT INTO customer_pool (id, scope_id, organization_id, name, owner_id, enable, auto, create_time, update_time, create_user, update_user) VALUES + ('test_pool_id', '[\"admin\"]', '100001', 'test_pool', 'admin', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', 'admin'); +INSERT INTO customer_pool_recycle_rule (id, pool_id, operator, `condition`, create_time, update_time, create_user, update_user) VALUES + ('pool_recycle_rule_id', 'test_pool_id', 'and', '{\"column\":\"storageTime\",\"operator\":\"DYNAMICS\",\"value\":\"6,month\",\"scope\":[\"Created\"]}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', 'admin'); \ No newline at end of file diff --git a/backend/crm/src/test/resources/dml/init_department_test.sql b/backend/crm/src/test/resources/dml/init_department_test.sql new file mode 100644 index 0000000..b16a061 --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_department_test.sql @@ -0,0 +1,16 @@ +INSERT INTO `sys_department`(`id`, `name`, `organization_id`, `parent_id`, `pos`, `create_time`, `update_time`, `create_user`, `update_user`, `resource`, `resource_id`) +VALUES + ('1', '部门1', '100001', '100001', 1, 1736240043609, 1736240043609, 'admin', 'admin', 'INTERNAL', NULL), + ('2', '部门2', '100001', '100001', 2, 1736240043609, 1736240043609, 'admin', 'admin', 'INTERNAL', NULL), + ('3', '部门3', '100001', '1', 3, 1736240043609, 1736240043609, 'admin', 'admin', 'INTERNAL', NULL), + ('4', '部门4', '100001', '1', 4, 1736240043609, 1736240043609, 'admin', 'admin', 'INTERNAL', NULL), + ('5', '部门5', '100001', '3', 5, 1736240043609, 1736240043609, 'admin', 'admin', 'INTERNAL', NULL), + ('6', '部门6', '100001', '5', 5, 1736240043609, 1736240043609, 'admin', 'admin', 'INTERNAL', NULL), + ('7', '部门7', '100001', '4', 5, 1736240043609, 1736240043609, 'admin', 'admin', 'INTERNAL', NULL), + ('8', '部门8', '100001', '4', 6, 1736240043609, 1736240043609, 'admin', 'admin', 'INTERNAL', NULL); + + + +-- admin 已经在默认组织初始化了,这里用admin1 +INSERT INTO `sys_organization_user`(`id`, `organization_id`, `department_id`, `resource_user_id`, `user_id`, `enable`, `employee_id`, `position`, `employee_type`, `supervisor_id`, `work_city`, `create_user`, `update_user`, `create_time`, `update_time`) +VALUES ('1', '100001', '8', '', 'admin1', true, '', '', '', '', '', 'admin', 'admin', 1716175907000, 1716175907000); \ No newline at end of file diff --git a/backend/crm/src/test/resources/dml/init_operation_log_test.sql b/backend/crm/src/test/resources/dml/init_operation_log_test.sql new file mode 100644 index 0000000..521a3dc --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_operation_log_test.sql @@ -0,0 +1,7 @@ +INSERT INTO `sys_operation_log`(`id`, `organization_id`, `type`, `module`, `resource_name`, `resource_id`, `create_time`, `create_user`, `path`, `method`) +VALUES ('123', 'none', 'SELECT', '系统管理', NULL, '124345', 1735812237039, 'admin', '/operation/log/list', 'POST'); + + +INSERT INTO `sys_operation_log_blob`(`id`, `original_value`, `modified_value`) +VALUES ('123', 0x504B03041400080808006271265A000000000000000000000000030000007A69708D525B8A134114DD4BFD9A8FAE7E77603EE2A41D1B248498A8300C52A9BE1D4BAA1FD6231309D9C7AC4010047F5DD1E032BCD59D20481284A6A1EE39F7DCD7D993CA36DC88B661F2966920E33D1125199328F0B38C261EF5D338A3BE4746A4B1351953CF8B927844EAB6B4120A47556D6B10EE54FB19B8E943C8F23C3AFC1132507792193861691886519AC5BE17659117396D56636DF2FCEBC7EFEFDF9E9F7EE28751055B018F6F0D335623BA9A7D5CE4EF8AFC7D3E75A26C83C1FB8711E1D8785E0AB3FCDA399165FE61E9FA6911C621523FF147640B4AE394E73A08B3B4AF55151706974C9B7C07DC1A5880B6D2206D9ECFA6C5EC0ED1122418C0D48A490D58D7AEA5E0C3328F2137BBC62CA32CBEB8027CAF3428D44902EAC7940634F5231AF9A867BBF21A3C941BE0C64A79D25B0AB7419A04310D8334F4439A9DB4CE4283CE00399DC3E81F2BBC94EDFA9A1DB481CE5DA5BFEFCE4C4173253A97EFD6F37AB2E1C1225ADFADB242142FE6B7D9E37C73738364D875681328FFAEF232B753A0E08B155A18B8CE2CFFAF3C4EC9AD366DFD4A802C9D7FF6BD7D2E9DBE72B4F3A6C910DE3269FBC628391C8D383186F14F3534E6E8CE4A489868DD72C15C776F8473C2FDC3E10F504B07088C0FDEA6C70100007B030000504B010214001400080808006271265A8C0FDEA6C70100007B0300000300000000000000000000000000000000007A6970504B0506000000000100010031000000F80100000000, 0x504B03041400080808006271265A000000000000000000000000030000007A69708D525B8ADB3014DD8B7E9B0F4B7E07E6239DB8534309214DDAC2301445BE4E55E4472D299312B28FAEA05028CCEFAC68E8327A6527949649281883EE39F7DCD7D993D2D6C2C8A6E6EA9A6B20E33D91051993D067694A638FB2244A29F3C888D4B62263EA79611C8D48D5145641EEA85DD31884DBAEF90CC2F42164791E1DFE0819A85AC50D9CB0240882304923E68569E8854E9B57589B3C3DFEFCF5E3FBD3B707FC28F311E8602BE1FEADE1C66A24AC661F17D9BB3C7B9F4D9D2EDF60F0F66E4404F69E15D22CBFB64E67997D58BA961A84718E84C56C44B6D0691CF4B9268234E96B95F999D915D726DB81B00616A0AD32489B67B3693EBB41B400050630B5E44A03D6B56B25C5B0CF63C88DAF31CB74165FA2037CAF3474A813FB944594FA3461210D19EAD9B6B8040FE506B8B64A9DF496D22D91C67E44033F095840D393D6DF50846B8F4F3A03E4740EA37FDCF05235EB4B8ED0065A7795FEC43B33052D3AD9BA7CB79ED7938DF017E1FA6695E6327F31BF4EEFE79BAB2B24C3AE45A740F16795E7B96D071D7CB1524B039799C5FF95C72985D5A6A95E495085F3CFBEB7CFB9D3978EF6BC695284B75CD9BE31460E47234E8CE1E25305B539BAB3940A265A374272D7DD1BE99C707B77F80D504B070858C3B763CE0100007E030000504B010214001400080808006271265A58C3B763CE0100007E0300000300000000000000000000000000000000007A6970504B0506000000000100010031000000FF0100000000); + diff --git a/backend/crm/src/test/resources/dml/init_opportunity_rule_test.sql b/backend/crm/src/test/resources/dml/init_opportunity_rule_test.sql new file mode 100644 index 0000000..c0d2260 --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_opportunity_rule_test.sql @@ -0,0 +1,8 @@ +INSERT INTO opportunity_rule (id, name, organization_id, owner_id, scope_id, enable, auto, operator, + `condition`, create_time, create_user, update_time, update_user) VALUES +('opportunity_rule_id', 'test_rule', '100001', 'admin', '[\"admin\"]', 1, 1, 'and', '{\"column\":\"storageTime\",\"operator\":\"DYNAMICS\",\"value\":\"6,month\",\"scope\":[\"Created\"]}', + CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, 'admin'); +INSERT INTO sys_organization_user (id, organization_id, department_id, user_id, enable, create_time, create_user, update_time, update_user) VALUES +('sys_org_user_id', '100001', 'default_dpt_id', 'admin', 1, CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, 'admin'); +INSERT INTO sys_department (id, name, organization_id, parent_id, pos, create_time, create_user, update_time, update_user, resource) VALUES +('default_dpt_id', 'default_dpt', '100001', 'root', 1, CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, 'admin', ''); \ No newline at end of file diff --git a/backend/crm/src/test/resources/dml/init_opportunity_test.sql b/backend/crm/src/test/resources/dml/init_opportunity_test.sql new file mode 100644 index 0000000..fc3120d --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_opportunity_test.sql @@ -0,0 +1,7 @@ +INSERT INTO `product`(`id`, price, status, `organization_id`, `name`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES + ('11', 3.55,'1','100001', '产品1', 1741255009000, 1741255009000, 'admin', 'admin'), + ('22', 3.55,'1', '100001', '产品2', 1741255009000, 1741255009000, 'admin', 'admin'); + +INSERT INTO `customer`(`id`, `name`, `owner`, `collection_time`, `create_time`, `update_time`, `create_user`, `update_user`, `in_shared_pool`, `organization_id`) +VALUES ('123', '1', 'admin', 1741338550027, 1741338550027, 1741338550027, 'admin', 'admin', b'0', '100001'); diff --git a/backend/crm/src/test/resources/dml/init_permission_test.sql b/backend/crm/src/test/resources/dml/init_permission_test.sql new file mode 100644 index 0000000..252a8f1 --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_permission_test.sql @@ -0,0 +1,28 @@ +-- 初始化用于项目级别权限测试的用户 +INSERT INTO `sys_user` (`id`, `name`, `email`, `password`, `gender`, `phone`, `language`, `last_organization_id`, `create_time`, + `update_time`, `create_user`, `update_user`) +VALUES ('permission_test', 'permission_test', 'permission_test@cordys-crm.io', MD5('CordysCRM'), 1,'1253465576', 'zh_CN', '100001', + UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 'admin', 'admin'); + +-- 初始化一个用于权限测试的用户组,这里默认使用 PROJECT 作为ID,如果是组织和项目级别类似,便于根据权限的前缀找到对应测试的用户组 +INSERT INTO `sys_role`(id, name, internal, data_scope, create_time, update_time, create_user, update_user, + description, organization_id) +VALUES('permission_test', 'permission_test', 0, 'ALL', UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 'admin', 'admin', + 'permission_test', '100001'); + +-- 初始化用户和角色的关系 +INSERT INTO `sys_user_role` (id, role_id, user_id, create_time, update_time, create_user, update_user) +VALUES('permission_test', 'permission_test', 'permission_test', UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 'admin', 'admin'); + +-- 初始化组织和用户的关系 +INSERT INTO sys_organization_user +(`id`, `organization_id`, `department_id`, `resource_user_id`, `user_id`, `enable`, `employee_id`, `position`, `employee_type`, `supervisor_id`, `work_city`, `create_user`, `update_user`, `create_time`, `update_time`) +VALUES ('permission_test', '100001', '100001', '', 'permission_test', true, '', '', '', '', '', 'admin', 'admin', UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000); + + +INSERT INTO `sys_department`(`id`, `name`, `organization_id`, `parent_id`, `pos`, `create_time`, `update_time`, `create_user`, `update_user`, `resource`, `resource_id`) +VALUES + ('100001', 'permission_test_department', '100001', 'NONE', 1, 1736240043609, 1736240043609, 'admin', 'admin', 'INTERNAL', NULL); + + + diff --git a/backend/crm/src/test/resources/dml/init_resource_approval_test.sql b/backend/crm/src/test/resources/dml/init_resource_approval_test.sql new file mode 100644 index 0000000..9e800bf --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_resource_approval_test.sql @@ -0,0 +1,23 @@ +DELETE FROM approval_record WHERE id IN ('approval_record_current', 'approval_record_other'); +DELETE FROM approval_task WHERE id IN ('approval_task_current', 'approval_task_other'); +DELETE FROM approval_instance WHERE id = 'approval_instance_test_001'; +DELETE FROM sys_user WHERE id IN ('appr_user_curr', 'appr_user_othr'); + +INSERT INTO sys_user (`id`, `name`, `email`, `password`, `gender`, `phone`, `language`, `last_organization_id`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES + ('appr_user_curr', 'Current Approver', 'current.approver@cordys-crm.io', MD5('CordysCRM'), 1, '13900000001', 'zh_CN', '100001', 1736240043609, 1736240043609, 'admin', 'admin'), + ('appr_user_othr', 'Other Approver', 'other.approver@cordys-crm.io', MD5('CordysCRM'), 1, '13900000002', 'zh_CN', '100001', 1736240043609, 1736240043609, 'admin', 'admin'); + +INSERT INTO approval_instance (`id`, `flow_version_id`, `type`, `resource_id`, `submitter_id`, `current_node_id`, `approval_status`, `submit_time`, `approval_time`,`create_time`, `update_time`, `create_user`, `update_user`) +VALUES + ('approval_instance_test_001', 'approval_flow_test_001', 'contract', 'approval_resource_test_001', 'admin', 'node_current', 'APPROVING', 1736240043609, NULL, 1736240043609, 1736240043609, 'admin', 'admin'); + +INSERT INTO approval_task (`id`, `node_id`, `node_round`, `instance_id`, `approver_id`, `status`, `type`, `action`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES + ('approval_task_current', 'node_current', 1, 'approval_instance_test_001', 'appr_user_curr', 'APPROVED', 'NL', 'APPROVE', 1736240043609, 1736240043609, 'admin', 'admin'), + ('approval_task_other', 'node_other', 1, 'approval_instance_test_001', 'appr_user_othr', 'APPROVED', 'NL', 'APPROVE',1736241043609, 1736241043609, 'admin', 'admin'); + +INSERT INTO approval_record (`id`, `instance_id`, `task_id`, `node_id`, `node_round`, `result`, `comment`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES + ('approval_record_current', 'approval_instance_test_001', 'approval_task_current', 'node_current', 1, 'APPROVED', 'current-node-comment', 1736240043609, 1736240043609, 'admin', 'admin'), + ('approval_record_other', 'approval_instance_test_001', 'approval_task_other', 'node_other', 1, 'UNAPPROVED', 'other-node-comment', 1736241043609, 1736241043609, 'admin', 'admin'); diff --git a/backend/crm/src/test/resources/dml/init_user_login_test.sql b/backend/crm/src/test/resources/dml/init_user_login_test.sql new file mode 100644 index 0000000..c90a581 --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_user_login_test.sql @@ -0,0 +1,30 @@ +-- 初始化一个系统管理员 + +INSERT INTO `sys_user` (`id`, + `name`, + `email`, + `password`, + `gender`, + `phone`, + `language`, + `last_organization_id`, + `create_time`, + `update_time`, + `create_user`, + `update_user`) +VALUES ("test.login@cordys.io", + 'test.loginistrator', + 'test.login@cordys.io', + MD5('test.login'), + 1, + '13651666666', + 'zh_CN', + '100001', + 1716175907000, + 1729752373360, + '717345437786112', + 'test.login'); + +INSERT INTO `sys_organization_user`(`id`, `organization_id`, `department_id`, `resource_user_id`, `user_id`, `enable`, `employee_id`, `position`, `employee_type`, `supervisor_id`, `work_city`, `create_user`, `update_user`, `create_time`, `update_time`) +VALUES ("test-sys_organization_user-0001", '100001', '100001', 'test.login@cordys.io', 'test.login@cordys.io', true, '', '', '', '', '', 'admin', 'admin', 1716175907000, 1716175907000); + diff --git a/backend/crm/src/test/resources/dml/init_user_test.sql b/backend/crm/src/test/resources/dml/init_user_test.sql new file mode 100644 index 0000000..0e20025 --- /dev/null +++ b/backend/crm/src/test/resources/dml/init_user_test.sql @@ -0,0 +1,31 @@ +-- 初始化用户 +INSERT INTO `sys_user`(`id`, `name`, `phone`, `email`, `password`, `gender`, `language`, `last_organization_id`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES + ('1', '用户1', '1234567', '1@cordys-crm.io', '4706261da78b85ec9553be61a15f924c', 1, 'zh_CN', '100001', 1716175907000, 1729752373360, '717345437786112', 'admin'), + ('2', '用户2', '2234567', '2@cordys-crm.io', '4706261da78b85ec9553be61a15f924c', 1, 'zh_CN', '100001', 1716175907000, 1729752373360, '717345437786112', 'admin'), + ('3', '用户3', '3234567', '3@cordys-crm.io', '4706261da78b85ec9553be61a15f924c', 0, 'zh_CN', '100001', 1716175907000, 1729752373360, '717345437786112', 'admin'), + ('4', '用户4', '4234567', '4@cordys-crm.io', '4706261da78b85ec9553be61a15f924c', 0, 'zh_CN', '100001', 1716175907000, 1729752373360, '717345437786112', 'admin'), + ('5', '用户5', '5234567', '5@cordys-crm.io', '4706261da78b85ec9553be61a15f924c', 1, 'zh_CN', '100001', 1716175907000, 1729752373360, '717345437786112', 'admin'); + + + + +INSERT INTO `sys_organization_user`(`id`, `organization_id`, `department_id`, `resource_user_id`, `user_id`, `enable`, `employee_id`, `position`, `employee_type`, `supervisor_id`, `work_city`, `create_user`, `update_user`, `create_time`, `update_time`) +VALUES + ('u_1', '100001', '9', '', '1', true, '', '', '', '', '', 'admin', 'admin', 1716175907000, 1716175907000), + ('u_2', '100001', '9', '', '2', true, '', '', '', '', '', 'admin', 'admin', 1716175907000, 1716175907000), + ('u_3', '100001', '9', '', '3', true, '', '', '', '', '', 'admin', 'admin', 1716175907000, 1716175907000), + ('u_4', '100001', '9', '', '4', true, '', '', '', '', '', 'admin', 'admin', 1716175907000, 1716175907000), + ('u_5', '100001', '9', '', '5', true, '', '', '', '', '', 'admin', 'admin', 1716175907000, 1716175907000); + + +INSERT INTO `sys_role`(`id`, `name`, `internal`, `data_scope`, `create_time`, `update_time`, `create_user`, `update_user`, `description`, `organization_id`) +VALUES ('r_1', '测试角色', 0, '1', 1716175907000, 1716175907000, 'admin', 'admin', NULL, '100001'); + +INSERT INTO `sys_user_role`(`id`, `role_id`, `user_id`, `create_time`, `update_time`, `create_user`, `update_user`) +VALUES ('sur_1', 'r_1', '1', 1716175907000, 1716175907000, 'admin', 'admin'); + + +INSERT INTO `sys_department`(`id`, `name`, `organization_id`, `parent_id`, `pos`, `create_time`, `update_time`, `create_user`, `update_user`, `resource`, `resource_id`) +VALUES + ('9', '部门9', '100001', '4', 6, 1736240043609, 1736240043609, 'admin', 'admin', 'INTERNAL', NULL); diff --git a/backend/crm/src/test/resources/file/user.xlsx b/backend/crm/src/test/resources/file/user.xlsx new file mode 100644 index 0000000..3db1525 Binary files /dev/null and b/backend/crm/src/test/resources/file/user.xlsx differ diff --git a/backend/crm/src/test/resources/file/user1.xlsx b/backend/crm/src/test/resources/file/user1.xlsx new file mode 100644 index 0000000..0b123bd Binary files /dev/null and b/backend/crm/src/test/resources/file/user1.xlsx differ diff --git a/backend/crm/src/test/resources/logback-spring.xml b/backend/crm/src/test/resources/logback-spring.xml new file mode 100644 index 0000000..74dd23c --- /dev/null +++ b/backend/crm/src/test/resources/logback-spring.xml @@ -0,0 +1,172 @@ + + + + + + + %d %5p %40.40c:%4L - %m%n + + + + + + + TRACE + ACCEPT + DENY + + ${logging.file.path}/trace.log + + ${logging.file.path}/history/trace.%d{yyyyMMdd}-%i.log + ${logger.max.history:-30} + 50MB + + + UTF-8 + %d [%thread] %-5level %logger{36} %line - %msg%n + + + + + + DEBUG + ACCEPT + DENY + + ${logging.file.path}/debug.log + + ${logging.file.path}/history/debug.%d{yyyyMMdd}-%i.log + ${logger.max.history:-30} + 50MB + + + UTF-8 + %d [%thread] %-5level %logger{36} %line - %msg%n + + + + + + INFO + + ${logging.file.path}/info.log + + ${logging.file.path}/history/info.%d{yyyyMMdd}-%i.log + ${logger.max.history:-30} + 50MB + + + UTF-8 + %d [%thread] %-5level %logger{36} %line - %msg%n + + + + + + ERROR + ACCEPT + DENY + + + ${logging.file.path}/error.log + + ${logging.file.path}/history/error.%d{yyyyMMdd}-%i.log + ${logger.max.history:-30} + 50MB + + + UTF-8 + %d [%thread] %-5level %logger{36} %line - %msg%n + + + + + + WARN + ACCEPT + DENY + + ${logging.file.path}/warn.log + + ${logging.file.path}/history/warn.%d{yyyyMMdd}-%i.log + ${logger.max.history:-30} + 50MB + + + UTF-8 + %d [%thread] %-5level %logger{36} %line - %msg%n + + + + + + DEBUG + + 10000 + + + + + + DEBUG + + 10000 + + + + + + + DEBUG + + 10000 + + + + + + INFO + + 10000 + + + + + + ERROR + + 10000 + true + + + + + + WARN + + 10000 + true + + + + + + + + + + + + + + + + + + + + diff --git a/backend/framework/README.md b/backend/framework/README.md new file mode 100755 index 0000000..29d5873 --- /dev/null +++ b/backend/framework/README.md @@ -0,0 +1,180 @@ +# 框架通用模块使用说明 + +## 目录 + +- [概述](#概述) +- [模块结构](#模块结构) +- [使用指南](#使用指南) + - [AOP 支持](#aop-支持) + - [公共功能](#公共功能) + - [MyBatis 配置](#mybatis-配置) + - [文件处理](#文件处理) + - [安全性管理](#安全性管理) + +## 概述 + +本框架是一个企业级应用开发基础设施,提供了多种常用功能模块,包括 AOP 支持、公共工具类、MyBatis 增强、文件处理、安全性管理等。框架旨在简化开发流程,提高代码质量和开发效率。 + +## 模块结构 + +``` +framework/ +├── aop/ # AOP 相关功能 +├── common/ # 公共功能 +├── config/ # 配置相关 +├── file/ # 文件处理 +├── mybatis/ # MyBatis 增强 +└── security/ # 安全性管理 +``` + +## 使用指南 + +### AOP 支持 + +AOP 模块提供了面向切面编程的基础设施,包含以下子模块: + +| 子模块 | 说明 | +|--------|------| +| annotation | 定义 AOP 功能的注解 | +| aspect | 实现切面逻辑 | +| builder | 提供构建器模式工具 | +| constants | 定义常量,避免硬编码 | +| dto | 数据传输对象 | +| event | 定义 AOP 相关事件 | + +#### 使用示例 + +##### 1. 基础日志记录 + +```java +@OperationLog( + module = LogModule.SYSTEM, + type = LogType.ADD, + operator = "{{#user.name}}", + resourceId = "{{#user.id}}", + success = "添加用户成功", + extra = "{{#newUser}}" +) +public void addUser(User user) { + // 业务代码 + + // 添加日志上下文 + OperationLogContext.putVariable("newUser", LogExtraDTO.builder() + .originalValue(null) + .modifiedValue(user) + .build()); +} +``` + +说明: +- `resourceId`:业务资源的 ID +- `success`:方法调用成功后记录的日志内容 +- 双大括号 `{{}}` 中的内容是 SpEL 表达式,支持调用静态方法、三目表达式等 + +##### 2. 成功/失败日志记录 + +```java +@OperationLog( + fail = "业务操作失败,失败原因:「{{#_errorMsg}}」", + success = "业务操作成功", + operator = "{{#user.name}}", + type = LogType.ADD, + resourceId = "{{#biz.id}}" +) +public boolean create(BizObj obj) { + OperationLogContext.putVariable("innerOrder", LogExtraDTO.builder() + .originalValue(obj) + .modifiedValue(obj) + .build()); + return true; +} +``` + +- `#_errorMsg` 是方法抛出异常后自动获取的错误信息 + +##### 3. 多条日志记录 + +```java +@OperationLog( + module = LogModule.SYSTEM, + type = LogType.UPDATE, + operator = "{{#user.name}}", + resourceId = "{{#user.id}}", + success = "更新用户基本信息成功", + extra = "{{#upUser}}" +) +@OperationLog( + module = LogModule.SYSTEM, + type = LogType.UPDATE, + operator = "{{#user.name}}", + resourceId = "{{#user.id}}", + success = "更新用户权限成功", + extra = "{{#upPermission}}" +) +public void updateUser(User user) { + // 更新用户 + User preUser = userMapper.selectByPrimaryKey(user.getId()); + + // 添加日志上下文 + OperationLogContext.putVariable("upUser", LogExtraDTO.builder() + .originalValue(preUser) + .modifiedValue(user) + .build()); +} +``` + +### 公共功能 + +公共功能模块提供了常用工具和基础功能: + +- **constants**:常量定义,避免魔法值 +- **exception**:自定义异常体系 +- **groups**:分组管理 +- **pager**:分页查询功能 +- **response**:统一响应格式 +- **uid**:唯一标识符生成 +- **util**:工具类(包含加密、编码等) + +### MyBatis 配置 + +MyBatis 配置模块增强了 MyBatis 的功能: + +- **interceptor**:自定义拦截器 +- **lambda**:Lambda 表达式支持,简化查询构建 + +#### 使用示例 + +```java +@Resource +private BaseMapper userMapper; + +// Lambda 查询示例 +List userList = userMapper.selectListByLambda( + new LambdaQueryWrapper() + .eq(User::getId, "admin") +); +``` + +自动生成的 SQL: +```sql +SELECT * FROM user WHERE id = 'admin' +``` + +### 文件处理 + +文件处理模块提供了文件操作相关功能: + +- **engine**:文件引擎,支持文件上传、下载、存储等操作 + +### 安全性管理 + +安全性管理模块提供了身份验证、授权和加密等功能: + +- 身份验证 +- 权限控制 +- 数据加密 +- 安全审计 + +## 更多信息 + +更多详细信息和高级用法,请参阅各模块的详细文档或源代码注释。 \ No newline at end of file diff --git a/backend/framework/pom.xml b/backend/framework/pom.xml new file mode 100644 index 0000000..4ef437c --- /dev/null +++ b/backend/framework/pom.xml @@ -0,0 +1,14 @@ + + + 4.0.0 + + + cn.cordys + backend + ${revision} + + + framework + ${revision} + diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/annotation/OperationLog.java b/backend/framework/src/main/java/cn/cordys/aspectj/annotation/OperationLog.java new file mode 100644 index 0000000..c25beff --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/annotation/OperationLog.java @@ -0,0 +1,48 @@ +package cn.cordys.aspectj.annotation; + +import cn.cordys.aspectj.constants.LogModule; +import cn.cordys.aspectj.constants.LogType; + +import java.lang.annotation.*; + +/** + * @author mr.zhao + */ +@Repeatable(OperationLogs.class) +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface OperationLog { + /** + * @return 日志的操作人 + * 没填,默认从 session 中获取 + */ + String operator() default ""; + + /** + * @return 操作日志的类型,如:新增、修改、删除 + * {@link LogType} + */ + String type(); + + /** + * @return 业务模块名 + * {@link LogModule} + */ + String module() default ""; + + /** + * @return 日志绑定的业务标识 + * 可以在注解中设置,也可以在 LogContextInfo 中设置 + * 优先级低于 LogContextInfo + */ + String resourceId() default ""; + + /** + * @return 操作对象的名称 + * 可以在注解中设置,也可以在 LogContextInfo 中设置 + * 优先级低于 LogContextInfo + */ + String resourceName() default ""; +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/annotation/OperationLogs.java b/backend/framework/src/main/java/cn/cordys/aspectj/annotation/OperationLogs.java new file mode 100644 index 0000000..e401e49 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/annotation/OperationLogs.java @@ -0,0 +1,12 @@ +package cn.cordys.aspectj.annotation; + +import java.lang.annotation.*; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface OperationLogs { + OperationLog[] value(); +} + diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/aop/OperationLogAopAdvisor.java b/backend/framework/src/main/java/cn/cordys/aspectj/aop/OperationLogAopAdvisor.java new file mode 100644 index 0000000..a0fe087 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/aop/OperationLogAopAdvisor.java @@ -0,0 +1,78 @@ +package cn.cordys.aspectj.aop; + +import lombok.Setter; +import org.springframework.aop.Pointcut; +import org.springframework.aop.support.AbstractBeanFactoryPointcutAdvisor; +import org.springframework.aop.support.StaticMethodMatcherPointcut; +import org.springframework.core.Ordered; +import org.springframework.util.CollectionUtils; + +import java.io.Serializable; +import java.lang.reflect.Method; +import java.util.regex.Pattern; + +/** + * BeanFactoryLogRecordAdvisor with LogRecordPointcut + *

+ * 结合了日志记录切点与通知的定义,提供一个完整的日志记录拦截功能。 + * 通过配置 `LogRecordOperationSource` 和自定义的 `LogRecordPointcut` 来判断 + * 哪些方法需要进行日志记录操作。 + *

+ */ +@Setter +public class OperationLogAopAdvisor extends AbstractBeanFactoryPointcutAdvisor implements Serializable, Ordered { + + /** + * 日志记录操作源,用于获取方法的日志记录操作配置。 + */ + private OperationLogSource operationLogSource; + + /** + * 获取切面优先级,值越小优先级越高 + * 设置较高优先级,确保 @OperationLog 切面在其他切面之前执行 + */ + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE + 10; + } + + /** + * 获取日志记录的切点,用于定义拦截哪些方法。 + * + * @return 返回定义的切点对象 + */ + @Override + public Pointcut getPointcut() { + return new LogRecordPointcut(operationLogSource); + } + + /** + * 自定义日志记录切点,用于判断哪些方法需要进行日志记录操作。 + */ + private static class LogRecordPointcut extends StaticMethodMatcherPointcut implements Serializable { + + private final OperationLogSource operationLogSource; + + public LogRecordPointcut(OperationLogSource operationLogSource) { + this.operationLogSource = operationLogSource; + } + + @Override + public boolean matches(Method method, Class targetClass) { + // 定义支持通配符的包名模式 + // todo: 后续需要修改 + String packagePattern = "cn.cordys\\..*\\.service"; + + // 获取目标类的包名 + String targetPackage = targetClass.getPackage().getName(); + + // 使用正则表达式匹配包名 + if (!Pattern.matches(packagePattern, targetPackage)) { + return false; + } + + // 检查方法是否有日志操作配置 + return !CollectionUtils.isEmpty(operationLogSource.computeLogRecordOperations(method, targetClass)); + } + } +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/aop/OperationLogSource.java b/backend/framework/src/main/java/cn/cordys/aspectj/aop/OperationLogSource.java new file mode 100644 index 0000000..c30723e --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/aop/OperationLogSource.java @@ -0,0 +1,151 @@ +package cn.cordys.aspectj.aop; + +import cn.cordys.aspectj.annotation.OperationLog; +import cn.cordys.aspectj.annotation.OperationLogs; +import cn.cordys.aspectj.builder.OperationLogBuilder; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.dto.LogContextInfo; +import org.apache.commons.lang3.StringUtils; +import org.springframework.core.BridgeMethodResolver; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.util.ClassUtils; +import org.springframework.util.ConcurrentReferenceHashMap; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 该类负责解析和计算基于注解的日志记录操作。 + */ +public class OperationLogSource { + + /** + * 缓存方法与其对应接口方法的映射关系。 + */ + private static final Map INTERFACE_METHOD_CACHE = new ConcurrentReferenceHashMap<>(256); + + /** + * 确定给定方法对应的接口方法(如果可能)。 + * + * @param method 要处理的方法 + * + * @return 对应的接口方法,如果不存在则返回原方法 + */ + public static Method getInterfaceMethodIfPossible(Method method) { + if (!Modifier.isPublic(method.getModifiers()) || method.getDeclaringClass().isInterface()) { + return method; + } + return INTERFACE_METHOD_CACHE.computeIfAbsent(method, key -> { + Class current = key.getDeclaringClass(); + while (current != null && current != Object.class) { + for (Class ifc : current.getInterfaces()) { + try { + return ifc.getMethod(key.getName(), key.getParameterTypes()); + } catch (NoSuchMethodException ex) { + // 忽略异常 + } + } + current = current.getSuperclass(); + } + return key; + }); + } + + /** + * 根据指定的方法和目标类计算日志记录操作。 + * + * @param method 要分析的方法 + * @param targetClass 目标类 + * + * @return 日志记录构建器的集合 + */ + public Collection computeLogRecordOperations(Method method, Class targetClass) { + if (!Modifier.isPublic(method.getModifiers())) { + return Collections.emptyList(); + } + + Method specificMethod = ClassUtils.getMostSpecificMethod(method, targetClass); + specificMethod = BridgeMethodResolver.findBridgedMethod(specificMethod); + + Collection logRecordOps = parseLogRecordAnnotations(specificMethod); + Collection logRecordsOps = parseLogRecordsAnnotations(specificMethod); + Collection abstractLogRecordOps = parseLogRecordAnnotations(getInterfaceMethodIfPossible(method)); + Collection abstractLogRecordsOps = parseLogRecordsAnnotations(getInterfaceMethodIfPossible(method)); + + Set result = new HashSet<>(); + result.addAll(logRecordOps); + result.addAll(abstractLogRecordOps); + result.addAll(logRecordsOps); + result.addAll(abstractLogRecordsOps); + return result; + } + + /** + * 解析方法上的 {@code LogRecords} 注解。 + * + * @param ae 被注解的元素 + * + * @return 日志记录构建器的集合 + */ + private Collection parseLogRecordsAnnotations(AnnotatedElement ae) { + return AnnotatedElementUtils.findAllMergedAnnotations(ae, OperationLogs.class).stream() + .flatMap(operationLogs -> Arrays.stream(operationLogs.value()) + .map(logRecord -> parseLogRecordAnnotation(ae, logRecord))) + .collect(Collectors.toList()); + } + + /** + * 解析方法上的 {@code LogRecord} 注解。 + * + * @param ae 被注解的元素 + * + * @return 日志记录构建器的集合 + */ + private Collection parseLogRecordAnnotations(AnnotatedElement ae) { + return AnnotatedElementUtils.findAllMergedAnnotations(ae, OperationLog.class).stream() + .map(recordAnnotation -> parseLogRecordAnnotation(ae, recordAnnotation)) + .collect(Collectors.toList()); + } + + /** + * 将 {@code LogRecord} 注解转换为日志记录构建器。 + * + * @param ae 被注解的元素 + * @param recordAnnotation {@code LogRecord} 注解 + * + * @return 日志记录构建器 + */ + private OperationLogBuilder parseLogRecordAnnotation(AnnotatedElement ae, OperationLog recordAnnotation) { + return OperationLogBuilder.builder() + .resourceName(recordAnnotation.resourceName()) + .type(recordAnnotation.type()) + .resourceId(recordAnnotation.resourceId()) + .operatorId(recordAnnotation.operator()) + .subType(recordAnnotation.module()) + .build(); + } + + /** + * 验证日志记录操作配置是否合法。 + * + * @param recordOps 日志记录构建器 + * + * @throws IllegalStateException 如果配置不合法 + */ + public boolean isLogRecordOperationValidated(OperationLogBuilder recordOps) { + LogContextInfo extra = OperationLogContext.getContext(); + String resourceName = recordOps.getResourceName(); + String resourceId = recordOps.getResourceId(); + if (extra != null && StringUtils.isNotBlank(extra.getResourceName())) { + resourceName = extra.getResourceName(); + } + if (extra != null && StringUtils.isNotBlank(extra.getResourceId())) { + resourceId = extra.getResourceId(); + } + + return !StringUtils.isBlank(resourceName) && !StringUtils.isBlank(resourceId); + } +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/aop/OperationOperationLogInterceptor.java b/backend/framework/src/main/java/cn/cordys/aspectj/aop/OperationOperationLogInterceptor.java new file mode 100644 index 0000000..f4839c7 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/aop/OperationOperationLogInterceptor.java @@ -0,0 +1,201 @@ +package cn.cordys.aspectj.aop; + +import cn.cordys.aspectj.builder.MethodExecuteResult; +import cn.cordys.aspectj.builder.OperationLog; +import cn.cordys.aspectj.builder.OperationLogBuilder; +import cn.cordys.aspectj.builder.parse.OperationLogValueParser; +import cn.cordys.aspectj.constants.CodeVariableType; +import cn.cordys.aspectj.context.OperationLogContext; +import cn.cordys.aspectj.handler.OperationLogService; +import cn.cordys.common.util.CommonBeanFactory; +import cn.cordys.security.SessionUtils; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.apache.commons.lang3.StringUtils; +import org.springframework.aop.framework.AopProxyUtils; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.util.CollectionUtils; + +import java.io.Serializable; +import java.lang.reflect.Method; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 日志记录拦截器,拦截方法执行并生成日志记录。 + *

+ * 该类支持基于注解的日志模板解析,能够在方法执行的前后记录业务操作日志。 + *

+ */ +@Slf4j +public class OperationOperationLogInterceptor extends OperationLogValueParser implements MethodInterceptor, Serializable, SmartInitializingSingleton { + + @Setter + private OperationLogSource operationLogSource; + + private OperationLogService operationLogService; + + /** + * 拦截方法执行,进行日志记录逻辑的处理。 + * + * @param invocation 方法调用上下文 + * + * @return 方法执行结果 + * + * @throws Throwable 执行过程中的异常 + */ + @Override + public Object invoke(MethodInvocation invocation) throws Throwable { + Method method = invocation.getMethod(); + return execute(invocation, invocation.getThis(), method, invocation.getArguments()); + } + + /** + * 核心执行逻辑:完成方法调用并处理日志记录。 + * + * @param invoker 方法调用上下文 + * @param target 目标对象 + * @param method 目标方法 + * @param args 方法参数 + * + * @return 方法的返回结果 + * + * @throws Throwable 执行过程中的异常 + */ + private Object execute(MethodInvocation invoker, Object target, Method method, Object[] args) throws Throwable { + if (AopUtils.isAopProxy(target)) { + return invoker.proceed(); + } + + Class targetClass = getTargetClass(target); + Object ret = null; + MethodExecuteResult methodExecuteResult = new MethodExecuteResult(method, args, targetClass); + OperationLogContext.putEmptySpan(); + Collection operations = new ArrayList<>(); + try { + operations = operationLogSource.computeLogRecordOperations(method, targetClass); + } catch (Exception e) { + log.error("日志解析异常", e); + } + + try { + ret = invoker.proceed(); + methodExecuteResult.setResult(ret); + methodExecuteResult.setSuccess(true); + } catch (Exception e) { + methodExecuteResult.setSuccess(false); + methodExecuteResult.setThrowable(e); + methodExecuteResult.setErrorMsg(e.getMessage()); + } + + processLogRecords(methodExecuteResult, operations); + + if (methodExecuteResult.getThrowable() != null) { + throw methodExecuteResult.getThrowable(); + } + + return ret; + } + + /** + * 处理日志记录逻辑,根据方法执行结果生成操作日志。 + * + * @param methodExecuteResult 方法执行结果 + * @param operations 日志操作集合 + */ + private void processLogRecords(MethodExecuteResult methodExecuteResult, Collection operations) { + if (CollectionUtils.isEmpty(operations)) { + return; + } + + for (OperationLogBuilder operation : operations) { + try { + if (!operationLogSource.isLogRecordOperationValidated(operation)) { + continue; + } + + if (methodExecuteResult.isSuccess()) { + handleSuccessLog(methodExecuteResult, operation); + } + } catch (Exception e) { + log.error("日志执行异常", e); + } + } + } + + /** + * 处理成功的日志记录。 + * + * @param methodExecuteResult 方法执行结果 + * @param operation 日志操作信息 + */ + private void handleSuccessLog(MethodExecuteResult methodExecuteResult, OperationLogBuilder operation) { + List templates = getSpElTemplates(operation); + String operatorId = resolveOperatorId(operation, templates); + Map expressionValues = processTemplate(templates, methodExecuteResult); + + saveLogRecord(methodExecuteResult.getMethod(), operation, operatorId, expressionValues); + } + + /** + * 保存日志记录。 + * + * @param method 方法对象 + * @param operation 日志操作信息 + * @param operatorId 操作人 ID + * @param expressionValues 模板解析后的值 + */ + private void saveLogRecord(Method method, OperationLogBuilder operation, String operatorId, Map expressionValues) { + OperationLog operationLog = OperationLog.builder() + .type(expressionValues.get(operation.getType())) + .resourceId(expressionValues.get(operation.getResourceId())) + .resourceName(expressionValues.get(operation.getResourceName())) + .operator(expressionValues.get(operatorId)) + .subType(expressionValues.get(operation.getSubType())) + .codeVariable(resolveCodeVariable(method)) + .createTime(new Date()) + .build(); + + operationLogService.record(operationLog); + } + + private Map resolveCodeVariable(Method method) { + return Map.of( + CodeVariableType.ClassName, method.getDeclaringClass(), + CodeVariableType.MethodName, method.getName() + ); + } + + private String resolveOperatorId(OperationLogBuilder operation, List templates) { + if (StringUtils.isEmpty(operation.getOperatorId())) { + // 如果没有标注操作人,则获取登入用户 + operation.setOperatorId(SessionUtils.getUserId()); + } + templates.add(operation.getOperatorId()); + return operation.getOperatorId(); + } + + private Class getTargetClass(Object target) { + return AopProxyUtils.ultimateTargetClass(target); + } + + private List getSpElTemplates(OperationLogBuilder operation) { + List spElTemplates = new ArrayList<>(); + spElTemplates.add(operation.getType()); + spElTemplates.add(operation.getResourceId()); + spElTemplates.add(operation.getResourceName()); + spElTemplates.add(operation.getSubType()); + return spElTemplates.stream() + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + @Override + public void afterSingletonsInstantiated() { + operationLogService = CommonBeanFactory.getBean(OperationLogService.class); + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/builder/LogDTOBuilder.java b/backend/framework/src/main/java/cn/cordys/aspectj/builder/LogDTOBuilder.java new file mode 100644 index 0000000..bf32325 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/builder/LogDTOBuilder.java @@ -0,0 +1,68 @@ +package cn.cordys.aspectj.builder; + +import cn.cordys.aspectj.dto.LogDTO; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +/** + *

用于构建 {@link LogDTO} 的建造者类。

+ *

通过 {@link Builder} 模式构建 {@link LogDTO} 对象,确保日志数据的完整性和一致性。

+ */ +@Getter +@Setter +@Builder +public class LogDTOBuilder { + + /** + * 组织ID + */ + private String organizationId; + + /** + * 来源ID + */ + private String sourceId; + + /** + * 创建用户 + */ + private String createUser; + + /** + * 日志类型 + */ + private String type; + + /** + * 方法名 + */ + private String method; + + /** + * 模块名 + */ + private String module; + + /** + * 日志内容 + */ + private String content; + + /** + * 请求路径 + */ + private String path; + + /** + * 构建 {@link LogDTO} 对象 + * + * @return {@link LogDTO} 对象 + */ + public LogDTO getLogDTO() { + LogDTO logDTO = new LogDTO(organizationId, sourceId, createUser, type, module, content); + logDTO.setPath(path); + logDTO.setMethod(method); + return logDTO; + } +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/builder/MethodExecuteResult.java b/backend/framework/src/main/java/cn/cordys/aspectj/builder/MethodExecuteResult.java new file mode 100644 index 0000000..be03399 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/builder/MethodExecuteResult.java @@ -0,0 +1,28 @@ +package cn.cordys.aspectj.builder; + +import lombok.Getter; +import lombok.Setter; + +import java.lang.reflect.Method; + +@Getter +public class MethodExecuteResult { + private final Method method; + private final Object[] args; + private final Class targetClass; + @Setter + private boolean success; + @Setter + private Throwable throwable; + @Setter + private String errorMsg; + @Setter + private Object result; + + public MethodExecuteResult(Method method, Object[] args, Class targetClass) { + this.method = method; + this.args = args; + this.targetClass = targetClass; + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/builder/OperationLog.java b/backend/framework/src/main/java/cn/cordys/aspectj/builder/OperationLog.java new file mode 100644 index 0000000..4cf15cb --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/builder/OperationLog.java @@ -0,0 +1,79 @@ +package cn.cordys.aspectj.builder; + +import cn.cordys.aspectj.constants.CodeVariableType; +import jakarta.validation.constraints.NotBlank; +import lombok.*; +import org.hibernate.validator.constraints.Length; + +import java.io.Serializable; +import java.util.Date; +import java.util.Map; + +@Setter +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@ToString +public class OperationLog { + + /** + * id + */ + private Serializable id; + /** + * 保存的操作日志的类型,比如:订单类型、商品类型 + **/ + @NotBlank(message = "type required") + @Length(max = 200, message = "type max length is 200") + private String type; + + /** + * 日志的子类型,比如订单的C端日志,和订单的B端日志,type都是订单类型,但是子类型不一样 + */ + private String subType; + + /** + * 日志绑定的业务标识 + */ + @NotBlank(message = "resourceId required") + @Length(max = 100, message = "resourceId max length is 100") + private String resourceId; + + /** + * 操作对象的名称 + */ + @NotBlank(message = "resourceName name") + private String resourceName; + + /** + * 日志详情 + */ + @NotBlank(message = "日志详情(非对比的日志详情)") + private String detail; + + /** + * 操作人 + */ + @NotBlank(message = "operator required") + @Length(max = 50, message = "operator max length 50") + private String operator; + + /** + * 日志内容 + */ + @NotBlank(message = "opAction required") + @Length(max = 500, message = "operator max length 500") + private String action; + + /** + * 日志的创建时间 + */ + private Date createTime; + + /** + * 打印日志的代码信息 + * CodeVariableType 日志记录的ClassName、MethodName + */ + private Map codeVariable; +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/builder/OperationLogBuilder.java b/backend/framework/src/main/java/cn/cordys/aspectj/builder/OperationLogBuilder.java new file mode 100644 index 0000000..0e96350 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/builder/OperationLogBuilder.java @@ -0,0 +1,15 @@ +package cn.cordys.aspectj.builder; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class OperationLogBuilder { + private String resourceId; + private String resourceName; + private String operatorId; + private String type; + private String subType; + private String extra; +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/builder/parse/OperationLogEvaluationContext.java b/backend/framework/src/main/java/cn/cordys/aspectj/builder/parse/OperationLogEvaluationContext.java new file mode 100644 index 0000000..89d7236 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/builder/parse/OperationLogEvaluationContext.java @@ -0,0 +1,52 @@ +package cn.cordys.aspectj.builder.parse; + +import cn.cordys.aspectj.context.OperationLogContext; +import org.springframework.context.expression.MethodBasedEvaluationContext; +import org.springframework.core.ParameterNameDiscoverer; + +import java.lang.reflect.Method; +import java.util.Map; + +/** + * 自定义的日志记录表达式上下文类,继承自 {@link MethodBasedEvaluationContext},用于处理方法日志记录时的上下文。 + * 该类在构造时会将方法参数、返回值、错误信息以及全局变量和当前上下文的变量注入到表达式上下文中, + * 以便在日志记录时进行动态解析。 + */ +public class OperationLogEvaluationContext extends MethodBasedEvaluationContext { + + /** + * 构造方法,初始化日志记录表达式上下文。 + * + * @param method 被调用的方法 + * @param arguments 方法参数 + * @param parameterNameDiscoverer 参数名称发现器 + * @param ret 方法返回值 + * @param errorMsg 错误信息 + */ + public OperationLogEvaluationContext(Object target, Method method, Object[] arguments, + ParameterNameDiscoverer parameterNameDiscoverer, Object ret, String errorMsg) { + // 调用父类构造方法初始化 + super(target, method, arguments, parameterNameDiscoverer); + + // 获取日志记录上下文中的变量 + Map variables = OperationLogContext.getVariables(); + Map globalVariables = OperationLogContext.getGlobalVariableMap(); + + // 设置当前上下文中的变量 + setVariables(variables); + + // 如果全局变量不为空,将其添加到上下文中 + if (!globalVariables.isEmpty()) { + for (Map.Entry entry : globalVariables.entrySet()) { + // 如果当前上下文中没有该变量,则设置该变量 + if (lookupVariable(entry.getKey()) == null) { + setVariable(entry.getKey(), entry.getValue()); + } + } + } + + // 设置返回值和错误信息变量 + setVariable("_ret", ret); + setVariable("_errorMsg", errorMsg); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/builder/parse/OperationLogExpressionEvaluator.java b/backend/framework/src/main/java/cn/cordys/aspectj/builder/parse/OperationLogExpressionEvaluator.java new file mode 100644 index 0000000..e282168 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/builder/parse/OperationLogExpressionEvaluator.java @@ -0,0 +1,50 @@ +package cn.cordys.aspectj.builder.parse; + +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.context.expression.AnnotatedElementKey; +import org.springframework.context.expression.BeanFactoryResolver; +import org.springframework.context.expression.CachedExpressionEvaluator; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class OperationLogExpressionEvaluator extends CachedExpressionEvaluator { + + private final Map targetMethodCache = new ConcurrentHashMap<>(64); + private final Map expressionCache = new ConcurrentHashMap<>(64); + + public Object parseExpression(String conditionExpression, AnnotatedElementKey methodKey, EvaluationContext evalContext) { + return getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, Object.class); + } + + /** + * Create an {@link EvaluationContext}. + * + * @param method the method + * @param args the method arguments + * @param targetClass the target class + * @param result the return value (can be {@code null}) or + * @param errorMsg errorMsg + * @param beanFactory Spring beanFactory + * + * @return the evaluation context + */ + public EvaluationContext createEvaluationContext(Method method, Object[] args, Class targetClass, + Object result, String errorMsg, BeanFactory beanFactory) { + Method targetMethod = getTargetMethod(targetClass, method); + OperationLogEvaluationContext evaluationContext = new OperationLogEvaluationContext(targetClass, targetMethod, args, getParameterNameDiscoverer(), result, errorMsg); + if (beanFactory != null) { + evaluationContext.setBeanResolver(new BeanFactoryResolver(beanFactory)); + } + return evaluationContext; + } + + private Method getTargetMethod(Class targetClass, Method method) { + AnnotatedElementKey methodKey = new AnnotatedElementKey(method, targetClass); + return targetMethodCache.computeIfAbsent(methodKey, k -> AopUtils.getMostSpecificMethod(method, targetClass)); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/builder/parse/OperationLogValueParser.java b/backend/framework/src/main/java/cn/cordys/aspectj/builder/parse/OperationLogValueParser.java new file mode 100644 index 0000000..8027435 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/builder/parse/OperationLogValueParser.java @@ -0,0 +1,57 @@ +package cn.cordys.aspectj.builder.parse; + +import cn.cordys.aspectj.builder.MethodExecuteResult; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.context.expression.AnnotatedElementKey; +import org.springframework.expression.EvaluationContext; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 解析需要存储的日志里面的SpeEL表达式 + */ +public class OperationLogValueParser implements BeanFactoryAware { + + private static final Pattern pattern = Pattern.compile("\\{\\s*(.*?)\\s*\\}"); + private final OperationLogExpressionEvaluator expressionEvaluator = new OperationLogExpressionEvaluator(); + protected BeanFactory beanFactory; + + public Map processTemplate(Collection templates, MethodExecuteResult methodExecuteResult) { + Map expressionValues = new HashMap<>(); + EvaluationContext evaluationContext = expressionEvaluator.createEvaluationContext(methodExecuteResult.getMethod(), + methodExecuteResult.getArgs(), methodExecuteResult.getTargetClass(), methodExecuteResult.getResult(), + methodExecuteResult.getErrorMsg(), beanFactory); + + for (String expressionTemplate : templates) { + if (expressionTemplate.contains("{")) { + Matcher matcher = pattern.matcher(expressionTemplate); + StringBuilder parsedStr = new StringBuilder(); + AnnotatedElementKey annotatedElementKey = new AnnotatedElementKey(methodExecuteResult.getMethod(), methodExecuteResult.getTargetClass()); + while (matcher.find()) { + String expression = matcher.group(1); + Object value = expressionEvaluator.parseExpression(expression, annotatedElementKey, evaluationContext); + matcher.appendReplacement(parsedStr, Matcher.quoteReplacement(value == null ? StringUtils.EMPTY : value.toString())); + } + matcher.appendTail(parsedStr); + expressionValues.put(expressionTemplate, parsedStr.toString()); + } else { + expressionValues.put(expressionTemplate, expressionTemplate); + } + + } + return expressionValues; + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = beanFactory; + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/config/OperationLogConfig.java b/backend/framework/src/main/java/cn/cordys/aspectj/config/OperationLogConfig.java new file mode 100644 index 0000000..a737456 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/config/OperationLogConfig.java @@ -0,0 +1,66 @@ +package cn.cordys.aspectj.config; + +import cn.cordys.aspectj.aop.OperationLogAopAdvisor; +import cn.cordys.aspectj.aop.OperationLogSource; +import cn.cordys.aspectj.aop.OperationOperationLogInterceptor; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.*; +import org.springframework.core.type.AnnotationMetadata; + +/** + * 日志记录配置类,默认启用日志记录功能。 + * 不依赖 @EnableLogRecord 注解,直接配置日志记录相关的切面、拦截器等。 + */ +@Configuration +public class OperationLogConfig implements ImportAware { + + /** + * 创建 LogRecordOperationSource 实例,提供日志记录操作的元数据。 + */ + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public OperationLogSource logRecordOperationSource() { + return new OperationLogSource(); + } + + /** + * 创建 LogRecordAopAdvisor 实例,配置日志记录的切面和拦截器。 + * + * @param operationLogInterceptor LogRecordInterceptor 实例 + * + * @return LogRecordAopAdvisor 实例 + */ + @DependsOn("logRecordInterceptor") + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public OperationLogAopAdvisor logRecordAdvisor(OperationOperationLogInterceptor operationLogInterceptor) { + OperationLogAopAdvisor advisor = new OperationLogAopAdvisor(); + advisor.setOperationLogSource(logRecordOperationSource()); + advisor.setAdvice(operationLogInterceptor); + return advisor; + } + + /** + * 创建 LogRecordInterceptor 实例,配置日志记录的拦截器。 + * + * @return LogRecordInterceptor 实例 + */ + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public OperationOperationLogInterceptor logRecordInterceptor() { + OperationOperationLogInterceptor interceptor = new OperationOperationLogInterceptor(); + interceptor.setOperationLogSource(logRecordOperationSource()); + return interceptor; + } + + /** + * 设置导入的元数据,在此处读取日志记录相关的配置。 + * + * @param importMetadata 导入的元数据 + */ + @Override + public void setImportMetadata(AnnotationMetadata importMetadata) { + // 如果需要在导入时读取其他的配置信息,可以在这里处理 + // log.info("Log record configuration is enabled by default"); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/constants/CodeVariableType.java b/backend/framework/src/main/java/cn/cordys/aspectj/constants/CodeVariableType.java new file mode 100644 index 0000000..0c4dfa4 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/constants/CodeVariableType.java @@ -0,0 +1,6 @@ +package cn.cordys.aspectj.constants; + +public enum CodeVariableType { + ClassName, + MethodName +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/constants/GlobalSearchModule.java b/backend/framework/src/main/java/cn/cordys/aspectj/constants/GlobalSearchModule.java new file mode 100644 index 0000000..7d55b09 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/constants/GlobalSearchModule.java @@ -0,0 +1,39 @@ +package cn.cordys.aspectj.constants; + +/** + * 全局搜索 + */ +public class GlobalSearchModule { + + + /** + * 客户 + */ + public static final String CUSTOMER = "CUSTOMER"; + + /** + * 线索-线索 + */ + public static final String CLUE = "CLUE"; + /** + * 线索-线索池 + */ + public static final String CLUE_POOL = "CLUE_MANAGEMENT"; + + /** + * 客户联系人 + */ + public static final String CUSTOMER_CONTACT = "CUSTOMER_CONTACT"; + + /** + * 商机 + */ + public static final String OPPORTUNITY = "OPPORTUNITY"; + + + /** + * 客户公海 + */ + public static final String CUSTOMER_POOL = "CUSTOMER_POOL"; + +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/constants/LogConstants.java b/backend/framework/src/main/java/cn/cordys/aspectj/constants/LogConstants.java new file mode 100644 index 0000000..07023e4 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/constants/LogConstants.java @@ -0,0 +1,10 @@ +package cn.cordys.aspectj.constants; + +public class LogConstants { + public static final String SYSTEM = "SYSTEM"; + public static final String ORGANIZATION = "ORGANIZATION"; + + public static final String PRE_VALIDATION = "PRE_VALIDATION"; + public static final String POST_VALIDATION = "POST_VALIDATION"; + +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/constants/LogModule.java b/backend/framework/src/main/java/cn/cordys/aspectj/constants/LogModule.java new file mode 100644 index 0000000..b09d9f2 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/constants/LogModule.java @@ -0,0 +1,161 @@ +package cn.cordys.aspectj.constants; + +/** + * 系统日志模块常量类。 + * 用于定义不同模块的名称,便于日志记录时进行分类。 + */ +public class LogModule { + + /** + * 系统管理模块 + */ + public static final String SYSTEM = "SYSTEM"; + /** + * 消息通知 + */ + public static final String SYSTEM_MESSAGE_MESSAGE = "SYSTEM_MESSAGE_MESSAGE"; + /** + * 邮件设置 + */ + public static final String SYSTEM_BUSINESS_MAIL = "SYSTEM_BUSINESS_MAIL"; + /** + * 认证设置 + */ + public static final String SYSTEM_BUSINESS_AUTH = "SYSTEM_BUSINESS_AUTH"; + + /** + * 三方设置 + */ + public static final String SYSTEM_BUSINESS_THIRD = "SYSTEM_BUSINESS_THIRD"; + + /** + * 界面设置 + */ + public static final String SYSTEM_BUSINESS_UI = "SYSTEM_BUSINESS_UI"; + + + /** + * 用户 + */ + public static final String SYSTEM_USER = "SYSTEM_USER"; + /** + * 公告 + */ + public static final String SYSTEM_MESSAGE_ANNOUNCEMENT = "SYSTEM_MESSAGE_ANNOUNCEMENT"; + /** + * 组织架构 + */ + public static final String SYSTEM_ORGANIZATION = "SYSTEM_ORGANIZATION"; + + /** + * 角色权限 + */ + public static final String SYSTEM_ROLE = "SYSTEM_ROLE"; + + /** + * 个人信息模块(API密钥) + */ + public static final String PERSONAL_INFORMATION_APIKEY = "PERSONAL_INFORMATION_APIKEY"; + + /** + * 模块配置 + */ + public static final String SYSTEM_MODULE = "SYSTEM_MODULE"; + + /** + * 客户 + */ + public static final String CUSTOMER_INDEX = "CUSTOMER_INDEX"; + /** + * 线索-线索 + */ + public static final String CLUE_INDEX = "CLUE_MANAGEMENT_CLUE"; + /** + * 线索-线索池 + */ + public static final String CLUE_POOL_INDEX = "CLUE_MANAGEMENT_POOL"; + + /** + * 客户联系人 + */ + public static final String CUSTOMER_CONTACT = "CUSTOMER_CONTACT"; + + //TODO start 暂定跟进记录模块常量 + /** + * 跟进记录 + */ + public static final String FOLLOW_UP_RECORD = "FOLLOW_UP_RECORD"; + + /** + * 跟进计划 + */ + public static final String FOLLOW_UP_PLAN = "FOLLOW_UP_PLAN"; + /** + * 商机 + */ + public static final String OPPORTUNITY_INDEX = "OPPORTUNITY_INDEX"; + + /** + * 商机报价 + */ + public static final String OPPORTUNITY_QUOTATION = "OPPORTUNITY_QUOTATION"; + + + //todo end + + // 可以根据需要扩展其他模块常量 + + /** + * 产品 + */ + public static final String PRODUCT_MANAGEMENT = "PRODUCT_MANAGEMENT_PRO"; + + /** + * 产品价格表 + */ + public static final String PRODUCT_PRICE_MANAGEMENT = "PRODUCT_MANAGEMENT_PRICE"; + + /** + * 客户公海 + */ + public static final String CUSTOMER_POOL = "CUSTOMER_POOL"; + + /** + * 仪表板 + */ + public static final String DASHBOARD = "DASHBOARD"; + public static final String AGENT = "AGENT"; + + public static final String CONTRACT_INDEX = "CONTRACT_INDEX"; + /** + * 合同回款计划 + */ + public static final String CONTRACT_PAYMENT = "CONTRACT_PAYMENT"; + /** + * 合同回款记录 + */ + public static final String CONTRACT_PAYMENT_RECORD = "CONTRACT_PAYMENT_RECORD"; + /** + * 发票 + */ + public static final String CONTRACT_INVOICE = "CONTRACT_INVOICE"; + /** + * 工商抬头 + */ + public static final String CONTRACT_BUSINESS_TITLE = "CONTRACT_BUSINESS_TITLE"; + /** + * 订单 + */ + public static final String ORDER_INDEX = "ORDER_INDEX"; + + public static final String APPROVAL_FLOW = "SYSTEM_PROCESS_APPROVAL"; + + /** + * 自定义表单 + */ + public static final String CUSTOM_FORM = "CUSTOM_FORM_INDEX"; + /** + * 自定义表单数据 + */ + public static final String CUSTOM_FORM_DATA = "CUSTOM_FORM_DATA"; +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/constants/LogType.java b/backend/framework/src/main/java/cn/cordys/aspectj/constants/LogType.java new file mode 100644 index 0000000..b1d3db1 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/constants/LogType.java @@ -0,0 +1,118 @@ +package cn.cordys.aspectj.constants; + +/** + * 操作日志类型枚举类。 + * 用于定义不同操作的日志类型,便于日志分类和处理。 + */ +public final class LogType { + public static final String SELECT = "SELECT"; + /** + * 添加操作 + */ + public static final String ADD = "ADD"; + /** + * 删除操作 + */ + public static final String DELETE = "DELETE"; + /** + * 更新操作 + */ + public static final String UPDATE = "UPDATE"; + /** + * 移入公海 + */ + public static final String MOVE_TO_CUSTOMER_POOL = "MOVE_TO_CUSTOMER_POOL"; + /** + * 审核操作 + */ + public static final String REVIEW = "REVIEW"; + /** + * 登出操作 + */ + public static final String LOGOUT = "LOGOUT"; + /** + * 登录操作 + */ + public static final String LOGIN = "LOGIN"; + /** + * 复制操作 + */ + public static final String COPY = "COPY"; + + /** + * 同步 + */ + public static final String SYNC = "SYNC"; + /** + * 领取 + */ + public static final String PICK = "PICK"; + /** + * 分配 + */ + public static final String ASSIGN = "ASSIGN"; + /** + * 取消 + */ + public static final String CANCEL = "CANCEL"; + /** + * 添加用户 + */ + public static final String ADD_USER = "ADD_USER"; + /** + * 移除用户 + */ + public static final String REMOVE_USER = "REMOVE_USER"; + /** + * 导出 + */ + public static final String EXPORT = "EXPORT"; + /** + * 合并 + */ + public static final String MERGE = "MERGE"; + + /** + * 审核 + */ + public static final String APPROVAL = "APPROVAL"; + + /** + * 作废 + */ + public static final String VOIDED = "VOIDED"; + + /** + * 归档 + */ + public static final String ARCHIVE = "ARCHIVE"; + /** + * 取消归档 + */ + public static final String UNARCHIVE = "UNARCHIVE"; + + + /** + * 下载 + */ + public static final String DOWNLOAD = "DOWNLOAD"; + + private LogType() { + // 私有构造函数防止实例化 + } + + /** + * 判断给定的日志类型是否包含关键字。 + * + * @param logType 当前日志类型 + * @param keyword 待匹配的日志类型关键字 + * + * @return 如果日志类型包含关键字,则返回 true,否则返回 false + */ + public static boolean contains(String logType, String keyword) { + if (logType == null || keyword == null) { + return false; + } + return logType.contains(keyword); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/constants/RequestSource.java b/backend/framework/src/main/java/cn/cordys/aspectj/constants/RequestSource.java new file mode 100644 index 0000000..98ce9b2 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/constants/RequestSource.java @@ -0,0 +1,5 @@ +package cn.cordys.aspectj.constants; + +public enum RequestSource { + API, WEB, SKILL, MCP +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/context/OperationLogContext.java b/backend/framework/src/main/java/cn/cordys/aspectj/context/OperationLogContext.java new file mode 100644 index 0000000..b9d9019 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/context/OperationLogContext.java @@ -0,0 +1,177 @@ +package cn.cordys.aspectj.context; + +import cn.cordys.aspectj.dto.LogContextInfo; + +import java.util.*; + +/** + * 用于记录日志上下文变量的工具类,支持方法级别和全局变量的管理。 + *

+ * 本类使用了 {@link InheritableThreadLocal},以确保子线程能够继承父线程的变量。 + *

+ */ +public class OperationLogContext { + + public static final String OPERATION_LOG_CONTEXT_KEY = "OPERATION_LOG_CONTEXT_KEY"; + + /** + * 存储方法级别变量的栈,每个方法调用对应一个栈帧。 + */ + private static final InheritableThreadLocal>> VARIABLE_MAP_STACK = new InheritableThreadLocal<>(); + + /** + * 存储全局变量的映射。 + */ + private static final InheritableThreadLocal> GLOBAL_VARIABLE_MAP = new InheritableThreadLocal<>(); + + // 防止实例化工具类 + private OperationLogContext() { + throw new IllegalStateException("Utility class"); + } + + /** + * 向当前方法栈中放入变量。 + * + * @param name 变量名 + * @param value 变量值 + */ + public static void putVariable(String name, Object value) { + // 初始化栈 + Deque> mapStack = Optional.ofNullable(VARIABLE_MAP_STACK.get()) + .orElseGet(() -> { + Deque> stack = new ArrayDeque<>(); + VARIABLE_MAP_STACK.set(stack); + return stack; + }); + + if (mapStack.isEmpty()) { + mapStack.push(new HashMap<>()); + } + Objects.requireNonNull(mapStack.peek()).put(name, value); + } + + /** + * 设置额外信息 + */ + public static LogContextInfo getContext() { + return (LogContextInfo) getVariable(OPERATION_LOG_CONTEXT_KEY); + } + + /** + * 设置额外信息 + */ + public static void setContext(LogContextInfo logContextInfo) { + putVariable(OPERATION_LOG_CONTEXT_KEY, logContextInfo); + } + + /** + * 设置资源名称 + * + * @param resourceName + */ + public static void setResourceName(String resourceName) { + setContext( + LogContextInfo.builder() + .resourceName(resourceName) + .build() + ); + } + + /** + * 获取当前方法栈中指定的变量值。 + * + * @param key 变量名 + * + * @return 变量值,如果找不到则返回 null + */ + public static Object getVariable(String key) { + Map variableMap = Optional.ofNullable(VARIABLE_MAP_STACK.get()) + .map(Deque::peek) + .orElse(null); + return variableMap == null ? null : variableMap.get(key); + } + + /** + * 获取当前方法栈中或全局变量映射中指定的变量值。 + *

+ * 若在方法栈中找不到变量,则会尝试从全局变量映射中获取。 + *

+ * + * @param key 变量名 + * + * @return 变量值,如果找不到则返回 null + */ + public static Object getMethodOrGlobal(String key) { + // 优先从方法栈中获取 + Object result = Optional.ofNullable(VARIABLE_MAP_STACK.get()) + .map(stack -> { + assert stack.peek() != null; + return stack.peek().get(key); + }) + .orElse(null); + + if (result == null) { + // 若方法栈中没有,则从全局变量中获取 + result = Optional.ofNullable(GLOBAL_VARIABLE_MAP.get()) + .map(map -> map.get(key)) + .orElse(null); + } + + return result; + } + + /** + * 获取当前方法栈中的所有变量。 + * + * @return 当前方法栈中的变量映射,若栈为空,则返回一个空的 HashMap + */ + public static Map getVariables() { + return Optional.ofNullable(VARIABLE_MAP_STACK.get()) + .map(Deque::peek) + .orElse(new HashMap<>()); + } + + /** + * 获取全局变量映射。 + * + * @return 全局变量映射,若未初始化,则返回一个空的 HashMap + */ + public static Map getGlobalVariableMap() { + return Optional.ofNullable(GLOBAL_VARIABLE_MAP.get()) + .orElse(new HashMap<>()); + } + + /** + * 清除当前方法栈中的变量。 + */ + public static void clear() { + Optional.ofNullable(VARIABLE_MAP_STACK.get()).ifPresent(Deque::pop); + } + + /** + * 清除全局变量映射中的所有变量。 + */ + public static void clearGlobal() { + Optional.ofNullable(GLOBAL_VARIABLE_MAP.get()).ifPresent(Map::clear); + } + + /** + * 在进入方法时初始化一个空的 Span 放入栈中,方法执行完后会弹出。 + *

+ * 该方法通常用于日志追踪中的 Span 管理。 + *

+ */ + public static void putEmptySpan() { + Deque> mapStack = Optional.ofNullable(VARIABLE_MAP_STACK.get()) + .orElseGet(() -> { + Deque> stack = new ArrayDeque<>(); + VARIABLE_MAP_STACK.set(stack); + return stack; + }); + mapStack.push(new HashMap<>()); + + if (GLOBAL_VARIABLE_MAP.get() == null) { + GLOBAL_VARIABLE_MAP.set(new HashMap<>()); + } + } +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/dto/LogContextInfo.java b/backend/framework/src/main/java/cn/cordys/aspectj/dto/LogContextInfo.java new file mode 100644 index 0000000..f9c5b1b --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/dto/LogContextInfo.java @@ -0,0 +1,45 @@ +package cn.cordys.aspectj.dto; + +import cn.cordys.common.util.JSON; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; + +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LogContextInfo implements java.io.Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 原始值 + */ + private Object originalValue; + + /** + * 修改后的值 + */ + private Object modifiedValue; + + /** + * 资源的id + * 优先级高于注解 + */ + private String resourceId; + + /** + * 资源的名称 + * 优先级高于注解 + */ + private String resourceName; + + @Override + public String toString() { + return JSON.toJSONString(this); + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/dto/LogDTO.java b/backend/framework/src/main/java/cn/cordys/aspectj/dto/LogDTO.java new file mode 100644 index 0000000..3d421ce --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/dto/LogDTO.java @@ -0,0 +1,107 @@ +package cn.cordys.aspectj.dto; + +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 日志数据传输对象(DTO),继承自操作日志(OperationLog)。 + * 用于封装操作日志的具体数据,包含变更前后内容、是否需要历史记录等信息。 + */ +@Data +@EqualsAndHashCode(callSuper = false) +public class LogDTO { + @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{operation_log.id.not_blank}", groups = {Updated.class}) + @Size(min = 1, max = 50, message = "{operation_log.id.length_range}", groups = {Created.class, Updated.class}) + private String id; + + @Schema(description = "组织id", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{operation_log.organization_id.not_blank}", groups = {Created.class}) + @Size(min = 1, max = 50, message = "{operation_log.organization_id.length_range}", groups = {Created.class, Updated.class}) + private String organizationId; + + @Schema(description = "操作时间") + private Long createTime; + + @Schema(description = "操作人") + private String createUser; + + @Schema(description = "资源id") + private String resourceId; + + @Schema(description = "资源名称") + private String resourceName; + + /** + * 无需对比的操作日志详情 + * 例如可以移入公海 + * 详情记录为:客户 xxx 移入公海 xxx + */ + @Schema(description = "日志详情") + private String detail; + + @Schema(description = "操作方法", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{operation_log.method.not_blank}", groups = {Created.class}) + @Size(min = 1, max = 255, message = "{operation_log.method.length_range}", groups = {Created.class, Updated.class}) + private String method; + + @Schema(description = "操作类型/add/update/delete", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{operation_log.type.not_blank}", groups = {Created.class}) + @Size(min = 1, max = 20, message = "{operation_log.type.length_range}", groups = {Created.class, Updated.class}) + private String type; + + @Schema(description = "操作模块") + private String module; + + @Schema(description = "操作路径") + private String path; + + @Schema(description = "登录地") + private String loginAddress; + + @Schema(description = "平台") + private String platform; + + @Schema(description = "请求来源") + private String requestSource; + + /** + * 原始值 + */ + private Object originalValue; + + /** + * 修改后的值 + */ + private Object modifiedValue; + + /** + * 默认构造函数 + */ + public LogDTO() { + } + + /** + * 带参构造函数,用于快速初始化日志数据 + * + * @param organizationId 组织ID + * @param resourceId 数据源ID + * @param createUser 创建用户 + * @param type 日志类型 + * @param module 模块 + */ + public LogDTO(String organizationId, String resourceId, String createUser, String type, String module, String resourceName) { + this.setOrganizationId(organizationId); + this.setResourceId(resourceId); + this.setCreateUser(createUser); + this.setType(type); + this.setModule(module); + this.setResourceName(resourceName); + this.setCreateTime(System.currentTimeMillis()); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/handler/OperationLogHandler.java b/backend/framework/src/main/java/cn/cordys/aspectj/handler/OperationLogHandler.java new file mode 100644 index 0000000..3ea08aa --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/handler/OperationLogHandler.java @@ -0,0 +1,13 @@ +package cn.cordys.aspectj.handler; + +import cn.cordys.aspectj.dto.LogDTO; + +public interface OperationLogHandler { + /** + * 处理日志 + * + * @param operationLog 操作日志 + */ + void handleLog(LogDTO operationLog); + +} diff --git a/backend/framework/src/main/java/cn/cordys/aspectj/handler/OperationLogService.java b/backend/framework/src/main/java/cn/cordys/aspectj/handler/OperationLogService.java new file mode 100644 index 0000000..53f544a --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/aspectj/handler/OperationLogService.java @@ -0,0 +1,83 @@ +package cn.cordys.aspectj.handler; + +import cn.cordys.aspectj.builder.OperationLog; +import cn.cordys.aspectj.constants.RequestSource; +import cn.cordys.aspectj.dto.LogDTO; +import cn.cordys.common.util.ServletUtils; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +/** + * 操作日志 ILogRecordService 实现类 + *

+ * 基于 {@link OperationLogHandler} 实现,记录操作日志 + */ +@Service +public class OperationLogService { + + @Resource + private OperationLogHandler operationLogHandler; + public static final String ACCESS_KEY_HEADER = "X-Access-Key"; + public static final String SECRET_KEY_HEADER = "X-Secret-Key"; + public static final String REQUEST_SOURCE_HEADER = "X-Request-Source"; + + public static void fillModuleFields(LogDTO reqDTO, OperationLog operationLog) { + reqDTO.setCreateTime(System.currentTimeMillis()); + reqDTO.setType(operationLog.getType()); // 大模块类型,例如:CRM 客户 + reqDTO.setCreateUser(operationLog.getOperator()); + reqDTO.setModule(operationLog.getSubType());// 操作类型:CURD + reqDTO.setResourceId(operationLog.getResourceId()); // 资源id + reqDTO.setResourceName(operationLog.getResourceName()); // 资源名称 + reqDTO.setDetail(operationLog.getDetail()); // 资源名称 + } + + private static void fillRequestFields(LogDTO reqDTO) { + // 获得 Request 对象 + HttpServletRequest request = ServletUtils.getRequest(); + if (request == null) { + return; + } + + String accessKey = request.getHeader(ACCESS_KEY_HEADER); + String secretKey = request.getHeader(SECRET_KEY_HEADER); + String requestSource = request.getHeader(REQUEST_SOURCE_HEADER); + reqDTO.setRequestSource(resolveRequestSource(requestSource, accessKey, secretKey)); + + // 补全请求信息 + reqDTO.setMethod(request.getMethod()); + reqDTO.setPath(request.getRequestURI()); + } + + public void record(OperationLog operationLog) { + // 1. 补全通用字段 + LogDTO reqDTO = new LogDTO(); + // 补全模块信息 + fillModuleFields(reqDTO, operationLog); + // 补全请求信息 + fillRequestFields(reqDTO); + + // todo: 组织或项目信息 + + // 2. 异步记录日志 + assert operationLogHandler != null; + operationLogHandler.handleLog(reqDTO); + } + + /** + * 根据请求头解析请求来源,优先级: + * 1. 明确指定的 X-Request-Source + * 2. 携带鉴权密钥对 (Access/Secret) 时视为 API 调用 + * 3. 否则默认为 WEB + */ + private static String resolveRequestSource(String requestSource, String accessKey, String secretKey) { + if (StringUtils.isNotBlank(requestSource)) { + return requestSource; + } + if (StringUtils.isNotBlank(accessKey) && StringUtils.isNotBlank(secretKey)) { + return RequestSource.API.name(); + } + return RequestSource.WEB.name(); + } +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/common/constants/ApplicationNumScope.java b/backend/framework/src/main/java/cn/cordys/common/constants/ApplicationNumScope.java new file mode 100644 index 0000000..041e8b8 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/constants/ApplicationNumScope.java @@ -0,0 +1,6 @@ +package cn.cordys.common.constants; + +public enum ApplicationNumScope { + SYSTEM, + TASK +} diff --git a/backend/framework/src/main/java/cn/cordys/common/constants/EnumValue.java b/backend/framework/src/main/java/cn/cordys/common/constants/EnumValue.java new file mode 100644 index 0000000..dff622f --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/constants/EnumValue.java @@ -0,0 +1,121 @@ +package cn.cordys.common.constants; + +import cn.cordys.common.util.Translator; +import jakarta.validation.Constraint; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import jakarta.validation.Payload; +import org.apache.commons.collections4.CollectionUtils; + +import java.lang.annotation.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * 枚举值校验注解,确保值为指定枚举类中的有效值。 + * 可选排除某些枚举值。 + * + * @author jianxing + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Constraint(validatedBy = EnumValue.EnumValueValidator.class) +public @interface EnumValue { + + /** + * 错误提示信息,当校验失败时使用。 + * + * @return 错误提示消息 + */ + String message() default "{enum_value_valid_message}"; + + /** + * 必须的属性,用于分组校验。 + * + * @return 分组校验的类 + */ + Class[] groups() default {}; + + /** + * 校验负载。 + * + * @return 校验负载的类 + */ + Class[] payload() default {}; + + /** + * 校验时使用的枚举类。 + * + * @return 枚举类 + */ + Class> enumClass(); + + /** + * 校验时排除的枚举值,仅支持字符串类型。 + * + * @return 排除的枚举值 + */ + String[] excludeValues() default {}; + + /** + * 枚举值校验的实现类。 + * + * @see EnumValue + */ + class EnumValueValidator implements ConstraintValidator { + + private Class> enumClass; + private String[] excludeValues; + + @Override + public void initialize(EnumValue enumValue) { + this.enumClass = enumValue.enumClass(); + this.excludeValues = enumValue.excludeValues(); + } + + /** + * 校验参数是否在枚举值中。 + * 如果设置了排除值,校验值不应在排除列表中。 + * + * @param value 待校验的值 + * @param context 校验上下文 + * + * @return 校验结果,若值有效返回 true,否则返回 false + */ + @Override + public boolean isValid(Object value, ConstraintValidatorContext context) { + // 如果值为空,则认为有效 + if (value == null) { + return true; + } + + // 获取枚举类的所有实例 + Enum[] enums = enumClass.getEnumConstants(); + List values = new ArrayList<>(); + + // 获取枚举类的所有有效值 + for (Enum item : enums) { + if (item instanceof ValueEnum) { + values.add(((ValueEnum) item).getValue()); + } else { + values.add(item.name()); + } + } + + // 判断是否排除指定的枚举值 + boolean isExcludeValue = excludeValues != null && Arrays.stream(excludeValues).anyMatch(value::equals); + boolean valid = values.contains(value) && !isExcludeValue; + + // 如果校验失败,生成自定义错误消息 + if (!valid) { + context.disableDefaultConstraintViolation(); + String errorValues = CollectionUtils.subtract(values, Arrays.asList(excludeValues)).toString(); + context.buildConstraintViolationWithTemplate(Translator.get("enum_value_valid_message") + errorValues).addConstraintViolation(); + } + + return valid; + } + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/constants/InternalUser.java b/backend/framework/src/main/java/cn/cordys/common/constants/InternalUser.java new file mode 100644 index 0000000..1de9f53 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/constants/InternalUser.java @@ -0,0 +1,20 @@ +package cn.cordys.common.constants; + +import lombok.Getter; + +/** + * 系统内置用户ID + * + * @author jianxing + */ +@Getter +public enum InternalUser { + ADMIN("admin"); + + private final String value; + + InternalUser(String value) { + this.value = value; + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/common/constants/MoveTypeEnum.java b/backend/framework/src/main/java/cn/cordys/common/constants/MoveTypeEnum.java new file mode 100644 index 0000000..268cb53 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/constants/MoveTypeEnum.java @@ -0,0 +1,11 @@ +package cn.cordys.common.constants; + +/** + * @author guoquqi + */ + +public enum MoveTypeEnum { + BEFORE, + AFTER, + APPEND +} diff --git a/backend/framework/src/main/java/cn/cordys/common/constants/QuadFunction.java b/backend/framework/src/main/java/cn/cordys/common/constants/QuadFunction.java new file mode 100644 index 0000000..6de9371 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/constants/QuadFunction.java @@ -0,0 +1,6 @@ +package cn.cordys.common.constants; + +@FunctionalInterface +public interface QuadFunction { + R apply(T t, U u, V v, W w); +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/common/constants/ValueEnum.java b/backend/framework/src/main/java/cn/cordys/common/constants/ValueEnum.java new file mode 100644 index 0000000..a629cc3 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/constants/ValueEnum.java @@ -0,0 +1,17 @@ +package cn.cordys.common.constants; + +/** + * 用于参数校验注解 EnumValue 的枚举接口 + * 如果枚举定义了类似 value 的值,可以实现改接口,即可使用于 EnumValue 注解 + * 如果枚举值只需要通过 name() 获取,可以不实现该接口 + * + * @author jianxing + */ +public interface ValueEnum { + /** + * 获取枚举值 + * + * @return 枚举值 + */ + T getValue(); +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/common/dto/BaseTree.java b/backend/framework/src/main/java/cn/cordys/common/dto/BaseTree.java new file mode 100644 index 0000000..da7b138 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/dto/BaseTree.java @@ -0,0 +1,27 @@ +package cn.cordys.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BaseTree { + @Schema(description = "节点id") + private String id; + + @Schema(description = "节点名称") + private String name; + + @Schema(description = "排序单位") + private long pos; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "父节点id") + private String parentId; + +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/common/dto/BaseTreeNode.java b/backend/framework/src/main/java/cn/cordys/common/dto/BaseTreeNode.java new file mode 100644 index 0000000..b286193 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/dto/BaseTreeNode.java @@ -0,0 +1,81 @@ +package cn.cordys.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jodd.util.StringUtil; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.apache.commons.lang3.Strings; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class BaseTreeNode { + + @Schema(description = "节点ID") + private String id; + + @Schema(description = "节点名称") + private String name; + + @Schema(description = "父节点ID") + private String parentId; + + @Schema(description = "组织id") + private String organizationId; + + @Schema(description = "子节点") + private List children = new ArrayList<>(); + + public BaseTreeNode(String id, String name) { + this.id = id; + this.name = name; + } + + public BaseTreeNode(String id, String name, String parentId) { + this.id = id; + this.name = name; + this.parentId = parentId; + } + + public static List buildTree(List nodeList) { + // 用于存储节点的 Map,key 是节点 ID + Map nodeMap = new HashMap<>(); + // 用于存储最终的根节点列表 + List rootNodes = new ArrayList<>(); + + // 1. 将所有节点放入 Map 中 + for (T node : nodeList) { + nodeMap.put(node.getId(), node); + } + + // 2. 遍历节点列表,构建父子关系 + for (T node : nodeList) { + if (StringUtil.isBlank(node.getParentId()) || Strings.CI.equals(node.getParentId(), "NONE")) { + // 没有父节点,则为根节点 + rootNodes.add(node); + } else { + // 获取父节点 + T parentNode = nodeMap.get(node.getParentId()); + if (parentNode != null) { + // 将当前节点添加到父节点的子节点列表中 + parentNode.addChild(node); + } + } + } + + // 3. 返回根节点列表 + return rootNodes; + } + + public void addChild(BaseTreeNode node) { + node.setParentId(this.getId()); + children.add(node); + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/common/dto/DeptUserTreeNode.java b/backend/framework/src/main/java/cn/cordys/common/dto/DeptUserTreeNode.java new file mode 100644 index 0000000..e40c4d3 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/dto/DeptUserTreeNode.java @@ -0,0 +1,23 @@ +package cn.cordys.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @author jianxing + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class DeptUserTreeNode extends BaseTreeNode { + @Schema(description = "节点类型") + private String nodeType; + + @Schema(description = "是否启用") + private Boolean enabled = true; + + @Schema(description = "是否部门负责人") + private Boolean commander = false; +} diff --git a/backend/framework/src/main/java/cn/cordys/common/dto/JsonDifferenceDTO.java b/backend/framework/src/main/java/cn/cordys/common/dto/JsonDifferenceDTO.java new file mode 100644 index 0000000..1d8201d --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/dto/JsonDifferenceDTO.java @@ -0,0 +1,36 @@ +package cn.cordys.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +@Data +public class JsonDifferenceDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "字段") + private String column; + + @Schema(description = "原值") + private Object oldValue; + + @Schema(description = "新值") + private Object newValue; + + + @Schema(description = "字段名称") + private String columnName; + + @Schema(description = "原值结果") + private Object oldValueName; + + @Schema(description = "新值结果") + private Object newValueName; + + @Schema(description = "类型", examples = {"add/新增", "removed/删除", "modified/修改"}) + private String type; + +} diff --git a/backend/framework/src/main/java/cn/cordys/common/dto/NodeSortCountResultDTO.java b/backend/framework/src/main/java/cn/cordys/common/dto/NodeSortCountResultDTO.java new file mode 100644 index 0000000..a560c4c --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/dto/NodeSortCountResultDTO.java @@ -0,0 +1,11 @@ +package cn.cordys.common.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class NodeSortCountResultDTO { + private boolean isRefreshPos; + private long pos; +} diff --git a/backend/framework/src/main/java/cn/cordys/common/dto/NodeSortDTO.java b/backend/framework/src/main/java/cn/cordys/common/dto/NodeSortDTO.java new file mode 100644 index 0000000..c47558d --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/dto/NodeSortDTO.java @@ -0,0 +1,28 @@ +package cn.cordys.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class NodeSortDTO { + + @Schema(description = "节点ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "{file_module.not.exist}") + private BaseTree node; + + @Schema(description = "父节点ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "{file_module.parent.not.exist}") + private BaseTree parent; + + @Schema(description = "前一个节点") + private BaseTree previousNode; + + @Schema(description = "后一个节点") + private BaseTree nextNode; +} + diff --git a/backend/framework/src/main/java/cn/cordys/common/dto/NodeSortQueryParam.java b/backend/framework/src/main/java/cn/cordys/common/dto/NodeSortQueryParam.java new file mode 100644 index 0000000..5566820 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/dto/NodeSortQueryParam.java @@ -0,0 +1,15 @@ +package cn.cordys.common.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class NodeSortQueryParam { + private String parentId; + private String operator; + private long pos; +} + diff --git a/backend/framework/src/main/java/cn/cordys/common/dto/OptionCountDTO.java b/backend/framework/src/main/java/cn/cordys/common/dto/OptionCountDTO.java new file mode 100644 index 0000000..a9d183d --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/dto/OptionCountDTO.java @@ -0,0 +1,18 @@ +package cn.cordys.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class OptionCountDTO implements Serializable { + @Schema(description = "选项数量") + private String key; + @Schema(description = "选项值") + private Integer count; +} diff --git a/backend/framework/src/main/java/cn/cordys/common/dto/OptionDTO.java b/backend/framework/src/main/java/cn/cordys/common/dto/OptionDTO.java new file mode 100644 index 0000000..e405bba --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/dto/OptionDTO.java @@ -0,0 +1,18 @@ +package cn.cordys.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class OptionDTO implements Serializable { + @Schema(description = "选项ID") + private String id; + @Schema(description = "选项名称") + private String name; +} diff --git a/backend/framework/src/main/java/cn/cordys/common/dto/RoleDataScopeDTO.java b/backend/framework/src/main/java/cn/cordys/common/dto/RoleDataScopeDTO.java new file mode 100644 index 0000000..438620e --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/dto/RoleDataScopeDTO.java @@ -0,0 +1,29 @@ +package cn.cordys.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * @author jianxing + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RoleDataScopeDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "选项ID") + private String id; + @Schema(description = "选项名称") + private String name; + @Schema(description = "数据权限") + private String dataScope; + @Schema(description = "组织ID") + private String organizationId; +} diff --git a/backend/framework/src/main/java/cn/cordys/common/dto/RolePermissionDTO.java b/backend/framework/src/main/java/cn/cordys/common/dto/RolePermissionDTO.java new file mode 100644 index 0000000..6755334 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/dto/RolePermissionDTO.java @@ -0,0 +1,24 @@ +package cn.cordys.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Set; + +/** + * @author jianxing + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RolePermissionDTO extends RoleDataScopeDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "权限列表") + private Set permissions; +} diff --git a/backend/framework/src/main/java/cn/cordys/common/dto/RoleUserTreeNode.java b/backend/framework/src/main/java/cn/cordys/common/dto/RoleUserTreeNode.java new file mode 100644 index 0000000..bdb9f39 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/dto/RoleUserTreeNode.java @@ -0,0 +1,23 @@ +package cn.cordys.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @author jianxing + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class RoleUserTreeNode extends BaseTreeNode { + @Schema(description = "节点类型") + private String nodeType; + + @Schema(description = "是否是内置角色") + private Boolean internal = false; + + @Schema(description = "是否启用") + private Boolean enabled = true; +} diff --git a/backend/framework/src/main/java/cn/cordys/common/dto/request/PosRequest.java b/backend/framework/src/main/java/cn/cordys/common/dto/request/PosRequest.java new file mode 100644 index 0000000..267e0fe --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/dto/request/PosRequest.java @@ -0,0 +1,40 @@ +package cn.cordys.common.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * @author guoyuqi + */ +@Data +@EqualsAndHashCode(callSuper = false) +@NoArgsConstructor +@AllArgsConstructor +public class PosRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "组织id", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{system.org_id.not_blank}") + private String orgId; + + @Schema(description = "移动用例id", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{system.move_id.not_blank}") + private String moveId; + + @Schema(description = "目标用例id", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{system.target_id.not_blank}") + private String targetId; + + @Schema(description = "移动类型", requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"BEFORE", "AFTER", "APPEND"}) + @NotBlank(message = "{system.move_mode.not_blank}") + private String moveMode; +} diff --git a/backend/framework/src/main/java/cn/cordys/common/exception/GenericException.java b/backend/framework/src/main/java/cn/cordys/common/exception/GenericException.java new file mode 100644 index 0000000..fd7147f --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/exception/GenericException.java @@ -0,0 +1,102 @@ +package cn.cordys.common.exception; + +import org.apache.commons.lang3.StringUtils; + +/** + * SystemException 是自定义的运行时异常,包含错误代码和详细信息。 + */ +public class GenericException extends RuntimeException { + + /** + * 错误代码 + */ + protected IResultCode errorCode; + + /** + * 构造方法,接受错误信息。 + * + * @param message 错误信息 + */ + public GenericException(String message) { + super(message); + } + + /** + * 构造方法,接受一个异常对象。 + * + * @param t 异常对象 + */ + public GenericException(Throwable t) { + super(t); + } + + /** + * 构造方法,接受错误代码,默认没有详细信息。 + * + * @param errorCode 错误代码 + */ + public GenericException(IResultCode errorCode) { + super(StringUtils.EMPTY); + if (errorCode == null) { + throw new IllegalArgumentException("errorCode cannot be null"); + } + this.errorCode = errorCode; + } + + /** + * 构造方法,接受错误代码和自定义错误信息。 + * + * @param errorCode 错误代码 + * @param message 错误信息 + */ + public GenericException(IResultCode errorCode, String message) { + super(message); + if (errorCode == null) { + throw new IllegalArgumentException("errorCode cannot be null"); + } + this.errorCode = errorCode; + } + + /** + * 构造方法,接受错误代码和异常对象。 + * + * @param errorCode 错误代码 + * @param t 异常对象 + */ + public GenericException(IResultCode errorCode, Throwable t) { + super(t); + if (errorCode == null) { + throw new IllegalArgumentException("errorCode cannot be null"); + } + this.errorCode = errorCode; + } + + /** + * 构造方法,接受自定义错误信息和异常对象。 + * + * @param message 错误信息 + * @param t 异常对象 + */ + public GenericException(String message, Throwable t) { + super(message, t); + } + + /** + * 获取错误代码。 + * + * @return 错误代码 + */ + public IResultCode getErrorCode() { + return errorCode; + } + + /** + * 重写toString方法,提供更有用的错误信息。 + * + * @return 错误代码和错误信息 + */ + @Override + public String toString() { + return "MSException{errorCode=" + errorCode + ", message=" + getMessage() + "}"; + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/exception/IResultCode.java b/backend/framework/src/main/java/cn/cordys/common/exception/IResultCode.java new file mode 100644 index 0000000..63785d5 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/exception/IResultCode.java @@ -0,0 +1,34 @@ +package cn.cordys.common.exception; + +import cn.cordys.common.util.Translator; + +/** + * API 接口状态码 + * + * @author jianxing + *

+ * 1. 如果想返回具有 Http 含义的状态码,使用对应实现类 + * 2. 业务状态码,各模块定义自己的状态码枚举类,各自管理 + * 3. 业务错误码,定义规则为 6 位数字 + * 4. 当需要抛出异常时,给异常设置状态码枚举对象 + *

+ */ +public interface IResultCode { + /** + * 返回状态码 + */ + int getCode(); + + /** + * 返回状态码信息 + */ + String getMessage(); + + /** + * 返回国际化后的状态码信息 + * 如果没有匹配则返回原文 + */ + default String getTranslationMessage(String message) { + return Translator.get(message, message); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/groups/Created.java b/backend/framework/src/main/java/cn/cordys/common/groups/Created.java new file mode 100644 index 0000000..5945d29 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/groups/Created.java @@ -0,0 +1,11 @@ +package cn.cordys.common.groups; + +/** + * 标记接口,表示某个实体或操作是“已创建”的状态。 + *

+ * 该接口可用于分组校验,例如,在某些验证框架中, + * 用于标识新创建的对象,或者在特定条件下执行相关操作。 + *

+ */ +public interface Created { +} diff --git a/backend/framework/src/main/java/cn/cordys/common/groups/Updated.java b/backend/framework/src/main/java/cn/cordys/common/groups/Updated.java new file mode 100644 index 0000000..c5b3730 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/groups/Updated.java @@ -0,0 +1,11 @@ +package cn.cordys.common.groups; + +/** + * 标记接口,表示某个实体或操作是“已更新”的状态。 + *

+ * 该接口可用于分组校验,例如,在某些验证框架中, + * 用于标识已更新的对象,或者在特定条件下执行相关操作。 + *

+ */ +public interface Updated { +} diff --git a/backend/framework/src/main/java/cn/cordys/common/pager/PageUtils.java b/backend/framework/src/main/java/cn/cordys/common/pager/PageUtils.java new file mode 100644 index 0000000..c0dd5d2 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/pager/PageUtils.java @@ -0,0 +1,67 @@ +package cn.cordys.common.pager; + +import cn.cordys.common.dto.OptionDTO; +import com.github.pagehelper.Page; + +import java.util.List; +import java.util.Map; + +/** + * 分页工具类,提供分页信息设置功能。 + *

+ * 该类用于将 PageHelper 分页对象转换为自定义的分页对象 {@link Pager}。 + *

+ */ +public class PageUtils { + + /** + * 设置分页信息并返回自定义的分页对象。 + *

+ * 此方法将 PageHelper 的分页数据(如:当前页、每页记录数、总记录数)转换为自定义的 {@link Pager} 对象。 + *

+ * + * @param page PageHelper 分页对象,包含分页相关的信息 + * @param list 分页查询结果数据列表 + * @param 数据列表的类型 + * + * @return 包含分页信息的自定义分页对象 {@link Pager} + * + * @throws RuntimeException 如果设置分页信息时发生错误,抛出运行时异常 + */ + public static Pager setPageInfo(Page page, T list) { + try { + Pager pager = new Pager<>(); + pager.setList(list); + pager.setPageSize(page.getPageSize()); + pager.setCurrent(page.getPageNum()); + pager.setTotal(page.getTotal()); + return pager; + } catch (Exception e) { + throw new RuntimeException("保存当前页码数据时发生错误!", e); + } + } + + /** + * 设置带有选项数据的分页信息 + * + * @param page 分页对象 + * @param list 数据列表 + * @param 数据列表的类型 + * @param optionMap 选项集合 + * + * @return 包含分页信息的自定义分页对象 + */ + public static PagerWithOption setPageInfoWithOption(Page page, T list, Map> optionMap) { + try { + PagerWithOption pager = new PagerWithOption<>(); + pager.setList(list); + pager.setPageSize(page.getPageSize()); + pager.setCurrent(page.getPageNum()); + pager.setTotal(page.getTotal()); + pager.setOptionMap(optionMap); + return pager; + } catch (Exception e) { + throw new RuntimeException("保存当前页码数据时发生错误!", e); + } + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/pager/Pager.java b/backend/framework/src/main/java/cn/cordys/common/pager/Pager.java new file mode 100644 index 0000000..882b8a3 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/pager/Pager.java @@ -0,0 +1,56 @@ +package cn.cordys.common.pager; + +import lombok.Data; + +/** + * 分页类,封装分页数据。 + *

+ * 该类用于表示分页查询结果,包括数据列表、总记录数、每页记录数和当前页码等信息。 + *

+ * + * @param 数据列表的类型 + */ +@Data +public class Pager { + + /** + * 数据列表,分页查询结果的具体数据。 + */ + private T list; + + /** + * 总记录数,表示符合查询条件的总条数。 + */ + private long total; + + /** + * 每页记录数,表示每一页显示的数据条数。 + */ + private long pageSize; + + /** + * 当前页码,表示当前显示的是哪一页。 + */ + private long current; + + /** + * 无参构造函数,初始化一个空的分页对象。 + */ + public Pager() { + } + + /** + * 带参构造函数,用于初始化分页对象。 + * + * @param list 数据列表 + * @param total 总记录数 + * @param pageSize 每页记录数 + * @param current 当前页码 + */ + public Pager(T list, long total, long pageSize, long current) { + this.list = list; + this.total = total; + this.pageSize = pageSize; + this.current = current; + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/pager/PagerWithOption.java b/backend/framework/src/main/java/cn/cordys/common/pager/PagerWithOption.java new file mode 100644 index 0000000..001bff2 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/pager/PagerWithOption.java @@ -0,0 +1,18 @@ +package cn.cordys.common.pager; + +import cn.cordys.common.dto.OptionDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class PagerWithOption extends Pager { + + /** + * 选项集合 + */ + @Schema(description = "选项集合") + private Map> optionMap; +} diff --git a/backend/framework/src/main/java/cn/cordys/common/response/handler/NoResultHolder.java b/backend/framework/src/main/java/cn/cordys/common/response/handler/NoResultHolder.java new file mode 100644 index 0000000..2c19bff --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/response/handler/NoResultHolder.java @@ -0,0 +1,22 @@ +package cn.cordys.common.response.handler; + +import java.lang.annotation.*; + +/** + * 标记一个方法为“不需要返回结果”方法的注解。 + *

+ * 该注解用于标记那些不需要返回任何结果或对返回结果不做处理的方法。 + * 常用于控制器方法中,表明此方法调用后不需要返回任何数据给客户端。 + *

+ *

+ * 使用此注解的方法,可以避免框架或处理机制进行不必要的结果处理。 + *

+ * + * @see java.lang.annotation.Annotation + */ +@Documented +@Inherited +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface NoResultHolder { +} diff --git a/backend/framework/src/main/java/cn/cordys/common/response/handler/RestControllerExceptionHandler.java b/backend/framework/src/main/java/cn/cordys/common/response/handler/RestControllerExceptionHandler.java new file mode 100644 index 0000000..8e786c0 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/response/handler/RestControllerExceptionHandler.java @@ -0,0 +1,215 @@ +package cn.cordys.common.response.handler; + +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.exception.IResultCode; +import cn.cordys.common.response.result.CrmHttpResultCode; +import cn.cordys.common.util.ServiceUtils; +import cn.cordys.common.util.Translator; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.shiro.UnavailableSecurityManagerException; +import org.apache.shiro.authz.UnauthorizedException; +import org.apache.shiro.lang.ShiroException; +import org.eclipse.jetty.io.EofException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.async.AsyncRequestNotUsableException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.HashMap; +import java.util.Map; + +/** + * 全局异常处理器,处理各类异常并返回统一格式的错误响应。 + */ +@RestControllerAdvice +@Slf4j +public class RestControllerExceptionHandler { + + /** + * 处理 NOT_FOUND 异常,拼接资源名称以提供更详细的错误信息。 + * + * @param message 错误信息模板 + * + * @return String 拼接后的错误信息 + */ + private static String getNotFoundMessage(String message) { + String resourceName = ServiceUtils.getResourceName(); + if (StringUtils.isNotBlank(resourceName)) { + message = String.format(message, Translator.get(resourceName, resourceName)); + } else { + message = String.format(message, Translator.get("resource.name")); + } + ServiceUtils.clearResourceName(); + return message; + } + + /** + * 格式化异常栈信息。 + * + * @param e Exception 异常 + * + * @return String 异常栈的字符串表示 + */ + public static String getStackTraceAsString(Exception e) { + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw, true)); + log.error(sw.toString()); + return sw.toString(); + } + + /** + * 处理数据校验异常,返回具体字段的校验信息。 + * + * @param ex MethodArgumentNotValidException 异常 + * + * @return ResultHolder 返回封装的错误信息,HTTP 状态码 400 + */ + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResultHolder handleValidationExceptions(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + ex.getBindingResult().getAllErrors().forEach((error) -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + errors.put(fieldName, errorMessage); + }); + return ResultHolder.error(CrmHttpResultCode.VALIDATE_FAILED.getCode(), + CrmHttpResultCode.VALIDATE_FAILED.getMessage(), errors); + } + + /** + * 处理请求方法不支持的异常,返回 HTTP 状态码 405。 + * + * @param response HttpServletResponse 响应 + * @param exception 异常信息 + * + * @return ResultHolder 返回错误信息 + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResultHolder handleHttpRequestMethodNotSupportedException(HttpServletResponse response, Exception exception) { + response.setStatus(HttpStatus.METHOD_NOT_ALLOWED.value()); + return ResultHolder.error(HttpStatus.METHOD_NOT_ALLOWED.value(), exception.getMessage()); + } + + /** + * 处理 MSException 异常,根据 errorCode 设置 HTTP 状态码和业务状态码。 + * + * @param e MSException 异常 + * + * @return ResponseEntity 返回响应实体,包含错误信息 + */ + @ExceptionHandler(GenericException.class) + public ResponseEntity handlerGenericException(GenericException e) { + IResultCode errorCode = e.getErrorCode(); + if (errorCode == null) { + // 未设置 errorCode,返回内部服务器错误 + return ResponseEntity.internalServerError() + .body(ResultHolder.error(CrmHttpResultCode.FAILED.getCode(), e.getMessage())); + } + + int code = errorCode.getCode(); + String message = errorCode.getMessage(); + message = Translator.get(message, message); + + if (errorCode instanceof CrmHttpResultCode) { + // 如果是 CrmHttpResultCode 类型,使用其状态码的后三位作为 HTTP 状态码 + if (errorCode.equals(CrmHttpResultCode.NOT_FOUND)) { + message = getNotFoundMessage(message); + } + return ResponseEntity.status(code % 1000) + .body(ResultHolder.error(code, message, e.getMessage())); + } else { + // 其他类型的错误,返回 500 状态码 + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ResultHolder.error(code, Translator.get(message, message), e.getMessage())); + } + } + + /** + * 处理所有类型的异常,返回 HTTP 状态码 500 并格式化异常栈信息。 + * + * @param e Exception 异常 + * + * @return ResponseEntity 返回响应实体,包含错误信息 + */ + @ExceptionHandler({Exception.class}) + public ResponseEntity handleException(Exception e) { + return ResponseEntity.internalServerError() + .body(ResultHolder.error(CrmHttpResultCode.FAILED.getCode(), + e.getMessage(), getStackTraceAsString(e))); + } + + @ExceptionHandler({NoResourceFoundException.class, UnavailableSecurityManagerException.class}) + public ResponseEntity handleNoResourceFoundException(NoResourceFoundException e) { + log.error("No static resource"); + return null; + } + + /** + * 处理 EOF 异常,判断请求路径并返回适当的响应。 + * + * @param request HttpServletRequest 请求 + * @param e 异常信息 + * + * @return ResponseEntity 返回响应实体,包含错误信息 + */ + @ExceptionHandler({EofException.class}) + public ResponseEntity handleEofException(HttpServletRequest request, Exception e) { + String requestURI = request.getRequestURI(); + if (requestURI != null && (requestURI.startsWith("/assets") + || requestURI.startsWith("/fonts") + || requestURI.startsWith("/images") + || requestURI.startsWith("/templates"))) { + return ResponseEntity.internalServerError().body(null); + } + return ResponseEntity.internalServerError() + .body(ResultHolder.error(CrmHttpResultCode.FAILED.getCode(), + e.getMessage(), getStackTraceAsString(e))); + } + + /** + * 处理 Shiro 异常,返回 HTTP 状态码 401。 + * + * @param request HttpServletRequest 请求 + * @param response HttpServletResponse 响应 + * @param exception 异常信息 + * + * @return ResultHolder 返回错误信息 + */ + @ExceptionHandler(ShiroException.class) + public ResultHolder exceptionHandler(HttpServletRequest request, HttpServletResponse response, Exception exception) { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + return ResultHolder.error(HttpStatus.UNAUTHORIZED.value(), exception.getMessage()); + } + + /** + * 处理 Shiro 未授权异常,返回 HTTP 状态码 403。 + * + * @param request HttpServletRequest 请求 + * @param response HttpServletResponse 响应 + * @param exception 异常信息 + * + * @return ResultHolder 返回错误信息 + */ + @ExceptionHandler(UnauthorizedException.class) + public ResultHolder unauthorizedExceptionHandler(HttpServletRequest request, HttpServletResponse response, Exception exception) { + response.setStatus(HttpStatus.FORBIDDEN.value()); + return ResultHolder.error(HttpStatus.FORBIDDEN.value(), exception.getMessage()); + } + + @ExceptionHandler(AsyncRequestNotUsableException.class) + public ResultHolder asyncRequestNotUsableExceptionHandler(HttpServletRequest request, HttpServletResponse response, Exception exception) { + return null; + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/response/handler/ResultHolder.java b/backend/framework/src/main/java/cn/cordys/common/response/handler/ResultHolder.java new file mode 100644 index 0000000..5182aa2 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/response/handler/ResultHolder.java @@ -0,0 +1,145 @@ +package cn.cordys.common.response.handler; + +import cn.cordys.common.response.result.CrmHttpResultCode; +import lombok.Data; + +/** + * ResultHolder 类用于封装接口响应结果,包括状态码、消息、详细信息和返回数据。 + */ +@Data +public class ResultHolder { + + /** + * 请求是否成功的状态码,默认值为 200(成功)。 + */ + private int code = CrmHttpResultCode.SUCCESS.getCode(); + + /** + * 返回给前端的描述信息,一般是错误信息或成功信息。 + */ + private String message; + + /** + * 详细描述信息,例如在发生异常时存储异常日志。 + */ + private Object messageDetail; + + /** + * 返回的数据,可以是任何类型的对象。 + */ + private Object data = ""; + + /** + * 默认构造函数,初始化默认值。 + */ + public ResultHolder() { + } + + /** + * 构造函数,初始化返回数据。 + * + * @param data 返回的数据 + */ + public ResultHolder(Object data) { + this.data = data; + } + + /** + * 构造函数,初始化状态码和消息。 + * + * @param code 状态码 + * @param msg 消息 + */ + public ResultHolder(int code, String msg) { + this.code = code; + this.message = msg; + } + + /** + * 构造函数,初始化状态码、消息和数据。 + * + * @param code 状态码 + * @param msg 消息 + * @param data 返回的数据 + */ + public ResultHolder(int code, String msg, Object data) { + this.code = code; + this.message = msg; + this.data = data; + } + + /** + * 构造函数,初始化状态码、消息、详细信息和数据。 + * + * @param code 状态码 + * @param msg 消息 + * @param messageDetail 详细信息 + * @param data 返回的数据 + */ + public ResultHolder(int code, String msg, Object messageDetail, Object data) { + this.code = code; + this.message = msg; + this.messageDetail = messageDetail; + this.data = data; + } + + /** + * 成功响应,返回带有数据的 ResultHolder。 + * + * @param obj 返回的数据 + * + * @return ResultHolder 返回封装的成功响应 + */ + public static ResultHolder success(Object obj) { + return new ResultHolder(obj); + } + + /** + * 错误响应,返回带有状态码和消息的 ResultHolder。 + * + * @param code 状态码 + * @param message 错误消息 + * + * @return ResultHolder 返回封装的错误响应 + */ + public static ResultHolder error(int code, String message) { + return new ResultHolder(code, message, null, null); + } + + /** + * 错误响应,返回带有消息和详细信息的 ResultHolder。 + * + * @param message 错误消息 + * @param messageDetail 错误的详细信息 + * + * @return ResultHolder 返回封装的错误响应 + */ + public static ResultHolder error(String message, String messageDetail) { + return new ResultHolder(-1, message, messageDetail, null); + } + + /** + * 错误响应,返回带有状态码、消息和详细信息的 ResultHolder。 + * + * @param code 状态码 + * @param message 错误消息 + * @param messageDetail 错误的详细信息 + * + * @return ResultHolder 返回封装的错误响应 + */ + public static ResultHolder error(int code, String message, Object messageDetail) { + return new ResultHolder(code, message, messageDetail, null); + } + + /** + * 特殊情况的响应,例如接口可正常返回 HTTP 状态码 200,但需要给前端提供错误信息。 + * + * @param code 自定义状态码 + * @param message 返回给前端的消息 + * + * @return ResultHolder 返回封装的响应 + */ + public static ResultHolder successCodeErrorInfo(int code, String message) { + return new ResultHolder(code, message, null, null); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/response/handler/ResultResponseBodyAdvice.java b/backend/framework/src/main/java/cn/cordys/common/response/handler/ResultResponseBodyAdvice.java new file mode 100644 index 0000000..39fd263 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/response/handler/ResultResponseBodyAdvice.java @@ -0,0 +1,81 @@ +package cn.cordys.common.response.handler; + +import cn.cordys.common.util.JSON; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +/** + *

统一处理返回结果集的响应体增强类。

+ *

该类用于在返回响应之前统一包装返回的结果,使得所有响应都遵循统一格式。 + * 如果返回的是空值,自动包装为一个成功的响应。

+ * + *

支持的消息转换器类型为:MappingJackson2HttpMessageConverter 和 StringHttpMessageConverter。

+ */ +@RestControllerAdvice(value = {"cn.cordys"}) +public class ResultResponseBodyAdvice implements ResponseBodyAdvice { + + /** + * 判断当前处理器是否支持该转换器。 + * + * @param methodParameter 当前请求的方法参数 + * @param converterType 转换器类型 + * + * @return 如果支持则返回 true,否则返回 false + */ + @Override + public boolean supports(MethodParameter methodParameter, + Class> converterType) { + return MappingJackson2HttpMessageConverter.class.isAssignableFrom(converterType) || + StringHttpMessageConverter.class.isAssignableFrom(converterType); + } + + /** + * 在响应体写出之前对响应结果进行处理。 + * + * @param body 响应体内容 + * @param methodParameter 当前方法参数 + * @param mediaType 响应的媒体类型 + * @param converterType 当前使用的消息转换器类型 + * @param serverHttpRequest 当前的 HTTP 请求 + * @param serverHttpResponse 当前的 HTTP 响应 + * + * @return 处理后的响应体内容 + */ + @Override + public Object beforeBodyWrite(Object body, + MethodParameter methodParameter, + MediaType mediaType, + Class> converterType, + ServerHttpRequest serverHttpRequest, + ServerHttpResponse serverHttpResponse) { + // 处理空值响应,转换为 JSON 格式的成功响应 + if (body == null && StringHttpMessageConverter.class.isAssignableFrom(converterType)) { + serverHttpResponse.getHeaders().setContentType(MediaType.APPLICATION_JSON); + return JSON.toJSONString(ResultHolder.success(body)); + } + + // 如果方法标注了 NoResultHolder 注解,则不做任何包装 + if (methodParameter.hasMethodAnnotation(NoResultHolder.class)) { + return body; + } + + // 如果响应体不是 ResultHolder 类型,则包装为 ResultHolder + if (!(body instanceof ResultHolder)) { + if (body instanceof String) { + serverHttpResponse.getHeaders().setContentType(MediaType.APPLICATION_JSON); + return JSON.toJSONString(ResultHolder.success(body)); + } + return ResultHolder.success(body); + } + + // 如果响应体已经是 ResultHolder 类型,则直接返回 + return body; + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/response/result/CrmHttpResultCode.java b/backend/framework/src/main/java/cn/cordys/common/response/result/CrmHttpResultCode.java new file mode 100644 index 0000000..161f4e8 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/response/result/CrmHttpResultCode.java @@ -0,0 +1,95 @@ +package cn.cordys.common.response.result; + +import cn.cordys.common.exception.IResultCode; + +/** + *

表示 HTTP 状态码的枚举,主要用于在抛出异常时,自动设置 HTTP 响应状态码为对应状态码的后三位数字。

+ *

枚举中的每个状态码代表一个 HTTP 请求的响应状态,常用于 REST API。

+ * + *

状态码采用 100 系列,前三位代表业务域,后三位代表具体的 HTTP 状态码:

+ *
    + *
  • 成功:100200
  • + *
  • 失败:100500
  • + *
  • 验证失败:100400
  • + *
  • 未授权:100401
  • + *
  • 禁止访问:100403
  • + *
  • 未找到:100404
  • + *
+ * + *

实现 {@link IResultCode} 接口,用于标准化异常处理。

+ * + * @author jianxing + * @see IResultCode + * @see CrmHttpResultCode#SUCCESS + * @see CrmHttpResultCode#FAILED + * @see CrmHttpResultCode#VALIDATE_FAILED + * @see CrmHttpResultCode#UNAUTHORIZED + * @see CrmHttpResultCode#FORBIDDEN + * @see CrmHttpResultCode#NOT_FOUND + */ +public enum CrmHttpResultCode implements IResultCode { + + /** + * 请求成功 + */ + SUCCESS(100200, "http_result_success"), + + /** + * 请求失败,未知异常 + */ + FAILED(100500, "http_result_unknown_exception"), + + /** + * 验证失败 + */ + VALIDATE_FAILED(100400, "http_result_validate"), + + /** + * 未授权,需登录 + */ + UNAUTHORIZED(100401, "http_result_unauthorized"), + + /** + * 禁止访问 + */ + FORBIDDEN(100403, "http_result_forbidden"), + + /** + * 资源未找到 + */ + NOT_FOUND(100404, "http_result_not_found"); + + private final int code; + private final String message; + + /** + * 枚举构造函数 + * + * @param code HTTP 状态码 + * @param message 状态码对应的消息键 + */ + CrmHttpResultCode(int code, String message) { + this.code = code; + this.message = message; + } + + /** + * 获取 HTTP 状态码 + * + * @return HTTP 状态码的数值 + */ + @Override + public int getCode() { + return code; + } + + /** + * 获取状态码的消息 + * + * @return 状态码对应的消息,经过翻译处理 + */ + @Override + public String getMessage() { + return getTranslationMessage(this.message); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/BitsAllocator.java b/backend/framework/src/main/java/cn/cordys/common/uid/BitsAllocator.java new file mode 100644 index 0000000..ef2243d --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/BitsAllocator.java @@ -0,0 +1,114 @@ +package cn.cordys.common.uid; + +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import org.springframework.util.Assert; + +/** + * Allocate 64 bits for the UID(long)
+ * sign (fixed 1bit) -> deltaSecond -> workerId -> sequence(within the same second) + **/ +public class BitsAllocator { + /** + * Total 64 bits + */ + public static final int TOTAL_BITS = 1 << 6; + + /** + * Bits for [sign-> second-> workId-> sequence] + */ + private final int signBits = 1; + private final int timestampBits; + private final int workerIdBits; + private final int sequenceBits; + + /** + * Max value for workId & sequence + */ + private final long maxDeltaSeconds; + private final long maxWorkerId; + private final long maxSequence; + + /** + * Shift for timestamp & workerId + */ + private final int timestampShift; + private final int workerIdShift; + + /** + * Constructor with timestampBits, workerIdBits, sequenceBits
+ * The highest bit used for sign, so 63 bits for timestampBits, workerIdBits, sequenceBits + */ + public BitsAllocator(int timestampBits, int workerIdBits, int sequenceBits) { + // make sure allocated 64 bits + int allocateTotalBits = signBits + timestampBits + workerIdBits + sequenceBits; + Assert.isTrue(allocateTotalBits == TOTAL_BITS, "allocate not enough 64 bits"); + + // initialize bits + this.timestampBits = timestampBits; + this.workerIdBits = workerIdBits; + this.sequenceBits = sequenceBits; + + // initialize max value + this.maxDeltaSeconds = ~(-1L << timestampBits); + this.maxWorkerId = ~(-1L << workerIdBits); + this.maxSequence = ~(-1L << sequenceBits); + + // initialize shift + this.timestampShift = workerIdBits + sequenceBits; + this.workerIdShift = sequenceBits; + } + + /** + * Allocate bits for UID according to delta seconds & workerId & sequence
+ * Note that: The highest bit will always be 0 for sign + */ + public long allocate(long deltaSeconds, long workerId, long sequence) { + return (deltaSeconds << timestampShift) | (workerId << workerIdShift) | sequence; + } + + /** + * Getters + */ + public int getSignBits() { + return signBits; + } + + public int getTimestampBits() { + return timestampBits; + } + + public int getWorkerIdBits() { + return workerIdBits; + } + + public int getSequenceBits() { + return sequenceBits; + } + + public long getMaxDeltaSeconds() { + return maxDeltaSeconds; + } + + public long getMaxWorkerId() { + return maxWorkerId; + } + + public long getMaxSequence() { + return maxSequence; + } + + public int getTimestampShift() { + return timestampShift; + } + + public int getWorkerIdShift() { + return workerIdShift; + } + + @Override + public String toString() { + return ToStringBuilder.reflectionToString(this, ToStringStyle.SHORT_PREFIX_STYLE); + } + +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/IDGenerator.java b/backend/framework/src/main/java/cn/cordys/common/uid/IDGenerator.java new file mode 100644 index 0000000..a6474ea --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/IDGenerator.java @@ -0,0 +1,37 @@ +package cn.cordys.common.uid; + +import cn.cordys.common.uid.impl.DefaultUidGenerator; +import cn.cordys.common.util.CommonBeanFactory; + +/** + * IDGenerator 用于生成唯一的 ID。 + * 提供了生成数字 ID 和字符串 ID 的功能。 + */ +public class IDGenerator { + + // 默认的 UID 生成器实例 + private static final DefaultUidGenerator DEFAULT_UID_GENERATOR; + + static { + // 从 CommonBeanFactory 获取 DefaultUidGenerator 实例 + DEFAULT_UID_GENERATOR = CommonBeanFactory.getBean(DefaultUidGenerator.class); + } + + /** + * 生成一个唯一的数字 ID。 + * + * @return 唯一的数字 ID + */ + public static Long nextNum() { + return DEFAULT_UID_GENERATOR.getUID(); + } + + /** + * 生成一个唯一的字符串 ID。 + * + * @return 唯一的字符串 ID + */ + public static String nextStr() { + return String.valueOf(DEFAULT_UID_GENERATOR.getUID()); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/NumGenerator.java b/backend/framework/src/main/java/cn/cordys/common/uid/NumGenerator.java new file mode 100644 index 0000000..a43fd22 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/NumGenerator.java @@ -0,0 +1,92 @@ +package cn.cordys.common.uid; + +import cn.cordys.common.constants.ApplicationNumScope; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.redisson.Redisson; +import org.redisson.api.RIdGenerator; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 用于生成数字 ID 的生成器类,支持根据不同的应用场景(Scope)生成唯一的 ID。 + * + *

该类使用 Redisson 的分布式 ID 生成器和 Redis 进行管理。

+ */ +@Component +public class NumGenerator { + + // 初始值,代表从100001开始生成 ID + private static final long INIT = 100001L; + + // 限制每次生成的最大数量 + private static final long LIMIT = 1; + // 特定的子范围,用于表示二级的用例 + private static final List SUB_NUM = List.of(ApplicationNumScope.SYSTEM); + // Redisson 实例,用于获取分布式 ID 生成器 + private static Redisson redisson; + // StringRedisTemplate 用于操作 Redis + private static StringRedisTemplate stringRedisTemplate; + + /** + * 根据指定的应用场景生成唯一的数字 ID。 + * + * @param scope 应用场景(例如:接口用例) + * + * @return 唯一的数字 ID + */ + public static long nextNum(ApplicationNumScope scope) { + return nextNum(scope.name(), scope); + } + + /** + * 根据指定的前缀和应用场景生成唯一的数字 ID。 + * + * @param prefix 前缀,例如:ORGANIZATION_ID + "_" + DOMAIN + * @param scope 应用场景(例如:接口用例) + * + * @return 唯一的数字 ID + */ + public static long nextNum(String prefix, ApplicationNumScope scope) { + // 获取分布式 ID 生成器 + RIdGenerator idGenerator = redisson.getIdGenerator(prefix + "_" + scope.name()); + + // 处理子范围的用例(如 SYSTEM) + if (SUB_NUM.contains(scope)) { + // 确保 ID 生成器存在,如果不存在则初始化 + if (!idGenerator.isExists()) { + idGenerator.tryInit(1, LIMIT); + } + // 返回格式化后的 ID,保留 3 位 + return Long.parseLong(prefix.split("_")[1] + StringUtils.leftPad(String.valueOf(idGenerator.nextId()), 3, "0")); + } else { + // 其他范围的用例,初始化 ID 生成器 + if (!idGenerator.isExists()) { + idGenerator.tryInit(INIT, LIMIT); + } + return idGenerator.nextId(); + } + } + + /** + * 设置 Redisson 实例,用于分布式 ID 生成器。 + * + * @param redisson Redisson 实例 + */ + @Resource + public void setRedisson(Redisson redisson) { + NumGenerator.redisson = redisson; + } + + /** + * 设置 StringRedisTemplate 实例,用于操作 Redis。 + * + * @param stringRedisTemplate StringRedisTemplate 实例 + */ + @Resource + public void setStringRedisTemplate(StringRedisTemplate stringRedisTemplate) { + NumGenerator.stringRedisTemplate = stringRedisTemplate; + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/SerialNumGenerator.java b/backend/framework/src/main/java/cn/cordys/common/uid/SerialNumGenerator.java new file mode 100644 index 0000000..d139432 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/SerialNumGenerator.java @@ -0,0 +1,122 @@ +package cn.cordys.common.uid; + +import cn.cordys.common.exception.GenericException; +import cn.cordys.quartz.anno.QuartzScheduled; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Objects; + +@Component +@Slf4j +public class SerialNumGenerator { + + private static final int RULE_SIZE = 5; + private static final int DATE_KEY_IDX = 3; + private static final String PREFIX = "serial"; + + private final StringRedisTemplate redis; + + public SerialNumGenerator(StringRedisTemplate redis) { + this.redis = redis; + } + + /** + * 按规则生成流水号 + */ + public String generateByRules(List rules, String orgId, String formKey) { + if (CollectionUtils.size(rules) < RULE_SIZE) { + throw new GenericException("流水号规则配置有误"); + } + + Rule r = Rule.from(rules); + + // 强制使用年月作为流水号 key 的日期部分 + String date = new SimpleDateFormat(r.datePattern()).format(new Date()); + String key = "%s:%s:%s:%s:%s".formatted(PREFIX, orgId, formKey, date, r.p1); + try { + // Redis 自增序列 + long seq = Objects.requireNonNull(redis.opsForValue().increment(key), "Redis increment 返回 null"); + // 构造最终流水号 + return ("%s%s%s%s%0" + r.width() + "d") + .formatted(r.p1(), r.p2(), date, r.mid(), seq); + + } catch (Exception e) { + log.error("生成流水号失败", e); + return null; + } + } + + /** + * 内部规则封装 + */ + private record Rule(String p1, String p2, String datePattern, String mid, int width) { + static Rule from(List rules) { + return new Rule( + rules.get(0), + rules.get(1), + rules.get(2), + rules.get(3), + Integer.parseInt(rules.get(4)) + ); + } + + private boolean equals(Rule other) { + return Strings.CS.equals(this.p1, other.p1) + && Strings.CS.equals(this.p2, other.p2) + && Strings.CS.equals(this.datePattern, other.datePattern) + && Strings.CS.equals(this.mid, other.mid) + && this.width == other.width; + } + } + + @QuartzScheduled(cron = "0 0 1 1,16 * ?") + public void clean() { + log.info("开始清理过期流水号Key"); + + String currentMonth = new SimpleDateFormat("yyyyMM").format(new Date()); + try (Cursor cursor = redis.scan(ScanOptions.scanOptions().match("serial:*:*:*:*").count(1000).build())) { + cursor.forEachRemaining(key -> { + String[] ks = key.split(":"); + if (ks.length != RULE_SIZE) { + log.warn("存在脏Key: {}", key); + return; + } + if (!currentMonth.equals(ks[DATE_KEY_IDX])) { + redis.delete(key); + log.info("删除过期Key: {}", key); + } + }); + } catch (Exception e) { + log.error("流水号过期Key清理异常: ", e); + } + + log.info("流水号过期Key清理完成"); + } + + public boolean sameRule(List oRules, List nRules) { + if (oRules.size() != nRules.size() && oRules.size() != RULE_SIZE) { + return false; + } + Rule or = Rule.from(oRules); + Rule nr = Rule.from(nRules); + return or.equals(nr); + } + + /** + * 重置指定规则的流水号 + */ + public void resetKey(String datePattern, String formKey, String orgId) { + String date = new SimpleDateFormat(datePattern).format(new Date()); + String key = "%s:%s:%s:%s".formatted(PREFIX, orgId, formKey, date); + redis.delete(key); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/buffer/BufferPaddingExecutor.java b/backend/framework/src/main/java/cn/cordys/common/uid/buffer/BufferPaddingExecutor.java new file mode 100644 index 0000000..0f74552 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/buffer/BufferPaddingExecutor.java @@ -0,0 +1,167 @@ +package cn.cordys.common.uid.buffer; + +import cn.cordys.common.uid.utils.NamingThreadFactory; +import cn.cordys.common.uid.utils.PaddedAtomicLong; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.Assert; + +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Represents an executor for padding {@link RingBuffer}
+ * There are two kinds of executors: one for scheduled padding, the other for padding immediately. + */ +public class BufferPaddingExecutor { + private static final Logger LOGGER = LoggerFactory.getLogger(BufferPaddingExecutor.class); + + /** + * Constants + */ + private static final String WORKER_NAME = "RingBuffer-Padding-Worker"; + private static final String SCHEDULE_NAME = "RingBuffer-Padding-Schedule"; + private static final long DEFAULT_SCHEDULE_INTERVAL = 5 * 60L; // 5 minutes + + /** + * Whether buffer padding is running + */ + private final AtomicBoolean running; + + /** + * We can borrow UIDs from the future, here store the last second we have consumed + */ + private final PaddedAtomicLong lastSecond; + + /** + * RingBuffer & BufferUidProvider + */ + private final RingBuffer ringBuffer; + private final BufferedUidProvider uidProvider; + + /** + * Padding immediately by the thread pool + */ + private final ExecutorService bufferPadExecutors; + /** + * Padding schedule thread + */ + private final ScheduledExecutorService bufferPadSchedule; + + /** + * Schedule interval Unit as seconds + */ + private long scheduleInterval = DEFAULT_SCHEDULE_INTERVAL; + + /** + * Constructor with {@link RingBuffer} and {@link BufferedUidProvider}, default use schedule + * + * @param ringBuffer {@link RingBuffer} + * @param uidProvider {@link BufferedUidProvider} + */ + public BufferPaddingExecutor(RingBuffer ringBuffer, BufferedUidProvider uidProvider) { + this(ringBuffer, uidProvider, true); + } + + /** + * Constructor with {@link RingBuffer}, {@link BufferedUidProvider}, and whether use schedule padding + * + * @param ringBuffer {@link RingBuffer} + * @param uidProvider {@link BufferedUidProvider} + */ + public BufferPaddingExecutor(RingBuffer ringBuffer, BufferedUidProvider uidProvider, boolean usingSchedule) { + this.running = new AtomicBoolean(false); + this.lastSecond = new PaddedAtomicLong(TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())); + this.ringBuffer = ringBuffer; + this.uidProvider = uidProvider; + + // initialize thread pool + int cores = Runtime.getRuntime().availableProcessors(); + bufferPadExecutors = Executors.newFixedThreadPool(cores * 2, new NamingThreadFactory(WORKER_NAME)); + + // initialize schedule thread + if (usingSchedule) { + bufferPadSchedule = Executors.newSingleThreadScheduledExecutor(new NamingThreadFactory(SCHEDULE_NAME)); + } else { + bufferPadSchedule = null; + } + } + + /** + * Start executors such as schedule + */ + public void start() { + if (bufferPadSchedule != null) { + bufferPadSchedule.scheduleWithFixedDelay(this::paddingBuffer, scheduleInterval, scheduleInterval, TimeUnit.SECONDS); + } + } + + /** + * Shutdown executors + */ + public void shutdown() { + if (!bufferPadExecutors.isShutdown()) { + bufferPadExecutors.shutdownNow(); + } + + if (bufferPadSchedule != null && !bufferPadSchedule.isShutdown()) { + bufferPadSchedule.shutdownNow(); + } + } + + /** + * Whether is padding + */ + public boolean isRunning() { + return running.get(); + } + + /** + * Padding buffer in the thread pool + */ + public void asyncPadding() { + bufferPadExecutors.submit(this::paddingBuffer); + } + + /** + * Padding buffer fill the slots until to catch the cursor + */ + public void paddingBuffer() { + LOGGER.info("Ready to padding buffer lastSecond:{}. {}", lastSecond.get(), ringBuffer); + + // is still running + if (!running.compareAndSet(false, true)) { + LOGGER.info("Padding buffer is still running. {}", ringBuffer); + return; + } + + // fill the rest slots until to catch the cursor + boolean isFullRingBuffer = false; + while (!isFullRingBuffer) { + List uidList = uidProvider.provide(lastSecond.incrementAndGet()); + for (Long uid : uidList) { + isFullRingBuffer = !ringBuffer.put(uid); + if (isFullRingBuffer) { + break; + } + } + } + + // not running now + running.compareAndSet(true, false); + LOGGER.info("End to padding buffer lastSecond:{}. {}", lastSecond.get(), ringBuffer); + } + + /** + * Setters + */ + public void setScheduleInterval(long scheduleInterval) { + Assert.isTrue(scheduleInterval > 0, "Schedule interval must positive!"); + this.scheduleInterval = scheduleInterval; + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/buffer/BufferedUidProvider.java b/backend/framework/src/main/java/cn/cordys/common/uid/buffer/BufferedUidProvider.java new file mode 100644 index 0000000..483dadd --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/buffer/BufferedUidProvider.java @@ -0,0 +1,15 @@ +package cn.cordys.common.uid.buffer; + +import java.util.List; + +/** + * Buffered UID provider(Lambda supported), which provides UID in the same one second + */ +@FunctionalInterface +public interface BufferedUidProvider { + + /** + * Provides UID in one second + */ + List provide(long momentInSecond); +} diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/buffer/RejectedPutBufferHandler.java b/backend/framework/src/main/java/cn/cordys/common/uid/buffer/RejectedPutBufferHandler.java new file mode 100644 index 0000000..6b7add0 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/buffer/RejectedPutBufferHandler.java @@ -0,0 +1,14 @@ +package cn.cordys.common.uid.buffer; + +/** + * If tail catches the cursor it means that the ring buffer is full, any more buffer put request will be rejected. + * Specify the policy to handle the reject. This is a Lambda supported interface + */ +@FunctionalInterface +public interface RejectedPutBufferHandler { + + /** + * Reject put buffer request + */ + void rejectPutBuffer(RingBuffer ringBuffer, long uid); +} diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/buffer/RejectedTakeBufferHandler.java b/backend/framework/src/main/java/cn/cordys/common/uid/buffer/RejectedTakeBufferHandler.java new file mode 100644 index 0000000..5654e1c --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/buffer/RejectedTakeBufferHandler.java @@ -0,0 +1,14 @@ +package cn.cordys.common.uid.buffer; + +/** + * If cursor catches the tail it means that the ring buffer is empty, any more buffer take request will be rejected. + * Specify the policy to handle the reject. This is a Lambda supported interface + */ +@FunctionalInterface +public interface RejectedTakeBufferHandler { + + /** + * Reject take buffer request + */ + void rejectTakeBuffer(RingBuffer ringBuffer); +} diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/buffer/RingBuffer.java b/backend/framework/src/main/java/cn/cordys/common/uid/buffer/RingBuffer.java new file mode 100644 index 0000000..dbfb401 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/buffer/RingBuffer.java @@ -0,0 +1,249 @@ +package cn.cordys.common.uid.buffer; + +import cn.cordys.common.uid.utils.PaddedAtomicLong; +import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.Assert; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * Represents a ring buffer based on array.
+ * Using array could improve read element performance due to the CUP cache line. To prevent + * the side effect of False Sharing, {@link PaddedAtomicLong} is using on 'tail' and 'cursor'

+ *

+ * A ring buffer is consisted of: + *

  • slots: each element of the array is a slot, which is be set with a UID + *
  • flags: flag array corresponding the same index with the slots, indicates whether can take or put slot + *
  • tail: a sequence of the max slot position to produce + *
  • cursor: a sequence of the min slot position to consume + */ +public class RingBuffer { + public static final int DEFAULT_PADDING_PERCENT = 50; + private static final Logger LOGGER = LoggerFactory.getLogger(RingBuffer.class); + /** + * Constants + */ + private static final int START_POINT = -1; + private static final long CAN_PUT_FLAG = 0L; + private static final long CAN_TAKE_FLAG = 1L; + /** + * The size of RingBuffer's slots, each slot hold a UID + */ + @Getter + private final int bufferSize; + private final long indexMask; + private final long[] slots; + private final PaddedAtomicLong[] flags; + + /** + * Tail: last position sequence to produce + */ + private final AtomicLong tail = new PaddedAtomicLong(START_POINT); + + /** + * Cursor: current position sequence to consume + */ + private final AtomicLong cursor = new PaddedAtomicLong(START_POINT); + + /** + * Threshold for trigger padding buffer + */ + private final int paddingThreshold; + + /** + * Reject put/take buffer handle policy + */ + private RejectedPutBufferHandler rejectedPutHandler = this::discardPutBuffer; + private RejectedTakeBufferHandler rejectedTakeHandler = this::exceptionRejectedTakeBuffer; + + /** + * Executor of padding buffer + */ + private BufferPaddingExecutor bufferPaddingExecutor; + + /** + * Constructor with buffer size, paddingFactor default as {@value #DEFAULT_PADDING_PERCENT} + * + * @param bufferSize must be positive & a power of 2 + */ + public RingBuffer(int bufferSize) { + this(bufferSize, DEFAULT_PADDING_PERCENT); + } + + /** + * Constructor with buffer size & padding factor + * + * @param bufferSize must be positive & a power of 2 + * @param paddingFactor percent in (0 - 100). When the count of rest available UIDs reach the threshold, it will trigger padding buffer
    + * Sample: paddingFactor=20, bufferSize=1000 -> threshold=1000 * 20 /100, + * padding buffer will be triggered when tail-cursor 0L, "RingBuffer size must be positive"); + Assert.isTrue(Integer.bitCount(bufferSize) == 1, "RingBuffer size must be a power of 2"); + Assert.isTrue(paddingFactor > 0 && paddingFactor < 100, "RingBuffer size must be positive"); + + this.bufferSize = bufferSize; + this.indexMask = bufferSize - 1; + this.slots = new long[bufferSize]; + this.flags = initFlags(bufferSize); + + this.paddingThreshold = bufferSize * paddingFactor / 100; + } + + /** + * Put an UID in the ring & tail moved
    + * We use 'synchronized' to guarantee the UID fill in slot & publish new tail sequence as atomic operations
    + * + * Note that: It is recommended to put UID in a serialize way, cause we once batch generate a series UIDs and put + * the one by one into the buffer, so it is unnecessary put in multi-threads + * * @return false means that the buffer is full, apply {@link RejectedPutBufferHandler} + */ + public synchronized boolean put(long uid) { + long currentTail = tail.get(); + long currentCursor = cursor.get(); + + // tail catches the cursor, means that you can't put any cause of RingBuffer is full + long distance = currentTail - (currentCursor == START_POINT ? 0 : currentCursor); + if (distance == bufferSize - 1) { + rejectedPutHandler.rejectPutBuffer(this, uid); + return false; + } + + // 1. pre-check whether the flag is CAN_PUT_FLAG + int nextTailIndex = calSlotIndex(currentTail + 1); + if (flags[nextTailIndex].get() != CAN_PUT_FLAG) { + rejectedPutHandler.rejectPutBuffer(this, uid); + return false; + } + + // 2. put UID in the next slot + // 3. update next slot' flag to CAN_TAKE_FLAG + // 4. publish tail with sequence increase by one + slots[nextTailIndex] = uid; + flags[nextTailIndex].set(CAN_TAKE_FLAG); + tail.incrementAndGet(); + + // The atomicity of operations above, guarantees by 'synchronized'. In another word, + // the take operation can't consume the UID we just put, until the tail is published(tail.incrementAndGet()) + return true; + } + + /** + * Take an UID of the ring at the next cursor, this is a lock free operation by using atomic cursor

    + *

    + * Before getting the UID, we also check whether reach the padding threshold, + * the padding buffer operation will be triggered in another thread
    + * If there is no more available UID to be taken, the specified {@link RejectedTakeBufferHandler} will be applied
    + * + * @return UID + * + * @throws IllegalStateException if the cursor moved back + */ + public long take() { + // spin get next available cursor + long currentCursor = cursor.get(); + long nextCursor = cursor.updateAndGet(old -> old == tail.get() ? old : old + 1); + + // check for safety consideration, it never occurs + Assert.isTrue(nextCursor >= currentCursor, "Cursor can't move back"); + + // trigger padding in an async-mode if reach the threshold + long currentTail = tail.get(); + if (currentTail - nextCursor < paddingThreshold) { + LOGGER.info("Reach the padding threshold:{}. tail:{}, cursor:{}, rest:{}", paddingThreshold, currentTail, + nextCursor, currentTail - nextCursor); + bufferPaddingExecutor.asyncPadding(); + } + + // cursor catch the tail, means that there is no more available UID to take + if (nextCursor == currentCursor) { + rejectedTakeHandler.rejectTakeBuffer(this); + } + + // 1. check next slot flag is CAN_TAKE_FLAG + int nextCursorIndex = calSlotIndex(nextCursor); + Assert.isTrue(flags[nextCursorIndex].get() == CAN_TAKE_FLAG, "Cursor not in can take status"); + + // 2. get UID from next slot + // 3. set next slot flag as CAN_PUT_FLAG. + long uid = slots[nextCursorIndex]; + flags[nextCursorIndex].set(CAN_PUT_FLAG); + + // Note that: Step 2,3 can not swap. If we set flag before get value of slot, the producer may overwrite the + // slot with a new UID, and this may cause the consumer take the UID twice after walk a round the ring + return uid; + } + + /** + * Calculate slot index with the slot sequence (sequence % bufferSize) + */ + protected int calSlotIndex(long sequence) { + return (int) (sequence & indexMask); + } + + /** + * Discard policy for {@link RejectedPutBufferHandler}, we just do logging + */ + protected void discardPutBuffer(RingBuffer ringBuffer, long uid) { + LOGGER.warn("Rejected putting buffer for uid:{}. {}", uid, ringBuffer); + } + + /** + * Policy for {@link RejectedTakeBufferHandler}, throws {@link RuntimeException} after logging + */ + protected void exceptionRejectedTakeBuffer(RingBuffer ringBuffer) { + LOGGER.warn("Rejected take buffer. {}", ringBuffer); + throw new RuntimeException("Rejected take buffer. " + ringBuffer); + } + + /** + * Initialize flags as CAN_PUT_FLAG + */ + private PaddedAtomicLong[] initFlags(int bufferSize) { + PaddedAtomicLong[] flags = new PaddedAtomicLong[bufferSize]; + for (int i = 0; i < bufferSize; i++) { + flags[i] = new PaddedAtomicLong(CAN_PUT_FLAG); + } + + return flags; + } + + /** + * Getters + */ + public long getTail() { + return tail.get(); + } + + public long getCursor() { + return cursor.get(); + } + + /** + * Setters + */ + public void setBufferPaddingExecutor(BufferPaddingExecutor bufferPaddingExecutor) { + this.bufferPaddingExecutor = bufferPaddingExecutor; + } + + public void setRejectedPutHandler(RejectedPutBufferHandler rejectedPutHandler) { + this.rejectedPutHandler = rejectedPutHandler; + } + + public void setRejectedTakeHandler(RejectedTakeBufferHandler rejectedTakeHandler) { + this.rejectedTakeHandler = rejectedTakeHandler; + } + + @Override + public String toString() { + return "RingBuffer [bufferSize=" + bufferSize + + ", tail=" + tail + + ", cursor=" + cursor + + ", paddingThreshold=" + paddingThreshold + "]"; + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/impl/DefaultUidGenerator.java b/backend/framework/src/main/java/cn/cordys/common/uid/impl/DefaultUidGenerator.java new file mode 100644 index 0000000..3f77c3a --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/impl/DefaultUidGenerator.java @@ -0,0 +1,156 @@ +package cn.cordys.common.uid.impl; + +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.uid.BitsAllocator; +import cn.cordys.common.uid.worker.WorkerIdAssigner; +import cn.cordys.common.util.TimeUtils; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + +import java.util.concurrent.TimeUnit; + +@Component +@Slf4j +public class DefaultUidGenerator implements DisposableBean { + + /** + * ===== Bits allocation ===== + * sign(1) + time + worker + sequence = 64 + */ + protected int timeBits = 30; // 秒级时间,≈34 年 + protected int workerBits = 21; // 200w+ worker + protected int seqBits = 12; // 每秒 4096 + + /** + * ===== Fixed epoch (DO NOT CHANGE AFTER RELEASE) ===== + * 2025-01-01 00:00:00 + */ + protected String epochStr = "2025-01-01"; + protected long epochSeconds; + + /** + * ===== Stable fields ===== + */ + protected BitsAllocator bitsAllocator; + protected long workerId; + + /** + * ===== Volatile fields ===== + */ + protected long sequence = 0L; + protected long lastSecond = -1L; + + /** + * ===== WorkerId ===== + */ + @Resource + protected WorkerIdAssigner workerIdAssigner; + + /** + * Spring lifecycle init + */ + @PostConstruct + public void init() { + // init epoch + setEpochStr(epochStr); + + // init bits allocator + this.bitsAllocator = new BitsAllocator(timeBits, workerBits, seqBits); + + // init worker id + this.workerId = workerIdAssigner.assignWorkerId(); + if (workerId < 0 || workerId > bitsAllocator.getMaxWorkerId()) { + throw new IllegalStateException( + "WorkerId " + workerId + " exceeds max " + bitsAllocator.getMaxWorkerId() + ); + } + + log.info( + "Initialized UID Generator: epoch={}, bits=(time:{}, worker:{}, seq:{}), workerId={}", + epochStr, timeBits, workerBits, seqBits, workerId + ); + } + + /** + * Get UID + */ + public long getUID() { + Assert.notNull(bitsAllocator, "UidGenerator not initialized"); + return nextId(); + } + + /** + * Generate next UID + */ + protected synchronized long nextId() { + long currentSecond = getCurrentSecond(); + + // Clock moved backwards + if (currentSecond < lastSecond) { + long refusedSeconds = lastSecond - currentSecond; + throw new GenericException( + String.format("Clock moved backwards. Refusing for %d seconds", refusedSeconds) + ); + } + + if (currentSecond == lastSecond) { + sequence = (sequence + 1) & bitsAllocator.getMaxSequence(); + if (sequence == 0) { + currentSecond = waitNextSecond(lastSecond); + } + } else { + sequence = 0L; + } + + lastSecond = currentSecond; + + return bitsAllocator.allocate( + currentSecond - epochSeconds, + workerId, + sequence + ); + } + + /** + * Wait until next second + */ + private long waitNextSecond(long lastSecond) { + long current = getCurrentSecond(); + while (current <= lastSecond) { + current = getCurrentSecond(); + } + return current; + } + + /** + * Get current second + */ + private long getCurrentSecond() { + long currentSecond = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()); + if (currentSecond - epochSeconds > bitsAllocator.getMaxDeltaSeconds()) { + throw new GenericException( + "Timestamp bits exhausted. Refusing UID generation. Now=" + currentSecond + ); + } + return currentSecond; + } + + public void setEpochStr(String epochStr) { + if (StringUtils.isNotBlank(epochStr)) { + this.epochStr = epochStr; + this.epochSeconds = TimeUnit.MILLISECONDS.toSeconds( + TimeUtils.parseByDayPattern(epochStr).getTime() + ); + } + } + + @Override + public void destroy() { + log.info("Shutdown UID Generator..."); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/utils/DockerUtils.java b/backend/framework/src/main/java/cn/cordys/common/uid/utils/DockerUtils.java new file mode 100644 index 0000000..3ce59cc --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/utils/DockerUtils.java @@ -0,0 +1,93 @@ +package cn.cordys.common.uid.utils; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * DockerUtils + */ +public abstract class DockerUtils { + private static final Logger LOGGER = LoggerFactory.getLogger(DockerUtils.class); + + /** + * Environment param keys + */ + private static final String ENV_KEY_HOST = "JPAAS_HOST"; + private static final String ENV_KEY_PORT = "JPAAS_HTTP_PORT"; + private static final String ENV_KEY_PORT_ORIGINAL = "JPAAS_HOST_PORT_8080"; + + /** + * Docker host & port + */ + private static String DOCKER_HOST = ""; + private static String DOCKER_PORT = ""; + + /** + * Whether is docker + */ + private static boolean IS_DOCKER; + + static { + retrieveFromEnv(); + } + + /** + * Retrieve docker host + * + * @return empty string if not a docker + */ + public static String getDockerHost() { + return DOCKER_HOST; + } + + /** + * Retrieve docker port + * + * @return empty string if not a docker + */ + public static String getDockerPort() { + return DOCKER_PORT; + } + + /** + * Whether a docker + * + * @return + */ + public static boolean isDocker() { + return IS_DOCKER; + } + + /** + * Retrieve host & port from environment + */ + private static void retrieveFromEnv() { + // retrieve host & port from environment + DOCKER_HOST = System.getenv(ENV_KEY_HOST); + DOCKER_PORT = System.getenv(ENV_KEY_PORT); + + // not found from 'JPAAS_HTTP_PORT', then try to find from 'JPAAS_HOST_PORT_8080' + if (StringUtils.isBlank(DOCKER_PORT)) { + DOCKER_PORT = System.getenv(ENV_KEY_PORT_ORIGINAL); + } + + boolean hasEnvHost = StringUtils.isNotBlank(DOCKER_HOST); + boolean hasEnvPort = StringUtils.isNotBlank(DOCKER_PORT); + + // docker can find both host & port from environment + if (hasEnvHost && hasEnvPort) { + IS_DOCKER = true; + + // found nothing means not a docker, maybe an actual machine + } else if (!hasEnvHost && !hasEnvPort) { + IS_DOCKER = false; + + } else { + LOGGER.error("Missing host or port from env for Docker. host:{}, port:{}", DOCKER_HOST, DOCKER_PORT); + throw new RuntimeException( + "Missing host or port from env for Docker. host:" + DOCKER_HOST + ", port:" + DOCKER_PORT); + } + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/utils/EnumUtils.java b/backend/framework/src/main/java/cn/cordys/common/uid/utils/EnumUtils.java new file mode 100644 index 0000000..b9bdd89 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/utils/EnumUtils.java @@ -0,0 +1,38 @@ +package cn.cordys.common.uid.utils; + +import org.springframework.util.Assert; + +/** + * EnumUtils provides the operations for {@link ValuedEnum} such as Parse, value of... + */ +public abstract class EnumUtils { + + /** + * Parse the bounded value into ValuedEnum + */ + public static , V> T parse(Class clz, V value) { + Assert.notNull(clz, "clz can not be null"); + if (value == null) { + return null; + } + + for (T t : clz.getEnumConstants()) { + if (value.equals(t.value())) { + return t; + } + } + return null; + } + + /** + * Null-safe valueOf function + */ + public static > T valueOf(Class enumType, String name) { + if (name == null) { + return null; + } + + return Enum.valueOf(enumType, name); + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/utils/NamingThreadFactory.java b/backend/framework/src/main/java/cn/cordys/common/uid/utils/NamingThreadFactory.java new file mode 100644 index 0000000..891a644 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/utils/NamingThreadFactory.java @@ -0,0 +1,118 @@ +package cn.cordys.common.uid.utils; + + +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ClassUtils; +import org.apache.commons.lang3.StringUtils; + +import java.lang.Thread.UncaughtExceptionHandler; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Named thread in ThreadFactory. If there is no specified name for thread, it + * will auto detect using the invoker classname instead. + */ +@Slf4j +public class NamingThreadFactory implements ThreadFactory { + /** + * Is daemon thread + */ + private final boolean daemon; + /** + * UncaughtExceptionHandler + */ + private final UncaughtExceptionHandler uncaughtExceptionHandler; + /** + * Sequences for multi thread name prefix + */ + private final ConcurrentHashMap sequences; + /** + * Thread name pre + * -- GETTER -- + * Getters & Setters + */ + @Setter + @Getter + private String name; + + /** + * Constructors + */ + public NamingThreadFactory() { + this(null, false, null); + } + + public NamingThreadFactory(String name) { + this(name, false, null); + } + + public NamingThreadFactory(String name, boolean daemon) { + this(name, daemon, null); + } + + public NamingThreadFactory(String name, boolean daemon, UncaughtExceptionHandler handler) { + this.name = name; + this.daemon = daemon; + this.uncaughtExceptionHandler = handler; + this.sequences = new ConcurrentHashMap<>(); + } + + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r); + thread.setDaemon(this.daemon); + + // If there is no specified name for thread, it will auto detect using the invoker classname instead. + // Notice that auto detect may cause some performance overhead + String prefix = this.name; + if (StringUtils.isBlank(prefix)) { + prefix = getInvoker(); + } + thread.setName(prefix + "-" + getSequence(prefix)); + + // no specified uncaughtExceptionHandler, just do logging. + thread.setUncaughtExceptionHandler(Objects.requireNonNullElseGet(this.uncaughtExceptionHandler, () -> (t, e) -> log.error("unhandled exception in thread: " + t.getName(), e))); + + return thread; + } + + /** + * Get the method invoker's class name + * + * @return + */ + private String getInvoker() { + Exception e = new Exception(); + StackTraceElement[] sites = e.getStackTrace(); + if (sites.length > 2) { + return ClassUtils.getShortClassName(sites[2].getClassName()); + } + return getClass().getSimpleName(); + } + + /** + * Get sequence for different naming prefix + * + * @param invoker + * + * @return + */ + private long getSequence(String invoker) { + AtomicLong r = this.sequences.get(invoker); + if (r == null) { + r = new AtomicLong(0); + AtomicLong previous = this.sequences.putIfAbsent(invoker, r); + if (previous != null) { + r = previous; + } + } + + return r.incrementAndGet(); + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/utils/NetUtils.java b/backend/framework/src/main/java/cn/cordys/common/uid/utils/NetUtils.java new file mode 100644 index 0000000..74dc710 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/utils/NetUtils.java @@ -0,0 +1,68 @@ +package cn.cordys.common.uid.utils; + +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.Enumeration; + +/** + * NetUtils + */ +public abstract class NetUtils { + + /** + * Pre-loaded local address + */ + public static InetAddress localAddress; + + static { + try { + localAddress = getLocalInetAddress(); + } catch (SocketException e) { + throw new RuntimeException("fail to get local ip."); + } + } + + /** + * Retrieve the first validated local ip address(the Public and LAN ip addresses are validated). + * + * @return the local address + * + * @throws SocketException the socket exception + */ + public static InetAddress getLocalInetAddress() throws SocketException { + // enumerates all network interfaces + Enumeration enu = NetworkInterface.getNetworkInterfaces(); + + while (enu.hasMoreElements()) { + NetworkInterface ni = enu.nextElement(); + if (ni.isLoopback()) { + continue; + } + + Enumeration addressEnumeration = ni.getInetAddresses(); + while (addressEnumeration.hasMoreElements()) { + InetAddress address = addressEnumeration.nextElement(); + + // ignores all invalidated addresses + if (address.isLinkLocalAddress() || address.isLoopbackAddress() || address.isAnyLocalAddress()) { + continue; + } + + return address; + } + } + + throw new RuntimeException("No validated local address!"); + } + + /** + * Retrieve local address + * + * @return the string local address + */ + public static String getLocalAddress() { + return localAddress.getHostAddress(); + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/utils/PaddedAtomicLong.java b/backend/framework/src/main/java/cn/cordys/common/uid/utils/PaddedAtomicLong.java new file mode 100644 index 0000000..062013d --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/utils/PaddedAtomicLong.java @@ -0,0 +1,39 @@ +package cn.cordys.common.uid.utils; + +import java.io.Serial; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Represents a padded {@link AtomicLong} to prevent the FalseSharing problem

    + *

    + * The CPU cache line commonly be 64 bytes, here is a sample of cache line after padding:
    + * 64 bytes = 8 bytes (object reference) + 6 * 8 bytes (padded long) + 8 bytes (a long value) + */ +public class PaddedAtomicLong extends AtomicLong { + @Serial + private static final long serialVersionUID = -3415778863941386253L; + + /** + * Padded 6 long (48 bytes) + */ + public volatile long p1, p2, p3, p4, p5, p6 = 7L; + + /** + * Constructors from {@link AtomicLong} + */ + public PaddedAtomicLong() { + super(); + } + + public PaddedAtomicLong(long initialValue) { + super(initialValue); + } + + /** + * To prevent GC optimizations for cleaning unused padded references + */ + public long sumPaddingToPreventOptimization() { + return p1 + p2 + p3 + p4 + p5 + p6; + } + +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/utils/ValuedEnum.java b/backend/framework/src/main/java/cn/cordys/common/uid/utils/ValuedEnum.java new file mode 100644 index 0000000..c9bd9fd --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/utils/ValuedEnum.java @@ -0,0 +1,10 @@ +package cn.cordys.common.uid.utils; + +/** + * {@code ValuedEnum} defines an enumeration which is bounded to a value, you + * may implements this interface when you defines such kind of enumeration, that + * you can use {@link EnumUtils} to simplify parse and valueOf operation. + */ +public interface ValuedEnum { + T value(); +} diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/worker/WorkerIdAssigner.java b/backend/framework/src/main/java/cn/cordys/common/uid/worker/WorkerIdAssigner.java new file mode 100644 index 0000000..6522b36 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/worker/WorkerIdAssigner.java @@ -0,0 +1,17 @@ +package cn.cordys.common.uid.worker; + +import cn.cordys.common.uid.impl.DefaultUidGenerator; + +/** + * Represents a worker id assigner for {@link DefaultUidGenerator} + */ +public interface WorkerIdAssigner { + + /** + * Assign worker id for {@link DefaultUidGenerator} + * + * @return assigned worker id + */ + long assignWorkerId(); + +} diff --git a/backend/framework/src/main/java/cn/cordys/common/uid/worker/WorkerNodeType.java b/backend/framework/src/main/java/cn/cordys/common/uid/worker/WorkerNodeType.java new file mode 100644 index 0000000..770e949 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/uid/worker/WorkerNodeType.java @@ -0,0 +1,31 @@ +package cn.cordys.common.uid.worker; + +import cn.cordys.common.uid.utils.ValuedEnum; + +/** + * WorkerNodeType + *

  • CONTAINER: Such as Docker + *
  • ACTUAL: Actual machine + */ +public enum WorkerNodeType implements ValuedEnum { + + CONTAINER(1), ACTUAL(2); + + /** + * Lock type + */ + private final Integer type; + + /** + * Constructor with field of type + */ + WorkerNodeType(Integer type) { + this.type = type; + } + + @Override + public Integer value() { + return type; + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/BeanUtils.java b/backend/framework/src/main/java/cn/cordys/common/util/BeanUtils.java new file mode 100644 index 0000000..ef62dd3 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/BeanUtils.java @@ -0,0 +1,115 @@ +package cn.cordys.common.util; + +import org.apache.commons.lang3.StringUtils; + +import java.lang.reflect.Method; + +/** + * BeanUtils 提供了用于操作 Java Bean 的工具方法,包括属性复制、反射获取和设置属性值等。 + */ +public class BeanUtils { + + /** + * 复制源对象的属性到目标对象。 + * + * @param target 目标对象 + * @param source 源对象 + * @param 目标对象类型 + * + * @return 目标对象 + * + * @throws RuntimeException 如果复制过程失败,抛出运行时异常 + */ + public static T copyBean(T target, Object source) { + try { + org.springframework.beans.BeanUtils.copyProperties(source, target); + return target; + } catch (Exception e) { + throw new RuntimeException("Failed to copy object: ", e); + } + } + + /** + * 复制源对象的属性到目标对象,并可以指定忽略的属性。 + * + * @param target 目标对象 + * @param source 源对象 + * @param ignoreProperties 要忽略的属性名称 + * @param 目标对象类型 + * + * @return 目标对象 + * + * @throws RuntimeException 如果复制过程失败,抛出运行时异常 + */ + public static T copyBean(T target, Object source, String... ignoreProperties) { + try { + org.springframework.beans.BeanUtils.copyProperties(source, target, ignoreProperties); + return target; + } catch (Exception e) { + throw new RuntimeException("Failed to copy object: ", e); + } + } + + /** + * 根据字段名获取 Java Bean 的属性值。 + * + * @param fieldName 字段名称 + * @param bean Java Bean 对象 + * + * @return 字段的值,如果获取失败则返回 null + */ + public static Object getFieldValueByName(String fieldName, Object bean) { + try { + if (StringUtils.isBlank(fieldName)) { + return null; + } + String getter = "get" + StringUtils.capitalize(fieldName); + Method method = bean.getClass().getMethod(getter); + return method.invoke(bean); + } catch (Exception e) { + return null; + } + } + + /** + * 根据字段名和类型设置 Java Bean 的属性值。 + * + * @param bean Java Bean 对象 + * @param fieldName 字段名称 + * @param value 要设置的值 + * @param type 字段类型 + */ + public static void setFieldValueByName(Object bean, String fieldName, Object value, Class type) { + try { + if (StringUtils.isBlank(fieldName)) { + return; + } + String setter = "set" + StringUtils.capitalize(fieldName); + Method method = bean.getClass().getMethod(setter, type); + method.invoke(bean, value); + } catch (Exception ignore) { + // 可以根据需求记录日志或进行其他处理 + } + } + + /** + * 根据字段名和类型获取 Java Bean 的 setter 方法。 + * + * @param bean Java Bean 对象 + * @param fieldName 字段名称 + * @param type 字段类型 + * + * @return setter 方法,如果获取失败则返回 null + */ + public static Method getMethod(Object bean, String fieldName, Class type) { + try { + if (StringUtils.isBlank(fieldName)) { + return null; + } + String setter = "set" + StringUtils.capitalize(fieldName); + return bean.getClass().getMethod(setter, type); + } catch (Exception e) { + return null; + } + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/BigDecimalNoTrailingZeroSerializer.java b/backend/framework/src/main/java/cn/cordys/common/util/BigDecimalNoTrailingZeroSerializer.java new file mode 100644 index 0000000..74ace70 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/BigDecimalNoTrailingZeroSerializer.java @@ -0,0 +1,16 @@ +package cn.cordys.common.util; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +import java.io.IOException; +import java.math.BigDecimal; + +public class BigDecimalNoTrailingZeroSerializer extends JsonSerializer { + @Override + public void serialize(BigDecimal value, JsonGenerator gen, SerializerProvider provider) throws IOException { + // 去除末尾零并转换为普通字符串 + gen.writeNumber(value.stripTrailingZeros().toPlainString()); + } +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/common/util/CaseFormatUtils.java b/backend/framework/src/main/java/cn/cordys/common/util/CaseFormatUtils.java new file mode 100644 index 0000000..cd578ba --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/CaseFormatUtils.java @@ -0,0 +1,70 @@ +package cn.cordys.common.util; + +/** + * @Author: jianxing + * @CreateTime: 2025-07-11 10:58 + */ +public class CaseFormatUtils { + /** + * 将驼峰字符串转换为下划线分隔的字符串。 + * + * @param camelStr 驼峰字符串 + * + * @return 下划线字符串 + */ + public static String camelToUnderscore(String camelStr) { + return convertCamelToSeparator(camelStr, '_'); + } + + /** + * 将驼峰字符串转换为指定分隔符的字符串。 + * + * @param camelStr 驼峰字符串 + * @param separator 分隔符 + * + * @return 分隔符字符串 + */ + public static String convertCamelToSeparator(String camelStr, char separator) { + if (camelStr == null || camelStr.trim().isEmpty()) { + return camelStr; + } + StringBuilder result = new StringBuilder(); + char[] chars = camelStr.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + if (Character.isUpperCase(c)) { + if (i > 0) { + result.append(separator); + } + result.append(Character.toLowerCase(c)); + } else { + result.append(c); + } + } + return result.toString(); + } + + /** + * 将字符串首字母转换为大写 + * + * @param input 输入字符串 + * + * @return 首字母大写的字符串 + */ + public static String capitalizeFirstLetter(String input) { + if (input == null || input.isEmpty()) { + return input; + } + + // 提取第一个字符并转换为大写 + char firstChar = Character.toUpperCase(input.charAt(0)); + + // 如果字符串只有一个字符,直接返回 + if (input.length() == 1) { + return String.valueOf(firstChar); + } + + // 拼接剩余字符 + return firstChar + input.substring(1); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/CodingUtils.java b/backend/framework/src/main/java/cn/cordys/common/util/CodingUtils.java new file mode 100644 index 0000000..2c5e240 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/CodingUtils.java @@ -0,0 +1,237 @@ +package cn.cordys.common.util; + +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * 加密解密工具类,提供 MD5、BASE64 和 AES 加密解密操作。 + * 支持常见的加密解密算法,简化了加密过程。 + */ +public class CodingUtils { + + private static final String UTF_8 = "UTF-8"; + private static final char[] HEX_DIGITS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}; + /** + * 加密偏移量,AES 加密的初始化向量。 + */ + private static final String GCM_IV = "1Av7hf9PgHusUHRm"; + private static final int GCM_TAG_LENGTH = 128; // GCM 标签长度(以位为单位) + + /** + * MD5加密(默认UTF-8字符集) + * + * @param src 要加密的字符串 + * + * @return 加密后的MD5字符串 + */ + public static String md5(String src) { + return md5(src, UTF_8); + } + + /** + * MD5加密 + * + * @param src 要加密的字符串 + * @param charset 使用的字符集 + * + * @return 加密后的MD5字符串 + */ + public static String md5(String src, String charset) { + if (StringUtils.isBlank(src)) { + throw new IllegalArgumentException("Input for MD5 cannot be null or empty"); + } + + try { + byte[] strTemp = src.getBytes(StringUtils.defaultIfBlank(charset, UTF_8)); + MessageDigest mdTemp = MessageDigest.getInstance("MD5"); + mdTemp.update(strTemp); + + byte[] md = mdTemp.digest(); + char[] str = new char[md.length * 2]; + int k = 0; + + for (byte byte0 : md) { + str[k++] = HEX_DIGITS[(byte0 >>> 4) & 0xf]; + str[k++] = HEX_DIGITS[byte0 & 0xf]; + } + + return new String(str); + } catch (Exception e) { + throw new RuntimeException("MD5 encrypt error:", e); + } + } + + /** + * BASE64加密 + * + * @param src 待加密的字符串 + * + * @return 加密后的字符串 + */ + public static String base64Encoding(String src) { + if (StringUtils.isBlank(src)) { + throw new IllegalArgumentException("Input for BASE64 encoding cannot be null or empty"); + } + + try { + return Base64.encodeBase64String(src.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new RuntimeException("BASE64 encoding error:", e); + } + } + + /** + * AES-GCM加密 + * + * @param src 待加密的字符串 + * @param secretKey 加密密钥(16字节) + * @param iv 初始向量(12字节) + * + * @return 加密后的字符串 + */ + public static String aesEncrypt(String src, String secretKey, byte[] iv) { + if (StringUtils.isBlank(src) || StringUtils.isBlank(secretKey)) { + throw new IllegalArgumentException("Input or secretKey cannot be null or empty"); + } + + try { + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "AES"); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec); + + byte[] encryptedBytes = cipher.doFinal(src.getBytes(StandardCharsets.UTF_8)); + return Base64.encodeBase64String(encryptedBytes); + } catch (Exception e) { + throw new RuntimeException("AES-GCM encrypt error:", e); + } + } + + /** + * AES-GCM解密 + * + * @param src 待解密的字符串 + * @param secretKey 解密密钥(16字节) + * @param iv 初始向量(12字节) + * + * @return 解密后的字符串 + */ + public static String aesDecrypt(String src, String secretKey, byte[] iv) { + if (StringUtils.isBlank(src) || StringUtils.isBlank(secretKey)) { + throw new IllegalArgumentException("Input or secretKey cannot be null or empty"); + } + + try { + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "AES"); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); + + byte[] decodedBytes = Base64.decodeBase64(src); + byte[] decryptedBytes = cipher.doFinal(decodedBytes); + return new String(decryptedBytes, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new RuntimeException("AES-GCM decrypt error:", e); + } + } + + /** + * 生成一个新的AES密钥 + * + * @return 生成的AES密钥(Base64编码) + */ + public static String generateSecretKey() { + try { + KeyGenerator keyGen = KeyGenerator.getInstance("AES"); + keyGen.init(128); + SecretKey secretKey = keyGen.generateKey(); + return bytesToHex(secretKey.getEncoded()); + } catch (Exception e) { + throw new RuntimeException("Generate AES secret key error:", e); + } + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + public static String generateAK() { + // 生成长度为 20 的随机字母数字字符串 + return RandomStringUtils.secure().nextAlphabetic(16); + } + + /** + * 生成随机IV(用于AES-GCM) + * + * @return 随机生成的IV + */ + public static byte[] generateIv() { + return GCM_IV.getBytes(); + } + + /** + * 计算字符串的哈希值(SHA-256)并返回其前16个字符的十六进制表示 + * + * @param str 需要计算哈希值的字符串 + * + * @return 字符串的哈希值(16个字符的十六进制表示) + */ + public static String hashStr(String str) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(str.getBytes(StandardCharsets.UTF_8)); + + // 转换为十六进制字符串 + char[] hexChars = new char[digest.length * 2]; + int k = 0; + for (byte b : digest) { + hexChars[k++] = HEX_DIGITS[(b >>> 4) & 0xf]; + hexChars[k++] = HEX_DIGITS[b & 0xf]; + } + + // 只使用前16个字符,平衡长度和唯一性 + return new String(hexChars, 0, Math.min(16, hexChars.length)); + } catch (NoSuchAlgorithmException e) { + // 降级方案:使用多种哈希组合减少冲突 + return str.hashCode() + "-" + str.length(); + } + } + + public static String aesCBCEncrypt(String src, String secretKey, String iv) { + if (StringUtils.isBlank(src)) { + return src; + } + if (StringUtils.isBlank(secretKey)) { + throw new IllegalArgumentException("Input or secretKey cannot be null or empty"); + } + try { + byte[] raw = secretKey.getBytes(StandardCharsets.UTF_8); + SecretKeySpec secretKeySpec = new SecretKeySpec(raw, "AES"); + // "算法/模式/补码方式" ECB + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + IvParameterSpec iv1 = new IvParameterSpec(iv.getBytes()); + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, iv1); + byte[] encrypted = cipher.doFinal(src.getBytes(StandardCharsets.UTF_8)); + return Base64.encodeBase64String(encrypted); + } catch (Exception e) { + throw new RuntimeException("AES encrypt error:", e); + } + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/CommonBeanFactory.java b/backend/framework/src/main/java/cn/cordys/common/util/CommonBeanFactory.java new file mode 100644 index 0000000..af57bed --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/CommonBeanFactory.java @@ -0,0 +1,197 @@ +package cn.cordys.common.util; + +import jakarta.servlet.Filter; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.RegexPatternTypeFilter; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Pattern; + +/** + * 通用的Spring Bean工厂,用于从Spring容器中获取Bean并调用方法。 + * 实现了ApplicationContextAware接口,以便在需要时获取Spring的应用上下文。 + */ +@Component +@Slf4j +public class CommonBeanFactory implements ApplicationContextAware { + + public static String BASE_X_P = "cn.cordys.xpack"; + // 保存ApplicationContext实例 + private static ApplicationContext context; + + /** + * 根据Bean名称获取Bean实例 + * + * @param beanName Bean的名称 + * + * @return 返回Bean实例,若未找到则返回null + */ + public static Object getBean(String beanName) { + try { + // 如果上下文或Bean名称为空,则返回null + if (context != null && StringUtils.isNotBlank(beanName)) { + return context.getBean(beanName); + } + } catch (BeansException e) { + // 捕获Spring的异常并返回null + return null; + } + return null; + } + + /** + * 根据Bean类型获取Bean实例 + * + * @param className Bean的类型 + * @param 返回的Bean类型 + * + * @return 返回Bean实例,若未找到则返回null + */ + public static T getBean(Class className) { + try { + // 如果上下文或类型为空,则返回null + if (context != null && className != null) { + return context.getBean(className); + } + } catch (BeansException e) { + // 捕获Spring的异常并返回null + return null; + } + return null; + } + + /** + * 获取指定类型的所有Bean实例 + * + * @param className Bean的类型 + * @param 返回的Bean类型 + * + * @return 返回所有类型为className的Bean实例的Map + */ + public static Map getBeansOfType(Class className) { + return context.getBeansOfType(className); + } + + /** + * 调用指定Bean的方法 + * + * @param beanName Bean的名称 + * @param methodFunction 方法选择器函数,接受Bean的类类型并返回一个Method对象 + * @param args 方法调用的参数 + * + * @return 返回方法的执行结果,若发生异常则返回null + */ + public static Object invoke(String beanName, Function, Method> methodFunction, Object... args) { + try { + Object bean = getBean(beanName); + // 检查bean是否存在 + if (ObjectUtils.isNotEmpty(bean)) { + Class clazz = bean.getClass(); + // 使用提供的methodFunction来获取方法并执行 + Method method = methodFunction.apply(clazz); + if (method != null) { + return method.invoke(bean, args); + } + } + } catch (Exception e) { + // 记录错误日志 + log.error(e.getMessage(), e); + } + return null; + } + + public static boolean packageExists(String basePackage) { + // 不使用默认的候选者过滤器(不只扫描 @Component) + ClassPathScanningCandidateComponentProvider scanner = + new ClassPathScanningCandidateComponentProvider(false); + + // 添加一个总是匹配的过滤器:只要有类就算命中 + scanner.addIncludeFilter(new RegexPatternTypeFilter(Pattern.compile(".*"))); + + // 按包路径扫描 + Set defs = scanner.findCandidateComponents(basePackage); + return !defs.isEmpty(); + } + + public static boolean packageExists() { + return packageExists(BASE_X_P); + } + + public static Filter getFilter() { + try { + if (!packageExists()) { + return null; + } + final Class clazz = Class.forName(BASE_X_P + ".crm.ApiKeyPreFilter").asSubclass(Filter.class); + return clazz.getDeclaredConstructor().newInstance(); + } catch (Exception ignored) { + return null; + } + } + + public static String getUser(HttpServletRequest request) { + if (packageExists()) { + Object user = CommonBeanFactory.invoke("extLicenseService", + clazz -> { + try { + return clazz.getMethod("getUser", HttpServletRequest.class); + } catch (NoSuchMethodException e) { + return null; + } + }, request); + if (user != null) { + return String.valueOf(user); + } + } + return null; + } + + /** + * 调用指定类的静态方法 + * + * @param className 类的全名,例如 "com.example.MyClass" + * @param methodName 方法名 + * @param parameterTypes 方法参数类型 + * @param args 方法参数 + * + * @return 方法返回值 + */ + public static void invokeStatic(String className, String methodName, Class[] parameterTypes, Object... args) { + try { + // 1. 加载类 + Class clazz = Class.forName(className); + + // 2. 获取方法 + Method method = clazz.getMethod(methodName, parameterTypes); + + // 3. 调用静态方法,第一个参数传 null + method.invoke(null, args); + + } catch (Exception ignored) { + } + } + + /** + * 设置ApplicationContext,Spring容器会自动注入上下文。 + * + * @param ctx 当前的Spring应用上下文 + * + * @throws BeansException 如果出现错误 + */ + public void setApplicationContext(ApplicationContext ctx) throws BeansException { + context = ctx; + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/CompressUtils.java b/backend/framework/src/main/java/cn/cordys/common/util/CompressUtils.java new file mode 100644 index 0000000..899137c --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/CompressUtils.java @@ -0,0 +1,190 @@ +package cn.cordys.common.util; + +import org.apache.commons.codec.binary.Base64; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.zip.*; + +public class CompressUtils { + + private CompressUtils() { + } + + /*** + * Zip压缩 + * + * @param data 待压缩数据 + * @return 压缩后数据 + */ + public static Object zip(Object data) { + if (!(data instanceof byte[] temp)) { + return data; + } + + try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(bos)) { + ZipEntry entry = new ZipEntry("zip"); + zip.putNextEntry(entry); + zip.write(temp); + zip.closeEntry(); + return bos.toByteArray(); + } catch (Exception e) { + return data; + } + } + + + private static File getFile(String filePath) throws IOException { + // 创建文件对象 + File file = new File(filePath); + + // 如果文件不存在,则尝试创建 + if (!file.exists() && !file.createNewFile()) { + throw new IOException("Failed to create the file: " + filePath); + } + + // 返回文件 + return file; + } + + public static FileOutputStream getFileStream(File file) throws FileNotFoundException { + return new FileOutputStream(file); + } + + private static void zipFile(File file, ZipOutputStream zipOutputStream) throws IOException { + if (file.exists() && file.isFile()) { + try (FileInputStream fis = new FileInputStream(file); + BufferedInputStream bis = new BufferedInputStream(fis)) { + ZipEntry entry = new ZipEntry(file.getName()); + zipOutputStream.putNextEntry(entry); + + // 使用固定大小的缓冲区 + final int BUFFER_SIZE = 10 * 1024 * 1024; // 10MB + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + + // 循环读取数据并写入压缩流 + while ((bytesRead = bis.read(buffer)) != -1) { + zipOutputStream.write(buffer, 0, bytesRead); + } + + zipOutputStream.closeEntry(); // 关闭当前 ZipEntry + } + } + } + + + /** + * 将多个文件压缩 + * + * @param zipFilePath 压缩文件所在路径 + * @param fileList 要压缩的文件 + */ + public static File zipFiles(String zipFilePath, List fileList) throws IOException { + File zipFile = getFile(zipFilePath); + // 文件输出流 + FileOutputStream outputStream = getFileStream(zipFile); + // 压缩流 + ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream); + + // 压缩列表中的文件 + for (File file : fileList) { + zipFile(file, zipOutputStream); + } + // 关闭压缩流、文件流 + zipOutputStream.close(); + outputStream.close(); + return zipFile; + } + + /** + * 将多个文件压缩至指定路径 + * + * @param fileList 待压缩的文件列表 + * @param zipFilePath 压缩文件路径 + * + * @return 返回压缩好的文件 + */ + public static File zipFilesToPath(String zipFilePath, List fileList) throws IOException { + File zipFile = new File(zipFilePath); + + // 创建输出流和压缩流 + try (FileOutputStream outputStream = new FileOutputStream(zipFile); + ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { + + // 遍历文件列表进行压缩 + for (File file : fileList) { + if (file != null && file.exists() && file.isFile()) { + zipFile(file, zipOutputStream); + } + } + } + + return zipFile; + } + + + /*** + * Zip解压 + * + * @param data 待解压数据 + * @return 解压后数据 + */ + public static Object unzip(Object data) { + if (!(data instanceof byte[] temp)) { + return data; + } + + try (ByteArrayInputStream bis = new ByteArrayInputStream(temp); + ZipInputStream zip = new ZipInputStream(bis)) { + + ByteArrayOutputStream bas = new ByteArrayOutputStream(); + + // 处理压缩包中的每个条目 + while (zip.getNextEntry() != null) { + byte[] buf = new byte[1024]; + int num; + + // 读取数据到缓冲区并写入输出流 + while ((num = zip.read(buf)) != -1) { + bas.write(buf, 0, num); + } + } + + return bas.toByteArray(); + } catch (Exception e) { + return data; + } + } + + + public static Object zipString(Object data) { + if (!(data instanceof String)) { + return data; + } + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + try (DeflaterOutputStream deflaterOutputStream = new DeflaterOutputStream(out)) { + deflaterOutputStream.write(((String) data).getBytes(StandardCharsets.UTF_8)); + } + return Base64.encodeBase64String(out.toByteArray()); + } catch (Exception e) { + return data; + } + } + + public static Object unzipString(Object data) { + if (!(data instanceof String)) { + return data; + } + try (ByteArrayOutputStream os = new ByteArrayOutputStream()) { + try (OutputStream outputStream = new InflaterOutputStream(os)) { + outputStream.write(Base64.decodeBase64((String) data)); + } + return os.toString(StandardCharsets.UTF_8); + } catch (Exception e) { + return data; + } + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/EncryptUtils.java b/backend/framework/src/main/java/cn/cordys/common/util/EncryptUtils.java new file mode 100644 index 0000000..f8a03b0 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/EncryptUtils.java @@ -0,0 +1,81 @@ +package cn.cordys.common.util; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 加密解密工具,扩展自CodingUtils,提供AES和MD5的加解密方法。 + */ +public class EncryptUtils extends CodingUtils { + + // 默认的加密密钥和向量 + private static final String secretKey = "www.fit2cloud.cn"; + + /** + * AES加密方法 + * + * @param o 要加密的对象 + * + * @return 加密后的字符串,若传入对象为空则返回null + */ + public static String aesEncrypt(Object o) { + if (o == null) { + return null; + } + return aesEncrypt(o.toString(), secretKey, generateIv()); + } + + /** + * AES解密方法 + * + * @param o 要解密的对象 + * + * @return 解密后的字符串,若传入对象为空则返回null + */ + public static String aesDecrypt(Object o) { + if (o == null) { + return null; + } + return aesDecrypt(o.toString(), secretKey, generateIv()); + } + + /** + * 对列表中的对象属性进行AES解密 + * + * @param o 要解密的对象列表 + * @param attrName 需要解密的属性名 + * @param 对象类型 + * + * @return 解密后的对象列表 + */ + public static Object aesDecrypt(List o, String attrName) { + if (o == null || attrName == null) { + return null; + } + // 对列表中的每个对象属性进行解密 + return o.stream() + .filter(element -> BeanUtils.getFieldValueByName(attrName, element) != null) + .peek(element -> { + Object fieldValue = BeanUtils.getFieldValueByName(attrName, element); + if (fieldValue != null) { + String decryptedValue = aesDecrypt(fieldValue.toString(), secretKey, generateIv()); + BeanUtils.setFieldValueByName(element, attrName, decryptedValue, String.class); + } + }) + .collect(Collectors.toList()); + } + + /** + * MD5加密方法 + * + * @param o 要加密的对象 + * + * @return 加密后的MD5字符串,若传入对象为空则返回null + */ + public static String md5Encrypt(Object o) { + if (o == null) { + return null; + } + return md5(o.toString()); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/FieldConverter.java b/backend/framework/src/main/java/cn/cordys/common/util/FieldConverter.java new file mode 100644 index 0000000..61c49c3 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/FieldConverter.java @@ -0,0 +1,15 @@ +package cn.cordys.common.util; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.text.CaseUtils; + +public class FieldConverter { + + public static String toCamelCaseWithCommons(String snakeCase) { + if (StringUtils.isBlank(snakeCase)) { + return snakeCase; + } + + return CaseUtils.toCamelCase(snakeCase.toLowerCase(), false, '_'); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/HikariCPUtils.java b/backend/framework/src/main/java/cn/cordys/common/util/HikariCPUtils.java new file mode 100644 index 0000000..45b97f1 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/HikariCPUtils.java @@ -0,0 +1,62 @@ +package cn.cordys.common.util; + +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.HikariPoolMXBean; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class HikariCPUtils { + /** + * 获取 HikariCP 连接池的使用情况 + * + * @param dataSource HikariDataSource 实例 + * + * @return HikariCP 连接池状态信息 + */ + public static String getHikariCPStatus(HikariDataSource dataSource) { + if (dataSource == null) { + throw new IllegalArgumentException("HikariDataSource cannot be null"); + } + + HikariPoolMXBean poolMXBean = dataSource.getHikariPoolMXBean(); + + return "HikariCP Status:\n" + + "Active Connections: " + poolMXBean.getActiveConnections() + "\n" + + "Idle Connections: " + poolMXBean.getIdleConnections() + "\n" + + "Total Connections: " + poolMXBean.getTotalConnections() + "\n" + + "Threads Awaiting Connection: " + poolMXBean.getThreadsAwaitingConnection() + "\n"; + } + + /** + * 获取 HikariCP 连接池的配置情况 + * + * @param dataSource HikariDataSource 实例 + * + * @return 连接池配置情况 + */ + public static String getHikariCPConfig(HikariDataSource dataSource) { + if (dataSource == null) { + throw new IllegalArgumentException("HikariDataSource cannot be null"); + } + + return "HikariCP Configuration:\n" + + "Maximum Pool Size: " + dataSource.getMaximumPoolSize() + "\n" + + "Minimum Idle Connections: " + dataSource.getMinimumIdle() + "\n" + + "Connection Timeout: " + dataSource.getConnectionTimeout() + " ms\n" + + "Idle Timeout: " + dataSource.getIdleTimeout() + " ms\n" + + "Max Lifetime: " + dataSource.getMaxLifetime() + " ms\n"; + } + + /** + * 打印 HikariCP 的状态和配置信息 + */ + public static void printHikariCPStatus() { + HikariDataSource dataSource = CommonBeanFactory.getBean(HikariDataSource.class); + if (dataSource == null) { + log.error("HikariDataSource not found"); + return; + } + log.info(getHikariCPStatus(dataSource)); + log.info(getHikariCPConfig(dataSource)); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/JSON.java b/backend/framework/src/main/java/cn/cordys/common/util/JSON.java new file mode 100644 index 0000000..47f5a3b --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/JSON.java @@ -0,0 +1,241 @@ +package cn.cordys.common.util; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.core.json.JsonReadFeature; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.type.CollectionType; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Map; + +/** + * JSON 工具类,封装了常用的 JSON 序列化和反序列化方法。 + * 支持对象与 JSON 字符串、字节数组、集合、映射等类型的相互转换。 + */ +public class JSON { + + // 默认最大字符串长度 + public static final int DEFAULT_MAX_STRING_LEN = Integer.MAX_VALUE; + // ObjectMapper 实例,用于 JSON 操作 + private static final ObjectMapper objectMapper = JsonMapper.builder() + .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS) // 允许 JSON 中未转义的控制字符 + .build(); + private static final TypeFactory typeFactory = objectMapper.getTypeFactory(); + + // 静态初始化块,配置 ObjectMapper + static { + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); // 忽略未知属性 + objectMapper.configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, true); // 使用 BigDecimal 处理浮点数 + objectMapper.configure(JsonParser.Feature.ALLOW_COMMENTS, true); // 允许 JSON 中的注释 + objectMapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); // 自动检测所有类的字段属性 + objectMapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); // 允许序列化空对象 + objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); // 接受单个值作为数组处理 + objectMapper.getFactory() + .setStreamReadConstraints(StreamReadConstraints.builder().maxStringLength(DEFAULT_MAX_STRING_LEN).build()); // 设置读取字符流时的长度限制 + objectMapper.registerModule(new JavaTimeModule()); // 注册 Java 8 时间模块 + } + + /** + * 将对象序列化为 JSON 字符串。 + * + * @param value 需要序列化的对象 + * + * @return JSON 字符串 + */ + public static String toJSONString(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (IOException e) { + throw new RuntimeException("JSON 序列化失败", e); + } + } + + /** + * 将对象序列化为格式化的 JSON 字符串(带缩进)。 + * + * @param value 需要序列化的对象 + * + * @return 格式化的 JSON 字符串 + */ + public static String toFormatJSONString(Object value) { + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); + } catch (IOException e) { + throw new RuntimeException("JSON 序列化失败", e); + } + } + + /** + * 将对象序列化为字节数组。 + * + * @param value 需要序列化的对象 + * + * @return JSON 字节数组 + */ + public static byte[] toJSONBytes(Object value) { + try { + return objectMapper.writeValueAsBytes(value); + } catch (IOException e) { + throw new RuntimeException("JSON 序列化失败", e); + } + } + + /** + * 将 JSON 字符串反序列化为 Java 对象。 + * + * @param content JSON 字符串 + * + * @return 反序列化后的 Java 对象 + */ + public static Object parseObject(String content) { + return parseObject(content, Object.class); + } + + /** + * 将 JSON 字符串反序列化为指定类型的 Java 对象。 + * + * @param content JSON 字符串 + * @param valueType 目标 Java 类 + * @param Java 类的类型 + * + * @return 反序列化后的 Java 对象 + */ + public static T parseObject(String content, Class valueType) { + try { + return objectMapper.readValue(content, valueType); + } catch (IOException e) { + throw new RuntimeException("JSON 反序列化失败", e); + } + } + + /** + * 将 JSON 字符串反序列化为指定类型的 Java 对象。 + * + * @param content JSON 字符串 + * @param valueType 目标 Java 类型引用 + * @param Java 类的类型 + * + * @return 反序列化后的 Java 对象 + */ + public static T parseObject(String content, TypeReference valueType) { + try { + return objectMapper.readValue(content, valueType); + } catch (IOException e) { + throw new RuntimeException("JSON 反序列化失败", e); + } + } + + /** + * 将输入流中的 JSON 数据反序列化为 Java 对象。 + * + * @param src 输入流 + * @param valueType 目标 Java 类 + * @param Java 类的类型 + * + * @return 反序列化后的 Java 对象 + */ + public static T parseObject(InputStream src, Class valueType) { + try { + return objectMapper.readValue(src, valueType); + } catch (IOException e) { + throw new RuntimeException("JSON 反序列化失败", e); + } + } + + /** + * 将 JSON 字符串反序列化为 Java 对象的集合。 + * + * @param content JSON 字符串 + * + * @return 反序列化后的集合对象 + */ + public static List parseArray(String content) { + return parseArray(content, Object.class); + } + + /** + * 将 JSON 字符串反序列化为指定类型的 Java 对象的集合。 + * + * @param content JSON 字符串 + * @param valueType 集合元素类型 + * @param 集合元素类型 + * + * @return 反序列化后的集合对象 + */ + public static List parseArray(String content, Class valueType) { + CollectionType javaType = typeFactory.constructCollectionType(List.class, valueType); + try { + return objectMapper.readValue(content, javaType); + } catch (IOException e) { + throw new RuntimeException("JSON 反序列化失败", e); + } + } + + /** + * 将 JSON 字符串反序列化为指定类型的 Java 对象的集合。 + * + * @param content JSON 字符串 + * @param valueType 集合元素类型引用 + * @param 集合元素类型 + * + * @return 反序列化后的集合对象 + */ + public static List parseArray(String content, TypeReference valueType) { + try { + JavaType subType = typeFactory.constructType(valueType); + CollectionType javaType = typeFactory.constructCollectionType(List.class, subType); + return objectMapper.readValue(content, javaType); + } catch (IOException e) { + throw new RuntimeException("JSON 反序列化失败", e); + } + } + + /** + * 将 JSON 字符串反序列化为 Map 对象。 + * + * @param jsonObject JSON 字符串 + * + * @return 反序列化后的 Map 对象 + */ + public static Map parseMap(String jsonObject) { + try { + return objectMapper.readValue(jsonObject, new TypeReference<>() { + }); + } catch (JsonProcessingException e) { + throw new RuntimeException("JSON 反序列化失败", e); + } + } + + public static Map parseToMap(String jsonObject) { + try { + return objectMapper.readValue(jsonObject, new TypeReference<>() { + }); + } catch (JsonProcessingException e) { + throw new RuntimeException("JSON 反序列化失败", e); + } + } + + public static T parseObject(InputStream src, TypeReference valueType) { + try { + return objectMapper.readValue(src, valueType); + } catch (IOException e) { + throw new RuntimeException("JSON 反序列化失败", e); + } + } + + public static final ObjectMapper MAPPER = new ObjectMapper(); + +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/JsonDifferenceUtils.java b/backend/framework/src/main/java/cn/cordys/common/util/JsonDifferenceUtils.java new file mode 100644 index 0000000..6d15793 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/JsonDifferenceUtils.java @@ -0,0 +1,150 @@ +package cn.cordys.common.util; + +import cn.cordys.common.dto.JsonDifferenceDTO; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.NullNode; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +public class JsonDifferenceUtils { + + public static List compareJson(String oldJson, String newJson, List JsonDifferenceDTO) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + JsonNode oldNode = oldJson != null ? mapper.readTree(oldJson) : mapper.createObjectNode(); + JsonNode newNode = newJson != null ? mapper.readTree(newJson) : mapper.createObjectNode(); + if (oldNode.isArray() && newNode.isArray()) { + compareArrayNodesById(oldNode, newNode, JsonDifferenceDTO); + } + compareJsonNodes(oldNode, newNode, JsonDifferenceDTO); + return JsonDifferenceDTO; + } + + private static void compareJsonNodes(JsonNode oldNode, JsonNode newNode, List JsonDifferenceDTOList) { + Iterator fieldNames1 = oldNode.fieldNames(); + //遍历 + while (fieldNames1.hasNext()) { + String fieldName = fieldNames1.next(); + JsonNode oldValue = oldNode.get(fieldName); + JsonNode newValue = newNode.get(fieldName); + if ((newValue instanceof NullNode || newValue == null) && (oldValue instanceof NullNode || newValue == null)) { + // 都是 null 的情况 + continue; + } + if (!newNode.has(fieldName)) { + //删除的属性 + JsonDifferenceDTO removed = new JsonDifferenceDTO(); + removed.setColumn(fieldName); + removed.setOldValue(getValue(oldValue)); + removed.setType("removed"); + JsonDifferenceDTOList.add(removed); + } else if (!isNodeEquals(oldValue, newValue)) { + //更新的属性 + JsonDifferenceDTO diff = new JsonDifferenceDTO(); + diff.setColumn(fieldName); + diff.setOldValue(getValue(oldValue)); + diff.setNewValue(getValue(newValue)); + diff.setType("modified"); + JsonDifferenceDTOList.add(diff); + } + } + + //遍历查找新增的属性 + Iterator fieldNames2 = newNode.fieldNames(); + while (fieldNames2.hasNext()) { + String fieldName = fieldNames2.next(); + if (!oldNode.has(fieldName)) { + JsonDifferenceDTO add = new JsonDifferenceDTO(); + add.setColumn(fieldName); + add.setNewValue(getValue(newNode.get(fieldName))); + add.setType("add"); + JsonDifferenceDTOList.add(add); + } + } + } + + private static boolean isNodeEquals(JsonNode oldValue, JsonNode newValue) { + if (oldValue.isNumber() && newValue.isNumber()) { + // 避免小数点,科学计数法等格式导致的比较不一致 + return oldValue.asDouble() == newValue.asDouble(); + } + // 数字与字符串的跨类型比较(如计算字段: 99 vs "99") + if (oldValue.isNumber() && newValue.isTextual()) { + return isNumericTextEqual(oldValue, newValue); + } + if (oldValue.isTextual() && newValue.isNumber()) { + return isNumericTextEqual(newValue, oldValue); + } + return oldValue.equals(newValue); + } + + /** + * 比较数值节点与文本节点是否相等 + * 解决计算字段等场景中,同一值因类型不同(数字 vs 字符串)导致的误判 + * + * @param numberNode 数值节点 + * @param textNode 文本节点 + * @return 是否相等 + */ + private static boolean isNumericTextEqual(JsonNode numberNode, JsonNode textNode) { + try { + String text = textNode.asText(); + if (text.contains(".") || text.contains("e") || text.contains("E")) { + return numberNode.asDouble() == Double.parseDouble(text); + } + return numberNode.asLong() == Long.parseLong(text); + } catch (NumberFormatException e) { + return false; + } + } + + /** + * 比较数组节点 (ID作为节点唯一性) + * + * @param oldNode 旧节点 + * @param newNode 新节点 + * @param jsonDifferenceDTO 差异属性集合 [节点1:属性1:值1 => 值2] + */ + private static void compareArrayNodesById(JsonNode oldNode, JsonNode newNode, List jsonDifferenceDTO) { + List oldNodeIds = new ArrayList<>(); + Iterator elements = oldNode.elements(); + while (elements.hasNext()) { + JsonNode oldElement = elements.next(); + boolean found = false; + oldNodeIds.add(oldElement.get("id").asText()); + for (JsonNode newElement : newNode) { + if (newElement.get("id").equals(oldElement.get("id"))) { + compareJsonNodes(oldElement, newElement, jsonDifferenceDTO); + found = true; + break; + } + } + if (!found) { + JsonDifferenceDTO removed = new JsonDifferenceDTO(); + removed.setOldValue(oldElement.get("name")); + removed.setType("removed"); + jsonDifferenceDTO.add(removed); + } + } + + for (JsonNode newElement : newNode) { + if (!oldNodeIds.contains(newElement.get("id").asText())) { + JsonDifferenceDTO added = new JsonDifferenceDTO(); + added.setNewValue(newElement.get("name")); + added.setType("add"); + jsonDifferenceDTO.add(added); + } + } + } + + public static Object getValue(JsonNode jsonNode) { + if (jsonNode.isArray() || jsonNode.isObject()) { + return JSON.parseObject(jsonNode.toString()); + } else { + return jsonNode.asText(); + } + } + +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/common/util/KMeansUtils.java b/backend/framework/src/main/java/cn/cordys/common/util/KMeansUtils.java new file mode 100644 index 0000000..6e65e3c --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/KMeansUtils.java @@ -0,0 +1,130 @@ +package cn.cordys.common.util; + +import java.util.*; + +/** + * 该类实现了 K-means 聚类算法的核心功能,包括数据点分配、质心更新等操作。 + * 它提供了基于输入数据集和指定簇数进行 K-means 聚类的工具方法。 + */ +public class KMeansUtils { + + /** + * 执行 K-means 聚类算法。 + * + * @param data 输入的数据集,每个数据点是一个特征向量。 + * @param k 聚类的簇数。 + * + * @return 包含每个数据点所属簇的列表,元素值为簇的索引。 + */ + public static List performKMeans(List data, int k) { + int maxIterations = 100; + List centroids = initializeCentroids(data, k); + List clusters = new ArrayList<>(Collections.nCopies(data.size(), -1)); + + for (int iter = 0; iter < maxIterations; iter++) { + boolean converged = true; + + // 第 1 步:将数据点分配给最近的质心 + for (int i = 0; i < data.size(); i++) { + int closestCentroid = findClosestCentroid(data.get(i), centroids); + if (clusters.get(i) != closestCentroid) { + clusters.set(i, closestCentroid); + converged = false; + } + } + + // 第 2 步:重新计算质心 + for (int j = 0; j < k; j++) { + double[] newCentroid = recalculateCentroid(data, clusters, j); + if (!Arrays.equals(newCentroid, centroids.get(j))) { + centroids.set(j, newCentroid); + converged = false; + } + } + + if (converged) break; + } + + return clusters; + } + + /** + * 随机初始化 K 个质心。 + * + * @param data 输入的数据集,每个数据点是一个特征向量。 + * @param k 聚类的簇数。 + * + * @return 初始化的 K 个质心。 + */ + private static List initializeCentroids(List data, int k) { + List centroids = new ArrayList<>(); + Random random = new Random(); + for (int i = 0; i < k; i++) { + centroids.add(data.get(random.nextInt(data.size()))); + } + return centroids; + } + + /** + * 计算一个数据点到所有质心的欧几里得距离,并返回距离最近的质心的索引。 + * + * @param point 输入的数据点。 + * @param centroids 所有的质心列表。 + * + * @return 最近质心的索引。 + */ + private static int findClosestCentroid(double[] point, List centroids) { + double minDistance = Double.MAX_VALUE; + int closestCentroid = -1; + for (int i = 0; i < centroids.size(); i++) { + double distance = euclideanDistance(point, centroids.get(i)); + if (distance < minDistance) { + minDistance = distance; + closestCentroid = i; + } + } + return closestCentroid; + } + + /** + * 计算两个数据点之间的欧几里得距离。 + * + * @param point1 第一个数据点。 + * @param point2 第二个数据点。 + * + * @return 两个数据点之间的欧几里得距离。 + */ + private static double euclideanDistance(double[] point1, double[] point2) { + double sum = 0; + for (int i = 0; i < point1.length; i++) { + sum += Math.pow(point1[i] - point2[i], 2); + } + return Math.sqrt(sum); + } + + /** + * 重新计算给定簇的质心。 + * + * @param data 输入的数据集,每个数据点是一个特征向量。 + * @param clusters 每个数据点所属的簇的索引。 + * @param clusterIndex 当前簇的索引。 + * + * @return 该簇的新质心。 + */ + private static double[] recalculateCentroid(List data, List clusters, int clusterIndex) { + double[] centroid = new double[data.getFirst().length]; + int count = 0; + for (int i = 0; i < data.size(); i++) { + if (clusters.get(i) == clusterIndex) { + for (int j = 0; j < centroid.length; j++) { + centroid[j] += data.get(i)[j]; + } + count++; + } + } + for (int i = 0; i < centroid.length; i++) { + centroid[i] /= count; + } + return centroid; + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/LogisticRegressionUtils.java b/backend/framework/src/main/java/cn/cordys/common/util/LogisticRegressionUtils.java new file mode 100644 index 0000000..2ffb6de --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/LogisticRegressionUtils.java @@ -0,0 +1,104 @@ +package cn.cordys.common.util; + +import java.util.List; + +/** + * 该类提供了用于训练和预测逻辑回归模型的工具方法。 + */ +public class LogisticRegressionUtils { + + // 学习率 + private static final double LEARNING_RATE = 0.01; + + // 最大迭代次数 + private static final int ITERATIONS = 1000; + + /** + * 使用梯度下降法训练逻辑回归模型。 + * + * @param features 输入特征列表,每个元素为一个特征向量。 + * @param labels 对应的标签列表,标签值为 0 或 1。 + * + * @return 训练得到的权重数组。 + */ + public static double[] train(List features, List labels) { + // 初始化权重 + double[] weights = new double[features.getFirst().length]; + + // 进行迭代优化 + for (int i = 0; i < ITERATIONS; i++) { + // 计算梯度 + double[] gradients = computeGradients(features, labels, weights); + // 更新权重 + for (int j = 0; j < weights.length; j++) { + weights[j] -= LEARNING_RATE * gradients[j]; + } + } + return weights; + } + + /** + * 计算给定特征和标签的梯度。 + * + * @param features 输入特征列表,每个元素为一个特征向量。 + * @param labels 对应的标签列表,标签值为 0 或 1。 + * @param weights 当前的模型权重。 + * + * @return 计算得到的梯度数组。 + */ + private static double[] computeGradients(List features, List labels, double[] weights) { + double[] gradients = new double[weights.length]; + for (int i = 0; i < features.size(); i++) { + // 计算预测值 + double prediction = sigmoid(dotProduct(weights, features.get(i))); + int label = labels.get(i); + double error = prediction - label; + + // 计算梯度 + for (int j = 0; j < weights.length; j++) { + gradients[j] += error * features.get(i)[j]; + } + } + return gradients; + } + + /** + * 计算两个向量的点积。 + * + * @param weights 模型的权重向量。 + * @param feature 输入特征向量。 + * + * @return 点积的值。 + */ + private static double dotProduct(double[] weights, double[] feature) { + double sum = 0; + for (int i = 0; i < weights.length; i++) { + sum += weights[i] * feature[i]; + } + return sum; + } + + /** + * Sigmoid 激活函数,将线性输出转化为概率值。 + * + * @param x 输入的线性值。 + * + * @return 转化后的概率值(范围在 0 和 1 之间)。 + */ + private static double sigmoid(double x) { + return 1.0 / (1.0 + Math.exp(-x)); + } + + /** + * 基于训练得到的权重和输入特征进行预测。 + * + * @param weights 训练得到的模型权重。 + * @param feature 输入的特征向量。 + * + * @return 预测结果,返回 1 表示正类,返回 0 表示负类。 + */ + public static int predict(double[] weights, double[] feature) { + double prediction = sigmoid(dotProduct(weights, feature)); + return prediction >= 0.5 ? 1 : 0; + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/NodeSortUtils.java b/backend/framework/src/main/java/cn/cordys/common/util/NodeSortUtils.java new file mode 100644 index 0000000..06a9dc0 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/NodeSortUtils.java @@ -0,0 +1,39 @@ +package cn.cordys.common.util; + + +import cn.cordys.common.dto.NodeSortCountResultDTO; + +public class NodeSortUtils { + //默认节点间隔 + public static final long DEFAULT_NODE_INTERVAL_POS = 4096; + + /** + * 计算排序 + * + * @param previousNodePos 前一个节点的pos 如果没有节点则为-1 + * @param nextNodePos 后一个节点的pos 如果没有节点则为-1 + * + * @return 计算后的num值以及是否需要刷新整棵树的num(如果两个节点之间的num值小于2则需要刷新整棵树的num) + */ + public static NodeSortCountResultDTO countModuleSort(long previousNodePos, long nextNodePos) { + boolean refreshNum = false; + long num; + if (nextNodePos < 0 && previousNodePos < 0) { + num = 0; + } else if (nextNodePos < 0) { + num = previousNodePos + DEFAULT_NODE_INTERVAL_POS; + } else if (previousNodePos < 0) { + num = nextNodePos / 2; + if (num < 2) { + refreshNum = true; + } + } else { + long quantityDifference = (nextNodePos - previousNodePos) / 2; + if (quantityDifference <= 2) { + refreshNum = true; + } + num = previousNodePos + quantityDifference; + } + return new NodeSortCountResultDTO(refreshNum, num); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/OnceInterface.java b/backend/framework/src/main/java/cn/cordys/common/util/OnceInterface.java new file mode 100644 index 0000000..664fecf --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/OnceInterface.java @@ -0,0 +1,14 @@ +package cn.cordys.common.util; + +/** + * 执行单次接口 + * @author song-cc-rock + */ +@FunctionalInterface +public interface OnceInterface { + + /** + * 执行 + */ + void execute(); +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/OnceInterfaceAction.java b/backend/framework/src/main/java/cn/cordys/common/util/OnceInterfaceAction.java new file mode 100644 index 0000000..31098f4 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/OnceInterfaceAction.java @@ -0,0 +1,16 @@ +package cn.cordys.common.util; + +/** + * 执行单次接口 (参数版本) + * @param

    参数类型 + * @author song-cc-rock + */ +@FunctionalInterface +public interface OnceInterfaceAction

    { + + /** + * 执行 (参数) + * @param param 参数 + */ + void execute(P param); +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/ServiceUtils.java b/backend/framework/src/main/java/cn/cordys/common/util/ServiceUtils.java new file mode 100644 index 0000000..872d642 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/ServiceUtils.java @@ -0,0 +1,141 @@ +package cn.cordys.common.util; + +import cn.cordys.common.constants.MoveTypeEnum; +import cn.cordys.common.constants.QuadFunction; +import cn.cordys.common.dto.request.PosRequest; +import cn.cordys.common.exception.GenericException; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.function.Function; + +public class ServiceUtils { + + // 用于排序的pos + public static final int POS_STEP = 4096; + + /** + * 保存资源名称,在处理 NOT_FOUND 异常时,拼接资源名称 + */ + private static final ThreadLocal resourceName = new ThreadLocal<>(); + // 反射元数据缓存,减少重复反射开销 + private static final Map, Accessors> ACCESSOR_CACHE = new ConcurrentHashMap<>(); + + public static String getResourceName() { + return resourceName.get(); + } + + public static void clearResourceName() { + resourceName.remove(); + } + + public static void updatePosFieldByAsc( + PosRequest request, + Class clazz, + String userId, + String resourceType, + Function selectByPrimaryKeyFunc, + QuadFunction getPrePosFunc, + QuadFunction getLastPosFunc, + Consumer updateByPrimaryKeySelectiveFuc) { + updatePosField(request, clazz, userId, resourceType, selectByPrimaryKeyFunc, getPrePosFunc, getLastPosFunc, updateByPrimaryKeySelectiveFuc, true); + } + + public static void updatePosFieldByDesc( + PosRequest request, + Class clazz, + String userId, + String resourceType, + Function selectByPrimaryKeyFunc, + QuadFunction getPrePosFunc, + QuadFunction getLastPosFunc, + Consumer updateByPrimaryKeySelectiveFuc) { + updatePosField(request, clazz, userId, resourceType, selectByPrimaryKeyFunc, getPrePosFunc, getLastPosFunc, updateByPrimaryKeySelectiveFuc, false); + } + + private static void updatePosField( + PosRequest request, + Class clazz, + String userId, + String resourceType, + Function selectByPrimaryKeyFunc, + QuadFunction getPrePosFunc, + QuadFunction getLastPosFunc, + Consumer updateByPrimaryKeySelectiveFuc, + boolean asc) { + try { + Accessors acc = getAccessors(clazz); + + // 获取移动的参考对象 + T target = selectByPrimaryKeyFunc.apply(request.getTargetId()); + if (target == null) { + // 如果参考对象被删除,则不处理 + return; + } + + Long targetPos = (Long) acc.getPos.invoke(target); + boolean moveAfter = MoveTypeEnum.AFTER.name().equals(request.getMoveMode()); + + Long neighborPos; + long pos; + + if (asc) { + if (moveAfter) { + pos = targetPos + POS_STEP; + neighborPos = getLastPosFunc.apply(request.getOrgId(), targetPos, userId, resourceType); + } else { + pos = targetPos - POS_STEP; + neighborPos = getPrePosFunc.apply(request.getOrgId(), targetPos, userId, resourceType); + } + } else { + if (moveAfter) { + pos = targetPos - POS_STEP; + neighborPos = getPrePosFunc.apply(request.getOrgId(), targetPos, userId, resourceType); + } else { + pos = targetPos + POS_STEP; + neighborPos = getLastPosFunc.apply(request.getOrgId(), targetPos, userId, resourceType); + } + } + + if (neighborPos != null) { + // 如果不是第一个或最后一个则取中间值 + pos = (targetPos + neighborPos) / 2; + } + + @SuppressWarnings("unchecked") + T updateObj = (T) acc.newInstance(); + acc.setId.invoke(updateObj, request.getMoveId()); + acc.setPos.invoke(updateObj, pos); + updateByPrimaryKeySelectiveFuc.accept(updateObj); + } catch (Exception e) { + throw new GenericException("更新 pos 字段失败: " + e.getMessage()); + } + } + + private static Accessors getAccessors(Class clazz) { + return ACCESSOR_CACHE.computeIfAbsent(clazz, c -> { + try { + Method getPos = c.getMethod("getPos"); + Method setId = c.getMethod("setId", String.class); + Method setPos = c.getMethod("setPos", Long.class); + Constructor ctor = c.getDeclaredConstructor(); + if (!ctor.canAccess(null)) { + ctor.setAccessible(true); + } + return new Accessors(getPos, setId, setPos, ctor); + } catch (Exception e) { + throw new GenericException("初始化反射元数据失败: " + e.getMessage()); + } + }); + } + + private record Accessors(Method getPos, Method setId, Method setPos, Constructor ctor) { + + Object newInstance() throws Exception { + return ctor.newInstance(); + } + } +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/common/util/ServletUtils.java b/backend/framework/src/main/java/cn/cordys/common/util/ServletUtils.java new file mode 100644 index 0000000..cf0b557 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/ServletUtils.java @@ -0,0 +1,59 @@ +package cn.cordys.common.util; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * 客户端工具类 + */ +public class ServletUtils { + /** + * @param request 请求 + * + * @return ua + */ + public static String getUserAgent(HttpServletRequest request) { + String ua = request.getHeader("User-Agent"); + return ua != null ? ua : ""; + } + + /** + * 获得请求 + * + * @return HttpServletRequest + */ + public static HttpServletRequest getRequest() { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (!(requestAttributes instanceof ServletRequestAttributes)) { + return null; + } + return ((ServletRequestAttributes) requestAttributes).getRequest(); + } + + public static String getUserAgent() { + HttpServletRequest request = getRequest(); + if (request == null) { + return null; + } + return getUserAgent(request); + } + + public static String getRequestHost(HttpServletRequest request) { + String port = ":" + request.getServerPort(); + if (request.getServerPort() == 80 || request.getServerPort() == 443) { + port = ""; + } + return request.getScheme() + "://" + request.getServerName() + port; + } + + public static String getUrl() { + HttpServletRequest request = getRequest(); + if (request == null) { + return null; + } + + return getRequestHost(request); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/SubListUtils.java b/backend/framework/src/main/java/cn/cordys/common/util/SubListUtils.java new file mode 100644 index 0000000..665a43f --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/SubListUtils.java @@ -0,0 +1,31 @@ +package cn.cordys.common.util; + +import org.apache.commons.collections4.CollectionUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +public class SubListUtils { + + public static int DEFAULT_EXPORT_BATCH_SIZE = 500; + public static int DEFAULT_QUERY_BATCH_SIZE = 200; + + /** + * 将较长的数组截断成较短的数组进行批处理 + */ + public static void dealForSubList(List totalList, int batchSize, Consumer> subFunc) { + if (CollectionUtils.isEmpty(totalList)) { + return; + } + List dealList = new ArrayList<>(totalList); + while (dealList.size() > batchSize) { + List subList = dealList.subList(0, batchSize); + subFunc.accept(subList); + dealList = dealList.subList(subList.size(), dealList.size()); + } + if (CollectionUtils.isNotEmpty(dealList)) { + subFunc.accept(dealList); + } + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/TimeUtils.java b/backend/framework/src/main/java/cn/cordys/common/util/TimeUtils.java new file mode 100644 index 0000000..129891d --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/TimeUtils.java @@ -0,0 +1,79 @@ +package cn.cordys.common.util; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.time.DateFormatUtils; +import org.apache.commons.lang3.time.DateUtils; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; + +/** + * DateUtils provides date formatting, parsing + */ +public abstract class TimeUtils extends DateUtils { + /** + * Patterns + */ + public static final String MONTH_PATTERN = "yyyy-MM"; + public static final String DAY_PATTERN = "yyyy-MM-dd"; + public static final String DATETIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; + + + /** + * Parse date by 'yyyy-MM-dd' pattern + */ + public static Date parseByDayPattern(String str) { + return parseDate(str, DAY_PATTERN); + } + + /** + * Parse date without Checked exception + * + * @throws RuntimeException when ParseException occurred + */ + public static Date parseDate(String str, String pattern) { + try { + return parseDate(str, new String[]{pattern}); + } catch (ParseException e) { + throw new RuntimeException(e); + } + } + + + /** + * Format date by 'yyyy-MM-dd HH:mm:ss' pattern + */ + public static String formatByDateTimePattern(Date date) { + return DateFormatUtils.format(date, DATETIME_PATTERN); + } + + public static String getMonthStr(Long timeStamp) { + if (timeStamp == null) { + return StringUtils.EMPTY; + } + SimpleDateFormat dateFormat = new SimpleDateFormat(MONTH_PATTERN); + return dateFormat.format(timeStamp); + } + + public static String getDateStr(Long timeStamp) { + if (timeStamp == null) { + return StringUtils.EMPTY; + } + SimpleDateFormat dateFormat = new SimpleDateFormat(DAY_PATTERN); + return dateFormat.format(timeStamp); + } + + public static String getDateTimeStr(Long timeStamp) { + if (timeStamp == null) { + return null; + } + SimpleDateFormat dateFormat = new SimpleDateFormat(DATETIME_PATTERN); + return dateFormat.format(timeStamp); + } + + public static Long getTodayStart() { + Date startDate = parseDate(DateFormatUtils.format(new Date(), DAY_PATTERN) + " 00:00:00", DATETIME_PATTERN); + return startDate.getTime(); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/Translator.java b/backend/framework/src/main/java/cn/cordys/common/util/Translator.java new file mode 100644 index 0000000..239a72f --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/Translator.java @@ -0,0 +1,76 @@ +package cn.cordys.common.util; + +import jakarta.annotation.Resource; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; + +import java.util.Locale; + +/** + * 翻译工具类,用于从消息源中获取本地化的消息。 + *

    + * 该类提供了根据消息键(key)从消息源中获取翻译的功能。 + *

    + */ +public class Translator { + + private static MessageSource messageSource; + + /** + * 根据给定的消息键获取翻译内容。 + * + * @param key 消息键 + * + * @return 翻译后的消息,如果没有找到对应的消息,则返回 "Not Support Key: " + key + */ + public static String get(String key) { + return messageSource.getMessage(key, null, "Not Support Key: " + key, LocaleContextHolder.getLocale()); + } + + /** + * 根据给定的消息键获取翻译内容,如果没有找到对应的消息,则返回指定的默认消息。 + * + * @param key 消息键 + * @param defaultMessage 默认消息 + * + * @return 翻译后的消息,若未找到则返回默认消息 + */ + public static String get(String key, String defaultMessage) { + return messageSource.getMessage(key, null, defaultMessage, LocaleContextHolder.getLocale()); + } + + /** + * 根据给定的消息键和指定的语言环境获取翻译内容。 + * + * @param key 消息键 + * @param locale 指定的语言环境 + * + * @return 翻译后的消息 + */ + public static String get(String key, Locale locale) { + return messageSource.getMessage(key, null, locale); + } + + /** + * 根据给定的消息键和参数获取翻译内容。 + * 支持格式化参数的插入。 + * + * @param key 消息键 + * @param args 格式化参数 + * + * @return 翻译后的消息 + */ + public static String getWithArgs(String key, Object... args) { + return messageSource.getMessage(key, args, "Not Support Key: " + key, LocaleContextHolder.getLocale()); + } + + /** + * 注入 MessageSource 用于国际化消息处理。 + * + * @param messageSource Spring 的 MessageSource 实例 + */ + @Resource + public void setMessageSource(MessageSource messageSource) { + Translator.messageSource = messageSource; + } +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/rsa/RsaKey.java b/backend/framework/src/main/java/cn/cordys/common/util/rsa/RsaKey.java new file mode 100644 index 0000000..bed09ca --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/rsa/RsaKey.java @@ -0,0 +1,27 @@ +package cn.cordys.common.util.rsa; + +import lombok.Getter; +import lombok.Setter; + +import java.io.Serializable; + +/** + * RSA 密钥对类,封装公钥和私钥。 + *

    + * 该类用于存储 RSA 加密算法中的公钥和私钥,供加密解密操作使用。 + *

    + */ +@Setter +@Getter +public class RsaKey implements Serializable { + + /** + * 公钥,使用 RSA 加密时的公钥部分。 + */ + private String publicKey; + + /** + * 私钥,使用 RSA 解密时的私钥部分。 + */ + private String privateKey; +} diff --git a/backend/framework/src/main/java/cn/cordys/common/util/rsa/RsaUtils.java b/backend/framework/src/main/java/cn/cordys/common/util/rsa/RsaUtils.java new file mode 100644 index 0000000..2c1361b --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/common/util/rsa/RsaUtils.java @@ -0,0 +1,245 @@ +package cn.cordys.common.util.rsa; + +import javax.crypto.Cipher; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +/** + * RSA 加密解密工具类,提供 RSA 算法相关的加密、解密、密钥生成等操作。 + *

    + * 该类支持使用 RSA 公钥和私钥进行加密、解密、密钥生成等操作。 + *

    + */ +public class RsaUtils { + + /** + * 字符编码集 + */ + public static final String CHARSET = StandardCharsets.UTF_8.name(); + + /** + * RSA 算法名称 + */ + public static final String RSA_ALGORITHM = "RSA"; + + /** + * RSA 加密填充方式 + */ + private static final String RSA_CIPHER_TRANSFORMATION_OAEP = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"; + + /** + * RSA 密钥对缓存 + */ + private static RsaKey rsaKey; + + /** + * 获取当前的 RSA 密钥对,如果未创建则生成新的密钥对。 + * + * @return 当前的 RSA 密钥对 + * + * @throws NoSuchAlgorithmException 如果无法生成密钥对 + */ + public static RsaKey getRsaKey() throws NoSuchAlgorithmException { + if (rsaKey == null) { + rsaKey = createKeys(); + } + return rsaKey; + } + + /** + * 设置 RSA 密钥对。 + * + * @param rsaKey RSA 密钥对 + */ + public static void setRsaKey(RsaKey rsaKey) { + RsaUtils.rsaKey = rsaKey; + } + + /** + * 创建一个 1024 位的 RSA 密钥对。 + * + * @return 生成的 RSA 密钥对 + * + * @throws NoSuchAlgorithmException 如果无法生成密钥对 + */ + public static RsaKey createKeys() throws NoSuchAlgorithmException { + return createKeys(1024); + } + + /** + * 创建一个指定大小的 RSA 密钥对。 + * + * @param keySize 密钥大小(例如:1024,2048) + * + * @return 生成的 RSA 密钥对 + * + * @throws NoSuchAlgorithmException 如果无法生成密钥对 + */ + public static RsaKey createKeys(int keySize) throws NoSuchAlgorithmException { + // 创建 RSA 密钥对生成器 + KeyPairGenerator kpg = KeyPairGenerator.getInstance(RSA_ALGORITHM); + kpg.initialize(keySize); + + // 生成密钥对 + KeyPair keyPair = kpg.generateKeyPair(); + + // 获取公钥和私钥并进行 Base64 编码 + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + + String publicKeyStr = Base64.getEncoder().encodeToString(publicKey.getEncoded()); + String privateKeyStr = Base64.getEncoder().encodeToString(privateKey.getEncoded()); + + // 封装密钥对 + RsaKey rsaKey = new RsaKey(); + rsaKey.setPublicKey(publicKeyStr); + rsaKey.setPrivateKey(privateKeyStr); + + return rsaKey; + } + + /** + * 使用私钥对原文进行加密。 + * + * @param originalText 原文 + * @param privateKey 私钥(Base64 编码) + * + * @return 加密后的密文 + * + * @throws NoSuchAlgorithmException 如果加密失败 + */ + public static String privateEncrypt(String originalText, String privateKey) throws Exception { + RSAPrivateKey rsaPrivateKey = getPrivateKey(privateKey); + return privateEncrypt(originalText, rsaPrivateKey); + } + + /** + * 使用私钥对原文进行加密。 + * + * @param originalText 原文 + * @param privateKey 私钥 + * + * @return 加密后的密文 + * + * @throws Exception 如果加密失败 + */ + private static String privateEncrypt(String originalText, RSAPrivateKey privateKey) throws Exception { + Cipher cipher = Cipher.getInstance(RSA_CIPHER_TRANSFORMATION_OAEP); + cipher.init(Cipher.ENCRYPT_MODE, privateKey); + + byte[] encryptedData = rsaSplitCodec(cipher, Cipher.ENCRYPT_MODE, originalText.getBytes(CHARSET), privateKey.getModulus().bitLength()); + return Base64.getEncoder().encodeToString(encryptedData); + } + + /** + * 使用私钥对密文进行解密。 + * + * @param cipherText 密文 + * @param privateKey 私钥(Base64 编码) + * + * @return 解密后的原文 + * + * @throws NoSuchAlgorithmException 如果解密失败 + */ + public static String privateDecrypt(String cipherText, String privateKey) throws Exception { + RSAPrivateKey rsaPrivateKey = getPrivateKey(privateKey); + return privateDecrypt(cipherText, rsaPrivateKey); + } + + /** + * 使用私钥对密文进行解密。 + * + * @param cipherText 密文 + * @param privateKey 私钥 + * + * @return 解密后的原文 + * + * @throws Exception 如果解密失败 + */ + private static String privateDecrypt(String cipherText, RSAPrivateKey privateKey) throws Exception { + Cipher cipher = Cipher.getInstance(RSA_ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, privateKey); + + byte[] decryptedData = rsaSplitCodec(cipher, Cipher.DECRYPT_MODE, Base64.getDecoder().decode(cipherText), privateKey.getModulus().bitLength()); + return new String(decryptedData, CHARSET); + } + + /** + * 获取私钥对象。 + * + * @param privateKey 私钥(Base64 编码) + * + * @return 私钥对象 + * + * @throws NoSuchAlgorithmException 如果无法获取私钥 + */ + private static RSAPrivateKey getPrivateKey(String privateKey) throws NoSuchAlgorithmException { + try { + KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey)); + return (RSAPrivateKey) keyFactory.generatePrivate(keySpec); + } catch (InvalidKeySpecException e) { + throw new RuntimeException("无效的私钥格式", e); + } + } + + /** + * 分块处理加密和解密过程,避免一次性数据过大导致的内存问题。 + * + * @param cipher 加解密器 + * @param opmode 操作模式(加密/解密) + * @param data 待加解密的数据 + * @param keySize 密钥大小 + * + * @return 处理后的数据 + * + * @throws Exception 如果处理过程中发生错误 + */ + private static byte[] rsaSplitCodec(Cipher cipher, int opmode, byte[] data, int keySize) throws Exception { + int maxBlock = (opmode == Cipher.DECRYPT_MODE) ? keySize / 8 : keySize / 8 - 11; + int offset = 0; + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + while (offset < data.length) { + int length = Math.min(maxBlock, data.length - offset); + byte[] buffer = cipher.doFinal(data, offset, length); + out.write(buffer, 0, buffer.length); + offset += length; + } + return out.toByteArray(); + } + } + + // 使用公钥加密 + public static String publicEncrypt(String originalText, String publicKey) throws Exception { + RSAPublicKey rsaPublicKey = getPublicKey(publicKey); + return publicEncrypt(originalText, rsaPublicKey); + } + + private static RSAPublicKey getPublicKey(String publicKey) throws NoSuchAlgorithmException { + try { + KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(Base64.getDecoder().decode(publicKey)); + return (RSAPublicKey) keyFactory.generatePublic(keySpec); + } catch (InvalidKeySpecException e) { + throw new RuntimeException("无效的公钥格式", e); + } + } + + private static String publicEncrypt(String originalText, RSAPublicKey publicKey) throws Exception { + Cipher cipher = Cipher.getInstance(RSA_ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, publicKey); + + byte[] encryptedData = rsaSplitCodec(cipher, Cipher.ENCRYPT_MODE, originalText.getBytes(CHARSET), publicKey.getModulus().bitLength()); + return Base64.getEncoder().encodeToString(encryptedData); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/config/DataAccessConfig.java b/backend/framework/src/main/java/cn/cordys/config/DataAccessConfig.java new file mode 100644 index 0000000..40a742b --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/config/DataAccessConfig.java @@ -0,0 +1,58 @@ +package cn.cordys.config; + +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.DataAccessLayer; +import jakarta.annotation.Resource; +import org.apache.ibatis.session.SqlSession; +import org.springframework.beans.factory.InjectionPoint; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; +import org.springframework.core.ResolvableType; + +import java.util.Objects; + +/** + * 数据访问配置类。 + * 提供 MyBatis 的 {@link BaseMapper} 的动态注入支持。 + */ +@Configuration +public class DataAccessConfig { + + /** + * 注入的 MyBatis {@link SqlSession},用于操作数据库。 + */ + @Resource + private SqlSession sqlSession; + + /** + * 提供泛型 {@link BaseMapper} 的动态实例。 + * 使用 Spring 的原型作用域,每次注入时动态解析泛型类型并实例化。 + * + * @param injectionPoint 当前注入点的信息,用于解析目标泛型类型 + * @param 泛型参数,表示实体类类型 + * + * @return 对应实体类的 {@link BaseMapper} 实例 + * + * @throws IllegalArgumentException 如果注入点的字段类型无法解析 + */ + @Bean + @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) + public BaseMapper simpleBaseMapper(InjectionPoint injectionPoint) { + // 解析注入点字段的泛型类型 + ResolvableType resolved = ResolvableType.forField( + Objects.requireNonNull(injectionPoint.getField(), "InjectionPoint 的字段信息不能为空") + ); + + // 获取泛型参数类型并验证 + @SuppressWarnings("unchecked") + Class parameterClass = (Class) Objects.requireNonNull( + resolved.getGeneric(0).resolve(), + "无法解析泛型参数类型,请确认使用了明确的泛型声明" + ); + + // 返回对应的 BaseMapper 实例 + return DataAccessLayer.with(parameterClass, sqlSession); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/config/I18nConfig.java b/backend/framework/src/main/java/cn/cordys/config/I18nConfig.java new file mode 100644 index 0000000..d2502a5 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/config/I18nConfig.java @@ -0,0 +1,69 @@ +package cn.cordys.config; + +import cn.cordys.common.util.Translator; +import jakarta.validation.Validator; +import org.hibernate.validator.HibernateValidator; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +/** + * 配置类,用于设置国际化和校验相关的Bean。 + *

    + * 该配置类包括: + * 1. 国际化翻译器 (Translator) 的 Bean 配置。 + * 2. 使用 JSR-303 规范的校验器 (Validator),并设置国际化消息源。 + *

    + */ +@Configuration +public class I18nConfig { + + /** + * 创建 Translator Bean,提供国际化的翻译功能。 + *

    + * 该 Bean 仅在没有其他 Translator Bean 的情况下创建。 + *

    + * + * @return Translator 对象 + */ + @Bean + @ConditionalOnMissingBean + public Translator translator() { + return new Translator(); + } + + /** + * 配置 JSR-303 校验的国际化消息源。 + *

    + * 使用 Hibernate Validator 作为校验提供者,并将指定的 MessageSource 作为消息源。 + *

    + * + * @param messageSource 消息源,用于提供国际化的错误信息 + * + * @return 配置好的 LocalValidatorFactoryBean + */ + @Bean + public LocalValidatorFactoryBean localValidatorFactoryBean(MessageSource messageSource) { + LocalValidatorFactoryBean localValidatorFactoryBean = new LocalValidatorFactoryBean(); + localValidatorFactoryBean.setProviderClass(HibernateValidator.class); + localValidatorFactoryBean.setValidationMessageSource(messageSource); + return localValidatorFactoryBean; + } + + /** + * 创建 Validator Bean,用于执行 JSR-303 校验。 + *

    + * 该 Bean 使用 LocalValidatorFactoryBean 作为校验工厂,提供一个验证器实例。 + *

    + * + * @param localValidatorFactoryBean 校验工厂 + * + * @return 校验器实例 + */ + @Bean + public Validator validator(LocalValidatorFactoryBean localValidatorFactoryBean) { + return localValidatorFactoryBean.getValidator(); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/config/MybatisInterceptorConfig.java b/backend/framework/src/main/java/cn/cordys/config/MybatisInterceptorConfig.java new file mode 100644 index 0000000..393e10c --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/config/MybatisInterceptorConfig.java @@ -0,0 +1,96 @@ +package cn.cordys.config; + +import cn.cordys.common.util.EncryptUtils; +import lombok.Getter; +import lombok.Setter; + +/** + * Mybatis 拦截器配置类,主要用于配置 Mybatis 加密和解密拦截器。 + *

    + * 该类用于定义与加密/解密相关的拦截器设置,包括模型类、属性名、拦截器类及方法等。 + *

    + */ +@Getter +@Setter +public class MybatisInterceptorConfig { + + /** + * 模型类的名称。 + */ + private String modelName; + + /** + * 需要拦截的属性名称。 + */ + private String attrName; + + /** + * 需要拦截的属性名称列表。 + */ + private String attrNameForList; + + /** + * 拦截器类的名称。 + */ + private String interceptorClass; + + /** + * 拦截器的方法名。 + */ + private String interceptorMethod; + + /** + * 反操作类(例如解密操作类)的名称。 + */ + private String undoClass; + + /** + * 反操作方法名(例如解密方法)。 + */ + private String undoMethod; + + /** + * 默认构造函数,初始化一个空的拦截器配置。 + */ + public MybatisInterceptorConfig() { + } + + /** + * 用于配置加密拦截器的构造函数。 + *

    + * 默认使用 {@link EncryptUtils} 类的加密和解密方法。 + *

    + * + * @param modelClass 模型类 + * @param attrName 需要加密的属性名称 + */ + public MybatisInterceptorConfig(Class modelClass, String attrName) { + this.modelName = modelClass.getName(); + this.attrName = attrName; + this.interceptorClass = EncryptUtils.class.getName(); + this.interceptorMethod = "aesEncrypt"; + this.undoClass = EncryptUtils.class.getName(); + this.undoMethod = "aesDecrypt"; + } + + /** + * 用于自定义拦截器的构造函数。 + *

    + * 可以通过此构造函数传入自定义的拦截器类和方法。 + *

    + * + * @param modelClass 模型类 + * @param attrName 需要拦截的属性名称 + * @param interceptorClass 自定义拦截器类 + * @param interceptorMethod 自定义拦截器方法 + * @param undoMethod 自定义反操作方法 + */ + public MybatisInterceptorConfig(Class modelClass, String attrName, Class interceptorClass, String interceptorMethod, String undoMethod) { + this.modelName = modelClass.getName(); + this.attrName = attrName; + this.interceptorClass = interceptorClass.getName(); + this.interceptorMethod = interceptorMethod; + this.undoClass = interceptorClass.getName(); + this.undoMethod = undoMethod; + } +} diff --git a/backend/framework/src/main/java/cn/cordys/config/RequestParamTrimConfig.java b/backend/framework/src/main/java/cn/cordys/config/RequestParamTrimConfig.java new file mode 100644 index 0000000..09b7b26 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/config/RequestParamTrimConfig.java @@ -0,0 +1,95 @@ +package cn.cordys.config; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdScalarDeserializer; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.util.HtmlUtils; + +import java.io.IOException; +import java.util.Arrays; + +/** + * 配置类,用于定制 Jackson 的反序列化过程。 + *

    + * 本类的作用是为 String 类型字段的反序列化过程添加自定义逻辑,去除请求参数中的前后空格。 + *

    + * + * @version 1.0 + */ +@Configuration +public class RequestParamTrimConfig { + + /** + * 需要开启 XSS 过滤的 URL 列表(Ant 风格,逗号分隔)。 + * 示例:/account/follow/**,/announcement/** + * 留空表示不对任何请求做 HTML 转义。 + */ + @Value("${xss.protection.url.list:}") + private String xssFilterUrlList; + + /** + * 定义一个 {@link Jackson2ObjectMapperBuilderCustomizer} Bean, + * 用于定制 Jackson 的 ObjectMapper 设置,特别是针对 String 类型字段的反序列化。 + *

    + * 通过此定制,所有从 JSON 反序列化的 String 类型字段将在反序列化时自动去除前后空格。 + *

    + * + * @return 定制后的 {@link Jackson2ObjectMapperBuilderCustomizer} 实例 + */ + @Bean + public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() { + AntPathMatcher pathMatcher = new AntPathMatcher(); // 线程安全,可复用 + + return builder -> builder + .deserializerByType(String.class, new StdScalarDeserializer(String.class) { + @Override + public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String rawValue = p.getValueAsString(); + String trimmedValue = StringUtils.trim(rawValue); + + // 判断是否需要对当前值进行 HTML 转义 + if (shouldEscapeXss(pathMatcher)) { + return HtmlUtils.htmlEscape(trimmedValue); + } + return trimmedValue; + } + }); + } + + /** + * 判断当前请求是否命中 XSS 过滤白名单(命中 → 执行转义)。 + */ + private boolean shouldEscapeXss(AntPathMatcher pathMatcher) { + // 未配置过滤列表 + if (StringUtils.isBlank(xssFilterUrlList)) { + return false; + } + + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return false; // 非 Web 上下文(定时任务等)不处理 + } + + HttpServletRequest request = attributes.getRequest(); + String requestURI = request.getRequestURI(); + if (StringUtils.isBlank(requestURI)) { + return false; + } + + // 逗号分隔的 Ant 路径列表,任一匹配即需要转义 + return Arrays.stream(xssFilterUrlList.split(",")) + .map(String::trim) + .filter(StringUtils::isNotBlank) // 过滤掉空白配置项 + .anyMatch(pattern -> pathMatcher.match(pattern, requestURI)); + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/context/OrganizationContext.java b/backend/framework/src/main/java/cn/cordys/context/OrganizationContext.java new file mode 100644 index 0000000..7a9d19b --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/context/OrganizationContext.java @@ -0,0 +1,114 @@ +package cn.cordys.context; + +import cn.cordys.common.constants.InternalUser; +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.response.result.CrmHttpResultCode; +import cn.cordys.security.SessionUser; +import cn.cordys.security.SessionUtils; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import java.util.Set; + +/** + * 组织上下文工具类,用于在请求/任务生命周期中传递组织ID和请求来源。 + *

    + * 使用 {@link InheritableThreadLocal} 实现父子线程自动传递,但在线程池场景需注意 + * 子线程任务执行完毕后必须清理,否则会造成上下文泄漏。 + *

    + * + *

    典型用法:

    + *
    + *   OrganizationContext.setOrganizationId("123456");
    + *   OrganizationContext.setRequestSource("API");
    + *   try {
    + *       // 业务逻辑
    + *   } finally {
    + *       OrganizationContext.clear();
    + *   }
    + * 
    + * + * @author jianxing + */ +@Slf4j +public final class OrganizationContext { + + public static final String DEFAULT_ORGANIZATION_ID = "100001"; + + private static final ThreadLocal ORGANIZATION_ID = new InheritableThreadLocal<>(); + + private OrganizationContext() { + // 工具类禁止实例化 + } + + /** + * 获取当前请求/任务的组织ID,并进行权限校验。 + *

    + * 优先级: + *

      + *
    1. ThreadLocal 中已有值时,直接校验权限后返回
    2. + *
    3. 无值时,根据用户信息自动推导(优先使用上次登录组织,否则任选一个)
    4. + *
    5. 管理员且无组织权限时返回默认组织ID
    6. + *
    + * 自动推导成功后会将结果存入 ThreadLocal,后续调用直接复用。 + *

    + * + * @return 组织ID(可能为 {@code null},仅当未登录且未手动设置时) + * + * @throws GenericException 如果当前用户对组织无权限 + */ + public static String getOrganizationId() { + String orgId = ORGANIZATION_ID.get(); + SessionUser user = SessionUtils.getUser(); + if (user == null) { + // 没有登入,则为定时任务,直接返回 + return orgId; + } + + boolean isAdmin = Strings.CS.equals(InternalUser.ADMIN.getValue(), user.getId()); + + if (StringUtils.isBlank(orgId)) { + Set organizationIds = user.getOrganizationIds(); + if (CollectionUtils.isNotEmpty(organizationIds)) { + // 如果有组织权限 + if (organizationIds.contains(user.getLastOrganizationId())) { + // 如果上次登入的组织任有权限,则获取该组织ID + orgId = user.getLastOrganizationId(); + } else { + // 获取一个组织ID + orgId = organizationIds.iterator().next(); + } + return orgId; + } else if (isAdmin) { + // 如果是管理员,则返回默认组织ID + return DEFAULT_ORGANIZATION_ID; + } + } else if (user.getOrganizationIds().contains(orgId) || isAdmin) { + // 如果用户有组织权限则返回 + return orgId; + } + + // 没有权限,抛出异常 + throw new GenericException(CrmHttpResultCode.FORBIDDEN, "No organization permission"); + } + + /** + * 手动设置组织ID(通常在过滤器或定时任务入口调用)。 + * + * @param organizationId 组织ID,允许为 {@code null} 或空字符串 + */ + public static void setOrganizationId(String organizationId) { + if (StringUtils.isNotBlank(organizationId)) { + ORGANIZATION_ID.set(organizationId); + } + } + + /** + * 清理 ThreadLocal 上下文,必须放在 finally 块中执行。 + */ + public static void clear() { + ORGANIZATION_ID.remove(); + } +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/excel/domain/ExcelDataFactory.java b/backend/framework/src/main/java/cn/cordys/excel/domain/ExcelDataFactory.java new file mode 100644 index 0000000..55d244e --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/excel/domain/ExcelDataFactory.java @@ -0,0 +1,8 @@ +package cn.cordys.excel.domain; + +/** + * @author wx + */ +public interface ExcelDataFactory { + Object getExcelDataByLocal(); +} diff --git a/backend/framework/src/main/java/cn/cordys/excel/domain/ExcelErrData.java b/backend/framework/src/main/java/cn/cordys/excel/domain/ExcelErrData.java new file mode 100644 index 0000000..d8918f3 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/excel/domain/ExcelErrData.java @@ -0,0 +1,17 @@ +package cn.cordys.excel.domain; + +import lombok.Data; + +@Data +public class ExcelErrData { + private Integer rowNum; + private String errMsg; + + public ExcelErrData() { + } + + public ExcelErrData(Integer rowNum, String errMsg) { + this.rowNum = rowNum; + this.errMsg = errMsg; + } +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/excel/utils/EasyExcelExporter.java b/backend/framework/src/main/java/cn/cordys/excel/utils/EasyExcelExporter.java new file mode 100644 index 0000000..d0b6e5a --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/excel/utils/EasyExcelExporter.java @@ -0,0 +1,61 @@ +package cn.cordys.excel.utils; + +import cn.cordys.common.exception.GenericException; +import cn.idev.excel.EasyExcel; +import cn.idev.excel.ExcelWriter; +import cn.idev.excel.write.handler.WriteHandler; +import cn.idev.excel.write.metadata.WriteSheet; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Slf4j +public class EasyExcelExporter { + public void buildExportResponse(HttpServletResponse response, String fileName) { + response.setContentType("application/vnd.ms-excel"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setHeader("Content-disposition", "attachment;filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8) + ".xlsx"); + } + + public void exportByCustomWriteHandler(HttpServletResponse response, List> headList, List> data, + String fileName, String sheetName, WriteHandler writeHandler) { + buildExportResponse(response, fileName); + try { + EasyExcel.write(response.getOutputStream()) + .head(Optional.ofNullable(headList).orElse(new ArrayList<>())) + .registerWriteHandler(writeHandler) + .sheet(sheetName) + .doWrite(data); + } catch (IOException e) { + log.error(e.getMessage(), e); + throw new GenericException(e.getMessage()); + } + } + + public void exportMultiSheetTplWithSharedHandler(HttpServletResponse response, List> headList, String fileName, + String mainSheetName, String tipSheetName, WriteHandler sharedHandler, WriteHandler styleHandler) { + buildExportResponse(response, fileName); + try (ExcelWriter writer = EasyExcel.write(response.getOutputStream()).build()) { + WriteSheet mainSheet = EasyExcel.writerSheet(0, mainSheetName) + .head(headList) + .registerWriteHandler(sharedHandler) + .registerWriteHandler(styleHandler) + .build(); + writer.write(new ArrayList<>(), mainSheet); + + WriteSheet tipSheet = EasyExcel.writerSheet(1, tipSheetName) + .registerWriteHandler(sharedHandler) + .build(); + writer.write(new ArrayList<>(), tipSheet); + } catch (IOException e) { + log.error(e.getMessage(), e); + throw new GenericException(e.getMessage()); + } + } +} diff --git a/backend/framework/src/main/java/cn/cordys/excel/utils/ExcelValidateHelper.java b/backend/framework/src/main/java/cn/cordys/excel/utils/ExcelValidateHelper.java new file mode 100644 index 0000000..bd44afd --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/excel/utils/ExcelValidateHelper.java @@ -0,0 +1,44 @@ +package cn.cordys.excel.utils; + +import cn.idev.excel.annotation.ExcelProperty; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; +import jakarta.validation.groups.Default; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Field; +import java.util.Set; + +@Component +public class ExcelValidateHelper { + + private static ExcelValidateHelper excelValidateHelper; + + @Resource + Validator validator; + + public static String validateEntity(T obj) throws NoSuchFieldException { + StringBuilder result = new StringBuilder(); + Set> set = excelValidateHelper.validator.validate(obj, Default.class); + if (set != null && !set.isEmpty()) { + for (ConstraintViolation cv : set) { + Field declaredField = obj.getClass().getDeclaredField(cv.getPropertyPath().toString()); + ExcelProperty annotation = declaredField.getAnnotation(ExcelProperty.class); + //拼接错误信息,包含当前出错数据的标题名字+错误信息 + result.append("[").append(annotation.value()[0]).append("]").append(cv.getMessage()).append("; "); + } + } + return result.toString(); + } + + /** + * 在静态方法中调用 + */ + @PostConstruct + public void initialize() { + excelValidateHelper = this; + excelValidateHelper.validator = this.validator; + } +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/file/engine/DefaultRepositoryDir.java b/backend/framework/src/main/java/cn/cordys/file/engine/DefaultRepositoryDir.java new file mode 100644 index 0000000..d83f9ef --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/file/engine/DefaultRepositoryDir.java @@ -0,0 +1,128 @@ +package cn.cordys.file.engine; + +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * 默认的资源目录工具类 + */ +public final class DefaultRepositoryDir { + + /** + * 临时目录 + */ + private static final String TMP_DIR = "/tmp"; + + /** + * 文件默认目录 + */ + private static final String DEFAULT_DIR = "/opt/cordys/data/files"; + + /** + * 导出目录 + */ + private static final String EXPORT_DIR = "/export"; + + /** + * 图片正式目录 + */ + private static final String PIC_DIR = "/pic"; + + /** + * 私有构造方法,防止实例化 + */ + private DefaultRepositoryDir() { + // 工具类不应被实例化 + } + + /** + * 获取临时目录路径 + * + * @return 临时目录路径 + */ + public static String getTmpDir() { + return TMP_DIR; + } + + /** + * 获取图片目录路径 + * + * @return 图片目录路径 + */ + public static String getPicDir() { + return PIC_DIR; + } + + /** + * 获取指定组织的导出目录路径 + * + * @param orgId 组织ID + * + * @return 导出目录路径 + */ + public static String getExportDir(String orgId) { + return Path.of(orgId, EXPORT_DIR).toString(); + } + + /** + * 获取默认目录路径 + * + * @return 默认目录路径 + */ + public static String getDefaultDir() { + return DEFAULT_DIR; + } + + /** + * 获取完整的临时目录路径 + * + * @return 完整的临时目录路径 + */ + public static Path getFullTmpPath() { + return Paths.get(DEFAULT_DIR, TMP_DIR); + } + + /** + * 获取完整的图片目录路径 + * + * @return 完整的图片目录路径 + */ + public static Path getFullPicPath() { + return Paths.get(DEFAULT_DIR, PIC_DIR); + } + + /** + * 获取指定组织的完整导出目录路径 + * + * @param orgId 组织ID + * + * @return 完整的导出目录路径 + */ + public static Path getFullExportPath(String orgId) { + return Paths.get(DEFAULT_DIR, orgId, EXPORT_DIR); + } + + /** + * 获取临时文件目录 + * + * @param tempFileId 临时文件ID + * + * @return 临时文件目录 + */ + public static String getTempFileDir(String tempFileId) { + return DefaultRepositoryDir.getTmpDir() + "/" + tempFileId; + } + + /** + * 获取转存文件目录 + * + * @param organizationId 组织ID + * @param resourceId 资源ID + * @param fileId 文件ID + * + * @return 转存文件目录 + */ + public static String getTransferFileDir(String organizationId, String resourceId, String fileId) { + return "/" + organizationId + DefaultRepositoryDir.getPicDir() + "/" + resourceId + "/" + fileId; + } +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/file/engine/FileCenter.java b/backend/framework/src/main/java/cn/cordys/file/engine/FileCenter.java new file mode 100644 index 0000000..ceb068b --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/file/engine/FileCenter.java @@ -0,0 +1,55 @@ +package cn.cordys.file.engine; + +import cn.cordys.common.util.CommonBeanFactory; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.Strings; + +import static cn.cordys.file.engine.StorageType.LOCAL; +import static cn.cordys.file.engine.StorageType.S3; + +/** + * FileCenter 类提供了根据存储类型获取对应文件仓库的静态方法。 + *

    + * 该类封装了不同存储类型(如 MINIO、LOCAL)的文件仓库获取逻辑,并允许根据存储类型返回相应的 {@link FileRepository} 实现。 + *

    + */ +@Slf4j +public class FileCenter { + // 默认存储类型 + private static StorageType defStorageType = null; + + // 私有构造函数,防止实例化 + private FileCenter() { + } + + /** + * 根据给定的存储类型返回对应的 {@link FileRepository} 实现。 + * + * @param storage 存储类型枚举值,指示所需的存储实现(如 S3、LOCAL)。 + * + * @return 返回对应的 {@link FileRepository} 实现,如果存储类型未知,则返回默认的仓库。 + */ + public static FileRepository getRepository(String storage) { + if (Strings.CS.equals(S3.name(), storage)) { + return CommonBeanFactory.getBean(S3Repository.class); + } else { + return CommonBeanFactory.getBean(LocalRepository.class); + } + } + + /** + * 返回默认的 {@link FileRepository} 实现。 + *

    + * 当前默认实现为 {@link LocalRepository},可以根据实际需求修改此方法以支持其他默认仓库。 + *

    + * + * @return 默认的 {@link FileRepository} 实现。 + */ + public static FileRepository getDefaultRepository() { + if (defStorageType == null) { + defStorageType = LOCAL; + } + log.info("Default storage type is set to: {}", defStorageType); + return getRepository(defStorageType.name()); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/file/engine/FileCopyRequest.java b/backend/framework/src/main/java/cn/cordys/file/engine/FileCopyRequest.java new file mode 100644 index 0000000..d89ead2 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/file/engine/FileCopyRequest.java @@ -0,0 +1,30 @@ +package cn.cordys.file.engine; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * 复制文件请求类,表示文件复制操作的请求参数。 + *

    + * 该类继承自 {@link FileRequest},包含了复制文件时需要的相关信息,如目标目录和文件名称。 + *

    + */ +@Data +@AllArgsConstructor +public class FileCopyRequest extends FileRequest { + + /** + * 复制的文件来源目录。 + */ + private String sourceFolder; + + /** + * 复制的文件目标目录。 + */ + private String destFolder; + + /** + * 复制的文件名称。 + */ + private String fileName; +} diff --git a/backend/framework/src/main/java/cn/cordys/file/engine/FileRepository.java b/backend/framework/src/main/java/cn/cordys/file/engine/FileRepository.java new file mode 100644 index 0000000..c068ad8 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/file/engine/FileRepository.java @@ -0,0 +1,155 @@ +package cn.cordys.file.engine; + +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.InputStream; +import java.util.List; + +/** + * 文件仓库接口,定义了文件操作(如保存、删除、下载、获取)的通用方法。 + *

    + * 实现该接口的类负责提供具体的文件存储和管理操作。 + *

    + */ +public interface FileRepository { + + /** + * 保存文件到文件仓库。 + * + * @param file 要保存的文件,类型为 {@link MultipartFile}。 + * @param request 文件请求信息,包含文件的元数据。 + * + * @return 返回保存后的文件标识符(通常为文件路径或唯一ID)。 + * + * @throws Exception 如果保存文件过程中发生错误,抛出异常。 + */ + String saveFile(MultipartFile file, FileRequest request) throws Exception; + + /** + * 保存字节数组表示的文件到文件仓库。 + * + * @param bytes 文件内容的字节数组。 + * @param request 文件请求信息,包含文件的元数据。 + * + * @throws Exception 如果保存文件过程中发生错误,抛出异常。 + */ + void saveFile(byte[] bytes, FileRequest request) throws Exception; + + /** + * 保存文件流表示的文件到文件仓库。 + * + * @param inputStream 文件内容的输入流。 + * @param request 文件请求信息,包含文件的元数据。 + * + * @return 返回保存后的文件标识符(通常为文件路径或唯一ID)。 + * + * @throws Exception 如果保存文件过程中发生错误,抛出异常。 + */ + String saveFile(InputStream inputStream, FileRequest request) throws Exception; + + /** + * 删除指定文件。 + * + * @param request 文件请求信息,包含待删除文件的标识符或路径。 + * + * @throws Exception 如果删除文件过程中发生错误,抛出异常。 + */ + void delete(FileRequest request) throws Exception; + + /** + * 删除指定文件夹及其所有内容。 + * + * @param request 文件请求信息,包含待删除文件夹的标识符或路径。 + * @param onlyDir 为true时跳过文件直接删除目录 + * + * @throws Exception 如果删除文件夹过程中发生错误,抛出异常。 + */ + void deleteFolder(FileRequest request, boolean onlyDir) throws Exception; + + /** + * 获取指定文件的字节内容。对于大文件,不建议使用此方法。 + * + * @param request 文件请求信息,包含待获取内容的文件标识符或路径。 + * + * @return 返回文件的字节数组。 + * + * @throws Exception 如果获取文件内容过程中发生错误,抛出异常。 + */ + byte[] getFile(FileRequest request) throws Exception; + + /** + * 获取指定文件的输入流。用于流式处理文件内容。 + * + * @param request 文件请求信息,包含待获取内容的文件标识符或路径。 + * + * @return 返回文件内容的输入流。 + * + * @throws Exception 如果获取文件输入流过程中发生错误,抛出异常。 + */ + InputStream getFileAsStream(FileRequest request) throws Exception; + + /** + * 以流式方式下载文件,通过逐块下载的方式节省内存。 + * + * @param request 文件请求信息,包含待下载的文件标识符或路径。 + * @param localPath 下载到本地的文件路径。 + * + * @throws Exception 如果下载文件过程中发生错误,抛出异常。 + */ + void downloadFile(FileRequest request, String localPath) throws Exception; + + + /** + * 以流式方式下载文件,通过逐块下载的方式节省内存。 + * + * @param request 文件请求信息,包含待下载的文件标识符或路径。 + * + * @throws Exception 如果下载文件过程中发生错误,抛出异常。 + */ + ResponseEntity downloadFile(FileRequest request) throws Exception; + + /** + * 获取指定文件夹下所有文件的文件名列表。 + * + * @param request 文件请求信息,包含目标文件夹的标识符或路径。 + * + * @return 返回文件夹中所有文件的文件名列表。 + * + * @throws Exception 如果获取文件夹文件列表过程中发生错误,抛出异常。 + */ + List getFolderFileNames(FileRequest request) throws Exception; + + /** + * 获取指定文件夹下所有文件的文件列表。 + * + * @param request 文件请求信息,包含目标文件夹的标识符或路径。 + * + * @return 返回文件夹中所有文件列表。 + * + * @throws Exception 如果获取文件夹文件列表过程中发生错误,抛出异常。 + */ + List getFolderFiles(FileRequest request) throws Exception; + + /** + * 复制文件到指定目录。 + * + * @param request 文件复制请求信息,包含源文件和目标目录信息。 + * + * @throws Exception 如果复制文件过程中发生错误,抛出异常。 + */ + void copyFile(FileCopyRequest request) throws Exception; + + /** + * 获取指定文件的大小(字节数)。 + * + * @param request 文件请求信息,包含待获取大小的文件标识符或路径。 + * + * @return 返回文件的大小,以字节为单位。 + * + * @throws Exception 如果获取文件大小过程中发生错误,抛出异常。 + */ + long getFileSize(FileRequest request) throws Exception; +} diff --git a/backend/framework/src/main/java/cn/cordys/file/engine/FileRequest.java b/backend/framework/src/main/java/cn/cordys/file/engine/FileRequest.java new file mode 100644 index 0000000..af270b7 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/file/engine/FileRequest.java @@ -0,0 +1,52 @@ +package cn.cordys.file.engine; + +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 文件请求类,封装了文件相关的请求信息。 + *

    + * 该类用于存储与文件操作相关的基本信息,如文件所在文件夹、存储类型和文件名等。 + *

    + */ +@Data +@NoArgsConstructor +public class FileRequest { + + /** + * 文件所在的文件夹路径。 + *

    + * 此字段指示文件存储的目录,通常用于定位文件。 + *

    + */ + private String folder; + + /** + * 存储类型,指示文件存储的方式或位置。 + *

    + * 例如,可以是 "S3"、"LOCAL" 等存储类型的标识符。 + *

    + */ + private String storage; + + /** + * 文件名称。 + *

    + * 此字段指示文件的名称,通常与文件的后缀(如 .txt、.jpg 等)一起确定文件的完整标识。 + *

    + */ + private String fileName; + + /** + * 构造函数,创建一个包含文件夹路径、存储类型和文件名的文件请求对象。 + * + * @param folder 文件所在的文件夹路径。 + * @param storage 存储类型,指示文件存储的位置或方式。 + * @param fileName 文件的名称。 + */ + public FileRequest(String folder, String storage, String fileName) { + this.folder = folder; + this.storage = storage; + this.fileName = fileName; + } +} diff --git a/backend/framework/src/main/java/cn/cordys/file/engine/FileValidate.java b/backend/framework/src/main/java/cn/cordys/file/engine/FileValidate.java new file mode 100644 index 0000000..9dc8297 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/file/engine/FileValidate.java @@ -0,0 +1,27 @@ +package cn.cordys.file.engine; + +import cn.cordys.common.exception.GenericException; +import cn.cordys.common.util.Translator; +import org.apache.commons.lang3.StringUtils; + +import java.io.File; + +public class FileValidate { + + /** + * Validates the file names to ensure they do not contain illegal characters. + * + * @param fileNames The file names to validate. + * + * @throws GenericException if any file name is invalid. + */ + public static void validateFileName(String... fileNames) { + if (fileNames != null) { + for (String fileName : fileNames) { + if (StringUtils.isNotBlank(fileName) && fileName.contains("." + File.separator)) { + throw new GenericException(Translator.get("invalid_parameter")); + } + } + } + } +} diff --git a/backend/framework/src/main/java/cn/cordys/file/engine/LocalRepository.java b/backend/framework/src/main/java/cn/cordys/file/engine/LocalRepository.java new file mode 100644 index 0000000..e2b5759 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/file/engine/LocalRepository.java @@ -0,0 +1,304 @@ +package cn.cordys.file.engine; + +import cn.cordys.common.exception.GenericException; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; +import org.aspectj.util.FileUtil; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.*; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.List; + +/** + * 本地文件仓库实现,提供本地文件的存储、删除、获取等操作。 + *

    + * 该类实现了 {@link FileRepository} 接口,具体实现了保存、删除、获取文件等方法,适用于本地存储的场景。 + *

    + */ +@Component +@Slf4j +public class LocalRepository implements FileRepository { + + /** + * 保存文件到本地存储。 + * + * @param multipartFile 要保存的文件,类型为 {@link MultipartFile}。 + * @param request 文件请求信息,包含文件的存储路径、文件名等信息。 + * + * @return 返回保存后的文件路径。 + * + * @throws IOException 如果保存文件过程中发生 I/O 错误,抛出异常。 + */ + @Override + public String saveFile(MultipartFile multipartFile, FileRequest request) throws IOException { + if (multipartFile == null || request == null || StringUtils.isEmpty(request.getFileName()) || StringUtils.isEmpty(request.getFolder())) { + return null; + } + createFileDir(request); + File file = new File(getFilePath(request)); + FileUtils.copyInputStreamToFile(multipartFile.getInputStream(), file); + return file.getPath(); + } + + /** + * 创建文件夹目录。 + * + * @param request 文件请求信息,包含文件夹路径信息。 + * + * @throws RuntimeException 如果创建目录失败,抛出运行时异常。 + */ + private void createFileDir(FileRequest request) { + String dir = getFileDir(request); + File fileDir = new File(dir); + if (!fileDir.exists() && !fileDir.mkdirs()) { + throw new RuntimeException("Failed to create directory: " + dir); + } + } + + /** + * 保存字节数组表示的文件到本地存储。 + * + * @param bytes 文件的字节数组。 + * @param request 文件请求信息,包含文件存储路径、文件名等。 + * + * @throws IOException 如果保存文件过程中发生 I/O 错误,抛出异常。 + */ + @Override + public void saveFile(byte[] bytes, FileRequest request) throws IOException { + File file = new File(getFilePath(request)); + // 检查父目录是否存在,如果不存在则创建它 + File parentDir = file.getParentFile(); + if (parentDir != null && !parentDir.exists()) { + // 创建父目录 + boolean dirsCreated = parentDir.mkdirs(); + if (!dirsCreated) { + throw new IOException("Failed to create directories: " + parentDir.getAbsolutePath()); + } + } + try (OutputStream ops = new FileOutputStream(file)) { + ops.write(bytes); + } + } + + /** + * 保存输入流表示的文件到本地存储。 + * + * @param inputStream 文件内容的输入流。 + * @param request 文件请求信息,包含文件存储路径、文件名等。 + * + * @return 返回保存后的文件路径。 + * + * @throws Exception 如果保存文件过程中发生错误,抛出异常。 + */ + @Override + public String saveFile(InputStream inputStream, FileRequest request) throws Exception { + File file = new File(getFilePath(request)); + FileUtils.copyInputStreamToFile(inputStream, file); + return file.getPath(); + } + + /** + * 删除文件。 + * + * @param request 文件请求信息,包含待删除文件的路径、文件名等。 + * + * @throws Exception 如果删除文件过程中发生错误,抛出异常。 + */ + @Override + public void delete(FileRequest request) throws Exception { + String path = StringUtils.join(getFilePath(request)); + File file = new File(path); + FileUtil.deleteContents(file); + if (file.exists() && !file.delete()) { + throw new RuntimeException("Failed to delete file: " + path); + } + } + + /** + * 删除指定文件夹及其所有内容。 + * + * @param request 文件请求信息,包含待删除文件夹路径的文件请求。 + * + * @throws Exception 如果删除文件夹过程中发生错误,抛出异常。 + */ + @Override + public void deleteFolder(FileRequest request, boolean onlyDir) throws Exception { + if (!onlyDir) { + FileValidate.validateFileName(request.getFolder(), request.getFileName()); + // 删除文件 + this.delete(request); + } + // 删除文件夹 + String folderPath = getFolderWithDefaultDir(request.getFolder()); + File folder = new File(folderPath); + if (folder.exists() && folder.isDirectory()) { + try { + FileUtils.deleteDirectory(folder); + } catch (IOException e) { + log.error("Failed to delete folder: {}", folderPath, e); + } + } + } + + /** + * 获取文件的字节内容。 + * + * @param request 文件请求信息,包含文件路径和文件名。 + * + * @return 返回文件的字节数组。 + * + * @throws Exception 如果获取文件内容过程中发生错误,抛出异常。 + */ + @Override + public byte[] getFile(FileRequest request) throws Exception { + File file = new File(getFilePath(request)); + return Files.readAllBytes(file.toPath()); + } + + /** + * 获取文件的输入流。 + * + * @param request 文件请求信息,包含文件路径和文件名。 + * + * @return 返回文件内容的输入流。 + * + * @throws Exception 如果获取文件输入流过程中发生错误,抛出异常。 + */ + @Override + public InputStream getFileAsStream(FileRequest request) throws Exception { + return new FileInputStream(getFilePath(request)); + } + + /** + * 下载文件到指定的本地路径(未实现)。 + * + * @param request 文件请求信息,包含待下载文件的路径。 + * @param localPath 下载到本地的路径。 + * + * @throws UnsupportedOperationException 本方法未实现,调用时会抛出异常。 + */ + @Override + public void downloadFile(FileRequest request, String localPath) { + throw new UnsupportedOperationException("Download file is not supported in LocalFileRepository."); + } + + @Override + public ResponseEntity downloadFile(FileRequest request) throws Exception { + File file = new File(getFilePath(request)); + if (!file.exists()) { + return ResponseEntity.notFound().build(); + } + + InputStreamResource resource = new InputStreamResource(new FileInputStream(file)); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + URLEncoder.encode(file.getName(), StandardCharsets.UTF_8)) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .contentLength(file.length()) + .body(resource); + } + + /** + * 获取指定文件夹下的文件名列表(未实现)。 + * + * @param request 文件请求信息,包含文件夹路径。 + * + * @return 返回文件夹中文件的文件名列表。 + */ + @Override + public List getFolderFileNames(FileRequest request) { + return null; + // 返回 null 或者未实现逻辑 + } + + /** + * 获取指定文件夹下的文件列表 + * + * @param request 文件请求信息,包含目标文件夹的标识符或路径。 + * + * @return 文件列表 + */ + @Override + public List getFolderFiles(FileRequest request) { + File file = new File(getFilePath(request)); + if (!file.isDirectory()) { + return List.of(); + } + File[] files = file.listFiles(); + return files != null ? List.of(files) : null; + } + + /** + * 复制文件到指定目录 + * + * @param request 文件复制请求信息。 + * + * @throws GenericException 如果复制文件时发生不支持的操作,抛出系统异常。 + */ + @Override + public void copyFile(FileCopyRequest request) throws Exception { + Files.createDirectories(Paths.get(getFolderWithDefaultDir(request.getDestFolder()))); + Files.copy(Paths.get(getFolderWithDefaultDir(request.getSourceFolder()), request.getFileName()), + Paths.get(getFolderWithDefaultDir(request.getDestFolder()), request.getFileName()), StandardCopyOption.REPLACE_EXISTING); + } + + /** + * 获取指定文件的大小。 + * + * @param request 文件请求信息,包含待获取大小的文件路径和文件名。 + * + * @return 返回文件的大小(字节数)。 + */ + @Override + public long getFileSize(FileRequest request) { + File file = new File(getFilePath(request)); + return file.length(); + } + + /** + * 获取文件的完整路径。 + * + * @param request 文件请求信息,包含文件夹路径和文件名。 + * + * @return 返回文件的完整路径。 + */ + private String getFilePath(FileRequest request) { + FileValidate.validateFileName(request.getFolder(), request.getFileName()); + return StringUtils.join(getFileDir(request), "/", request.getFileName()); + } + + /** + * 获取文件所在的文件夹路径。 + * + * @param request 文件请求信息,包含文件夹路径。 + * + * @return 返回文件夹路径。 + */ + private String getFileDir(FileRequest request) { + FileValidate.validateFileName(request.getFolder(), request.getFileName()); + return getFolderWithDefaultDir(request.getFolder()); + } + + /** + * 获取默认的文件夹路径 + * + * @param folder 文件夹名称 + * + * @return 文件夹路径 + */ + private String getFolderWithDefaultDir(String folder) { + return StringUtils.join(DefaultRepositoryDir.getDefaultDir(), folder); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/file/engine/S3Repository.java b/backend/framework/src/main/java/cn/cordys/file/engine/S3Repository.java new file mode 100644 index 0000000..d3c37f4 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/file/engine/S3Repository.java @@ -0,0 +1,76 @@ +package cn.cordys.file.engine; + +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.InputStream; +import java.util.List; + +public class S3Repository implements FileRepository { + @Override + public String saveFile(MultipartFile file, FileRequest request) throws Exception { + return ""; + } + + @Override + public void saveFile(byte[] bytes, FileRequest request) throws Exception { + + } + + @Override + public String saveFile(InputStream inputStream, FileRequest request) throws Exception { + return ""; + } + + @Override + public void delete(FileRequest request) throws Exception { + + } + + @Override + public void deleteFolder(FileRequest request, boolean onlyDir) throws Exception { + + } + + @Override + public byte[] getFile(FileRequest request) throws Exception { + return new byte[0]; + } + + @Override + public InputStream getFileAsStream(FileRequest request) throws Exception { + return null; + } + + @Override + public void downloadFile(FileRequest request, String localPath) throws Exception { + + } + + @Override + public ResponseEntity downloadFile(FileRequest request) throws Exception { + return null; + } + + @Override + public List getFolderFileNames(FileRequest request) throws Exception { + return List.of(); + } + + @Override + public List getFolderFiles(FileRequest request) throws Exception { + return List.of(); + } + + @Override + public void copyFile(FileCopyRequest request) throws Exception { + + } + + @Override + public long getFileSize(FileRequest request) throws Exception { + return 0; + } +} diff --git a/backend/framework/src/main/java/cn/cordys/file/engine/StorageType.java b/backend/framework/src/main/java/cn/cordys/file/engine/StorageType.java new file mode 100644 index 0000000..6fb300a --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/file/engine/StorageType.java @@ -0,0 +1,42 @@ +package cn.cordys.file.engine; + +/** + * 存储类型枚举类,用于标识不同的文件存储方式。 + *

    + * 此枚举类定义了三种存储方式:MINIO、GIT 和 LOCAL。 + *

    + */ +public enum StorageType { + + /** + * 标准S3协议 存储,通常用于对象存储。 + */ + S3, + + /** + * LOCAL 存储,通常用于本地文件存储。 + */ + LOCAL; + + /** + * 根据字符串获取对应的枚举值。 + *

    + * 如果提供的字符串不匹配任何枚举值,返回 {@code null}。 + *

    + * + * @param storageType 字符串表示的存储类型 + * + * @return 对应的枚举值,或 {@code null} 如果没有匹配的枚举 + */ + public static StorageType fromString(String storageType) { + if (storageType != null) { + for (StorageType type : StorageType.values()) { + if (type.name().equalsIgnoreCase(storageType)) { + return type; + } + } + } + return LOCAL; + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/mybatis/AbstractSqlProviderSupport.java b/backend/framework/src/main/java/cn/cordys/mybatis/AbstractSqlProviderSupport.java new file mode 100644 index 0000000..81b85a3 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/mybatis/AbstractSqlProviderSupport.java @@ -0,0 +1,213 @@ +package cn.cordys.mybatis; + +import org.apache.ibatis.builder.annotation.ProviderContext; + +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Stream; + +/** + * 抽象 SQL 提供者支持类,提供动态 SQL 生成的基础功能。 + */ +public abstract class AbstractSqlProviderSupport { + + /** + * 用于缓存表信息结构,支持高效查询。 + */ + private static final Map, EntityTable> tableCache = new ConcurrentHashMap<>(256); + /** + * 当前表的元信息。 + */ + protected EntityTable table; + + /** + * 生成 SQL 的抽象方法,由子类实现。 + * + * @param criteria 查询条件 + * + * @return SQL 实例 + */ + abstract String sql(Object criteria); + + /** + * 动态生成更新 SQL 的方法。 + * + * @return 更新 SQL 脚本 + */ + public String updateSQL() { + StringBuilder sql = new StringBuilder(); + sql.append(""); + + return sql.toString(); + } + + public void tableWhere(StringBuilder sql) { + sql.append(" FROM ").append(table.getTableName()); + sql.append(""); + for (Field field : table.getFields()) { + String column = field.getName(); + sql.append(""); + sql.append(" AND ").append(columnName(field)).append(" = ").append(bindParameter(field)); + sql.append(""); + } + sql.append(""); + } + + /** + * 根据查询条件和上下文构建 SQL。 + * + * @param criteria 查询条件 + * @param context MyBatis 提供的上下文 + * + * @return 构建的 SQL 脚本 + */ + String invoke(Object criteria, ProviderContext context) { + return buildSql(criteria, tableInfo(context)); + } + + /** + * 构建 SQL 脚本。 + * + * @param criteria 查询条件 + * @param table 表的元信息 + * + * @return SQL 脚本字符串 + */ + String buildSql(Object criteria, EntityTable table) { + this.table = table; + String sql = sql(criteria); + beforeInterceptor(criteria); + return sql; + } + + /** + * SQL 执行前的拦截器,用于处理特定操作。 + * + * @param obj 查询条件对象 + */ + void beforeInterceptor(Object obj) { + if (obj instanceof BaseMapper.Interceptor && this instanceof BaseMapper.WriteType) { + ((BaseMapper.Interceptor) obj).prePersist(); + } + } + + /** + * 根据查询条件过滤空值并对字段进行操作。 + * + * @param criteria 查询条件 + * @param func 对字段进行操作的函数 + * @param ignorePk 是否忽略主键字段 + * + * @return 操作后的字段数组 + */ + String[] ignoreNullAndCombined(Object criteria, Function func, boolean ignorePk) { + return Stream.of(table.getFields()) + .filter(field -> { + Object value = value(criteria, field); + // 过滤空字符串 + boolean noNull = value != null; + return ignorePk ? (noNull && !table.getPrimaryKeyColumn().equals(columnName(field))) : noNull; + }) + .map(func) + .toArray(String[]::new); + } + + /** + * 根据查询条件过滤空值并对字段进行操作(不过滤主键)。 + * + * @param criteria 查询条件 + * @param func 对字段进行操作的函数 + * + * @return 操作后的字段数组 + */ + String[] ignoreNullAndCombined(Object criteria, Function func) { + return ignoreNullAndCombined(criteria, func, false); + } + + /** + * 获取并缓存表信息结构。 + * + * @param context MyBatis 提供的上下文 + * + * @return 表的元信息 + */ + EntityTable tableInfo(ProviderContext context) { + return tableCache.computeIfAbsent(context.getMapperType(), t -> EntityTableMapper.extractTableInfo(entityType(context))); + } + + /** + * 获取 BaseMapper 接口中的泛型类型。 + * + * @param context ProviderContext + * + * @return 泛型类型对应的 Class 对象 + * + * @throws IllegalStateException 如果未找到 BaseMapper 的泛型类 + */ + Class entityType(ProviderContext context) { + return Stream.of(context.getMapperType().getGenericInterfaces()) + .filter(ParameterizedType.class::isInstance) + .map(ParameterizedType.class::cast) + .filter(type -> type.getRawType() == BaseMapper.class) + .findFirst() + .map(type -> type.getActualTypeArguments()[0]) + .filter(Class.class::isInstance) + .map(Class.class::cast) + .orElseThrow(() -> new IllegalStateException("未找到 BaseMapper 的泛型类 " + context.getMapperType().getName() + ".")); + } + + /** + * 绑定字段到参数。 + * + * @param field 目标字段 + * + * @return 参数绑定表达式 + */ + String bindParameter(Field field) { + return String.format("#{%s}", field.getName()); + } + + /** + * 获取字段对应的列名。 + * + * @param field 目标字段 + * + * @return 字段对应的列名 + */ + String columnName(Field field) { + return EntityTableMapper.getColumnName(field); + } + + /** + * 获取指定对象中字段的值。 + * + * @param bean 目标对象 + * @param field 目标字段 + * + * @return 字段的值 + */ + Object value(Object bean, Field field) { + return EntityTableMapper.getFieldValue(bean, field); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/mybatis/BaseMapper.java b/backend/framework/src/main/java/cn/cordys/mybatis/BaseMapper.java new file mode 100644 index 0000000..9fd2632 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/mybatis/BaseMapper.java @@ -0,0 +1,452 @@ +package cn.cordys.mybatis; + +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.ibatis.annotations.*; +import org.apache.ibatis.jdbc.AbstractSQL; + +import java.io.Serializable; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + + +/** + * 通用 Mapper接口,提供基本的增删改查方法。 + * + * @param 实体类型 + */ +public interface BaseMapper { + + /** + * 插入一条记录。 + * + * @param entity 要插入的实体 + * + * @return 插入的行数 + */ + @InsertProvider(type = InsertSqlProvider.class, method = "invoke") + @Options(useGeneratedKeys = true, keyProperty = "id") + Integer insert(E entity); + + /** + * 批量插入记录。 + * + * @param entities 要插入的实体列表 + * + * @return 插入的行数 + */ + @InsertProvider(type = BatchInsertSqlProvider.class, method = "invoke") + @Options(useGeneratedKeys = true, keyProperty = "id") + Integer batchInsert(List entities); + + /** + * 根据主键更新记录。 + * + * @param entity 要更新的实体 + * + * @return 更新的行数 + */ + @UpdateProvider(type = UpdateSqlProvider.class, method = "invoke") + Integer updateById(E entity); + + /** + * 选择性更新记录(仅更新非空字段)。 + * + * @param entity 要更新的实体 + * + * @return 更新的行数 + */ + @UpdateProvider(type = UpdateSelectiveSqlProvider.class, method = "invoke") + Integer update(E entity); + + /** + * 根据主键删除记录。 + * + * @param id 主键值 + * + * @return 删除的行数 + */ + @DeleteProvider(type = DeleteSqlProvider.class, method = "invoke") + Integer deleteByPrimaryKey(Serializable id); + + /** + * 根据条件删除记录。 + * + * @param criteria 删除的条件 + * + * @return 删除的行数 + */ + @DeleteProvider(type = DeleteByCriteriaSqlProvider.class, method = "invoke") + Integer delete(E criteria); + + /** + * 使用 LambdaQueryWrapper 批量删除。 + * + * @param wrapper LambdaQueryWrapper 删除条件 + */ + @DeleteProvider(type = DeleteByLambdaSqlProvider.class, method = "invoke") + void deleteByLambda(@Param("wrapper") LambdaQueryWrapper wrapper); + + /** + * 批量删除。 + * + * @param ids List 删除条件 + */ + @DeleteProvider(type = DeleteByIdsSqlProvider.class, method = "invoke") + void deleteByIds(@Param("ids") List ids); + + /** + * 根据主键查询记录。 + * + * @param id 主键值 + * + * @return 查询到的实体 + */ + @SelectProvider(type = SelectByIdSqlProvider.class, method = "invoke") + E selectByPrimaryKey(Serializable id); + + /** + * 查询所有记录。 + * + * @param orderBy 排序条件 + * + * @return 查询到的实体列表 + */ + @SelectProvider(type = SelectAllSqlProvider.class, method = "invoke") + List selectAll(String orderBy); + + /** + * 根据条件查询记录。 + * + * @param criteria 查询条件 + * + * @return 查询到的实体列表 + */ + @SelectProvider(type = SelectByCriteriaSqlProvider.class, method = "invoke") + List select(E criteria); + + /** + * 使用 LambdaQueryWrapper 查询记录列表。 + * + * @param wrapper LambdaQueryWrapper 查询条件 + * + * @return 查询结果列表 + */ + @SelectProvider(type = SelectByLambdaSqlProvider.class, method = "invoke") + List selectListByLambda(@Param("wrapper") LambdaQueryWrapper wrapper); + + /** + * 根据条件查询单条记录。 + * + * @param criteria 查询条件 + * + * @return 查询到的实体 + */ + @SelectProvider(type = SelectByCriteriaSqlProvider.class, method = "invoke") + E selectOne(E criteria); + + /** + * 根据列和值查询记录。 + * + * @param column 列名 + * @param ids 查询的值数组 + * + * @return 查询到的实体列表 + */ + @SelectProvider(type = SelectInSqlProvider.class, method = "invoke") + List selectByColumn(@Param("column") String column, @Param("array") Serializable[] ids); + + /** + * 根据条件统计记录数。 + * + * @param criteria 查询条件 + * + * @return 记录数 + */ + @SelectProvider(type = CountByCriteriaSqlProvider.class, method = "invoke") + Long countByExample(E criteria); + + /** + * 根据主键数组查询记录。 + * + * @param ids 主键值数组 + * + * @return 查询到的实体列表 + */ + default List selectByIds(@Param("array") Serializable[] ids) { + if (ids == null || ids.length == 0) { + return List.of(); + } + return selectByColumn("id", ids); + } + + /** + * 根据主键数组查询记录。 + * + * @param ids 主键值数组 + * + * @return 查询到的实体列表 + */ + default List selectByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return List.of(); + } + return selectByIds(ids.toArray(new String[0])); + } + + /** + * 判断记录是否存在。 + * + * @param criteria 查询条件 + * + * @return 如果存在返回true,否则返回false + */ + default boolean exist(E criteria) { + Long count = countByExample(criteria); + return count != null && count > 0; + } + + /** + * 插入或更新记录。 + * + * @param criteria 查询条件 + * + * @return 执行的行数 + */ + default Integer upsert(E criteria) { + return exist(criteria) ? updateById(criteria) : insert(criteria); + } + + interface Interceptor { + /** + * 类似 @PrePersist + **/ + default void prePersist() { + } + } + + interface WriteType { + } + + class InsertSqlProvider extends AbstractSqlProviderSupport implements WriteType { + @Override + public String sql(Object criteria) { + SQL sql = new SQL() + .INSERT_INTO(table.getTableName()) + .INTO_COLUMNS(table.getColumns()) + .INTO_VALUES(Stream.of(table.getFields()).map(this::bindParameter).toArray(String[]::new)); + + return String.format("", sql.toString()); + } + } + + @SuppressWarnings("all") + class BatchInsertSqlProvider extends AbstractSqlProviderSupport implements WriteType { + @Override + public String sql(Object criteria) { + SQL sql = new SQL() + .INSERT_INTO(table.getTableName()) + .INTO_COLUMNS(table.getColumns()) + .INTO_VALUES(Stream.of(table.getFields()).map(this::bindParameter).toArray(String[]::new)); + + return String.format("", sql.toString()); + } + } + + @SuppressWarnings("all") + class UpdateSqlProvider extends AbstractSqlProviderSupport implements WriteType { + @Override + public String sql(Object criteria) { + return updateSQL(); + } + } + + @SuppressWarnings("all") + class UpdateSelectiveSqlProvider extends AbstractSqlProviderSupport implements WriteType { + @Override + public String sql(Object entity) { + return updateSQL(); + } + } + + class DeleteSqlProvider extends AbstractSqlProviderSupport implements WriteType { + @Override + public String sql(Object criteria) { + SQL sql = new SQL() + .DELETE_FROM(table.getTableName()) + .WHERE(table.getPrimaryKeyColumn() + " = #{id}"); + + return String.format("", sql.toString()); + } + } + + class DeleteByCriteriaSqlProvider extends AbstractSqlProviderSupport implements WriteType { + @Override + public String sql(Object criteria) { + StringBuilder sql = new StringBuilder(); + sql.append(""); + return sql.toString(); + } + } + + class DeleteByLambdaSqlProvider extends AbstractSqlProviderSupport implements WriteType { + @Override + public String sql(Object criteria) { + LambdaQueryWrapper wrapper = (LambdaQueryWrapper) criteria; + + SQL sql = new SQL().DELETE_FROM(table.getTableName()); + + // 将 LambdaQueryWrapper 的条件解析为 WHERE 子句 + Optional.ofNullable(wrapper.getWhereClause()) + .filter(StringUtils::isNotBlank) + .ifPresent(sql::WHERE); + + return String.format("", sql); + } + } + + @SuppressWarnings("all") + class DeleteByIdsSqlProvider extends AbstractSqlProviderSupport implements WriteType { + @Override + public String sql(Object entities) { + List ids = (List) entities; + if (CollectionUtils.isEmpty(ids)) { + throw new IllegalArgumentException("ids cannot be empty"); + } + + StringBuilder sql = new StringBuilder(); + sql.append(""); + + return sql.toString(); + } + } + + class SelectByIdSqlProvider extends AbstractSqlProviderSupport { + @Override + public String sql(Object criteria) { + SQL sql = new SQL() + .SELECT(table.getSelectColumns()) + .FROM(table.getTableName()) + .WHERE(table.getPrimaryKeyColumn() + " = #{id}"); + + return String.format("", sql.toString()); + } + } + + class SelectAllSqlProvider extends AbstractSqlProviderSupport { + @Override + public String sql(Object criteria) { + String orderBy = (String) criteria; + SQL sql = new SQL() + .SELECT(table.getSelectColumns()) + .FROM(table.getTableName()); + if (StringUtils.isBlank(orderBy)) { + orderBy = table.getPrimaryKeyColumn() + " DESC"; + } + sql.ORDER_BY(orderBy); + + return String.format("", sql); + } + } + + class SelectInSqlProvider extends AbstractSqlProviderSupport { + @Override + public String sql(Object entities) { + var param = (Map) entities; + String inField = (String) param.get("column"); + Serializable[] ids = (Serializable[]) param.get("array"); + + if (ids == null || ids.length == 0) { + throw new IllegalArgumentException("array parameter cannot be empty"); + } + + return """ + + """.formatted(String.join(", ", table.getSelectColumns()), table.getTableName(), inField); + } + } + + class SelectByCriteriaSqlProvider extends AbstractSqlProviderSupport { + @Override + public String sql(Object criteria) { + StringBuilder sql = new StringBuilder(); + sql.append(""); + return sql.toString(); + } + } + + class SelectByLambdaSqlProvider extends AbstractSqlProviderSupport { + @Override + public String sql(Object criteria) { + LambdaQueryWrapper wrapper = (LambdaQueryWrapper) criteria; + + SQL sql = new SQL() + .SELECT(table.getSelectColumns()) + .FROM(table.getTableName()); + + // 将 LambdaQueryWrapper 的条件解析为 WHERE 子句 + String whereClause = wrapper.getWhereClause(); + if (StringUtils.isNotBlank(whereClause)) { + sql.WHERE(whereClause); + } + + // 解析排序条件 + String orderByClause = wrapper.getOrderByClause(); + if (StringUtils.isNotBlank(orderByClause)) { + sql.ORDER_BY(orderByClause); + } + return String.format("", sql.toString()); + } + } + + class CountByCriteriaSqlProvider extends AbstractSqlProviderSupport { + @Override + public String sql(Object criteria) { + StringBuilder sql = new StringBuilder(); + sql.append(""); + + return sql.toString(); + } + } + + class SQL extends AbstractSQL { + public SQL WHERE(boolean test, String condition) { + return test ? super.WHERE(condition) : this; + } + + @Override + public SQL getSelf() { + return this; + } + } +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/mybatis/DataAccessLayer.java b/backend/framework/src/main/java/cn/cordys/mybatis/DataAccessLayer.java new file mode 100644 index 0000000..46e4e0b --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/mybatis/DataAccessLayer.java @@ -0,0 +1,290 @@ +package cn.cordys.mybatis; + +import cn.cordys.common.util.CodingUtils; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.mapping.ResultMap; +import org.apache.ibatis.mapping.SqlCommandType; +import org.apache.ibatis.mapping.SqlSource; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.ExecutorType; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +import java.io.Serializable; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 数据访问层(DAL),提供与数据库交互的方法,使用 MyBatis 进行 SQL 查询的执行。 + * 支持多种 CRUD 操作。 + */ +@Component +@Slf4j +public class DataAccessLayer implements ApplicationContextAware { + + private static volatile ApplicationContext applicationContext; + // 使用 ConcurrentHashMap 替代同步 LinkedHashMap,提高并发性能 + private final Map, EntityTable> cachedTableInfo = new ConcurrentHashMap<>(128); + private final Map msIdLocks = new ConcurrentHashMap<>(); + // 添加 MappedStatement 缓存计数监控 + private final AtomicInteger mappedStatementCount = new AtomicInteger(0); + private SqlSession sqlSession; + private Configuration configuration; + + private DataAccessLayer() { + // 私有构造函数 + } + + /** + * 获取 Dal 实例并为指定的实体类准备 Executor + */ + public static Executor with(Class entityClass) { + if (applicationContext == null) { + throw new IllegalStateException("ApplicationContext 未初始化"); + } + return with(entityClass, applicationContext.getBean(SqlSession.class)); + } + + /** + * 获取 Dal 实例并使用指定的 SqlSession + */ + public static Executor with(Class entityClass, SqlSession sqlSession) { + var instance = Holder.INSTANCE.initSession(sqlSession); + + var entityTable = Optional.ofNullable(entityClass) + .map(clazz -> instance.cachedTableInfo.computeIfAbsent( + clazz, EntityTableMapper::extractTableInfo)) + .orElse(null); + + return instance.new Executor<>(entityTable); + } + + /** + * 初始化 SqlSession,用于执行 DAL 操作 + */ + private DataAccessLayer initSession(SqlSession sqlSession) { + this.sqlSession = sqlSession; + this.configuration = sqlSession.getConfiguration(); + return this; + } + + @Override + public void setApplicationContext(ApplicationContext context) throws BeansException { + // 使用双重检查锁定模式 + if (applicationContext == null) { + synchronized (DataAccessLayer.class) { + if (applicationContext == null) { + applicationContext = context; + } + } + } + } + + /** + * 生成缓存键 + */ + private String generateCacheKey(String methodName, Class parameterType, SqlCommandType sqlCommandType) { + return String.format("%s:%s:%s", sqlCommandType.name(), parameterType.getName(), methodName); + } + + /** + * 执行 SQL,返回 MappedStatement ID + */ + private String execute(String sql, String methodName, Class parameterType, Class resultType, SqlCommandType sqlCommandType) { + var msId = generateCacheKey(methodName, parameterType, sqlCommandType); + + if (!configuration.hasStatement(msId, false)) { + Object lock = msIdLocks.computeIfAbsent(msId, k -> new Object()); + synchronized (lock) { + if (!configuration.hasStatement(msId, false)) { + // 创建和注册 MappedStatement + var sqlSource = configuration + .getDefaultScriptingLanguageInstance() + .createSqlSource(configuration, sql, parameterType); + + newMappedStatement(msId, sqlSource, resultType, sqlCommandType); + + var count = mappedStatementCount.incrementAndGet(); + if (count % 500 == 0) { + log.info("当前缓存的 MappedStatement 总量:{}", count); + } + } + } + msIdLocks.remove(msId); + } + + return msId; + } + + /** + * 创建并注册新的 MappedStatement + */ + private void newMappedStatement(String msId, SqlSource sqlSource, Class resultType, SqlCommandType sqlCommandType) { + var resultMap = new ResultMap.Builder(configuration, "defaultResultMap", resultType, new ArrayList<>(0)) + .build(); + + var ms = new MappedStatement.Builder(configuration, msId, sqlSource, sqlCommandType) + .resultMaps(Collections.singletonList(resultMap)) + .build(); + + configuration.addMappedStatement(ms); + } + + /** + * 单例持有者,确保 Dal 实例的唯一性 + */ + private static class Holder { + private static final DataAccessLayer INSTANCE = new DataAccessLayer(); + } + + /** + * 数据访问执行器 + */ + public class Executor implements BaseMapper { + private final EntityTable table; + private final Class resultType; + + Executor(EntityTable table) { + this.table = table; + this.resultType = table != null ? table.getEntityClass() : null; + } + + @Override + public List selectAll(String orderBy) { + var sql = new SelectAllSqlProvider().buildSql(orderBy, this.table); + var msId = execute(sql, "BaseMapper.selectAll", table.getEntityClass(), resultType, SqlCommandType.SELECT); + return sqlSession.selectList(msId, orderBy); + } + + @Override + public List select(E criteria) { + var sql = new SelectByCriteriaSqlProvider().buildSql(criteria, this.table); + var msId = execute(sql, "BaseMapper.select", table.getEntityClass(), resultType, SqlCommandType.SELECT); + return sqlSession.selectList(msId, criteria); + } + + @Override + public List selectListByLambda(LambdaQueryWrapper wrapper) { + var sql = new SelectByLambdaSqlProvider().buildSql(wrapper, this.table); + var msId = execute(sql, "BaseMapper.selectListByLambda:" + CodingUtils.hashStr(sql), table.getEntityClass(), resultType, SqlCommandType.SELECT); + return sqlSession.selectList(msId, wrapper.getParams()); + } + + @Override + public E selectByPrimaryKey(Serializable criteria) { + var sql = new SelectByIdSqlProvider().buildSql(criteria, this.table); + var msId = execute(sql, "BaseMapper.selectByPrimaryKey", table.getEntityClass(), resultType, SqlCommandType.SELECT); + return sqlSession.selectOne(msId, criteria); + } + + @Override + public E selectOne(E criteria) { + var sql = new SelectByCriteriaSqlProvider().buildSql(criteria, this.table); + var msId = execute(sql, "BaseMapper.selectOne", table.getEntityClass(), resultType, SqlCommandType.SELECT); + return sqlSession.selectOne(msId, criteria); + } + + @Override + public List selectByColumn(String column, Serializable[] criteria) { + var params = new HashMap(); + params.put("column", column); + params.put("array", criteria); + + var sql = new SelectInSqlProvider().buildSql(params, this.table); + var msId = execute(sql, "BaseMapper.selectByColumn", table.getEntityClass(), resultType, SqlCommandType.SELECT); + return sqlSession.selectList(msId, criteria); + } + + @Override + public Long countByExample(E criteria) { + var sql = new CountByCriteriaSqlProvider().buildSql(criteria, this.table); + var msId = execute(sql, "BaseMapper.countByExample", table.getEntityClass(), Long.class, SqlCommandType.SELECT); + return sqlSession.selectOne(msId, criteria); + } + + @Override + public Integer insert(E criteria) { + var sql = new InsertSqlProvider().buildSql(criteria, this.table); + var msId = execute(sql, "BaseMapper.insert", table.getEntityClass(), int.class, SqlCommandType.INSERT); + return sqlSession.insert(msId, criteria); + } + + @Override + public Integer updateById(E criteria) { + var sql = new UpdateSelectiveSqlProvider().buildSql(criteria, this.table); + var msId = execute(sql, "BaseMapper.updateById", table.getEntityClass(), int.class, SqlCommandType.UPDATE); + return sqlSession.update(msId, criteria); + } + + @Override + public Integer update(E criteria) { + var sql = new UpdateSelectiveSqlProvider().buildSql(criteria, this.table); + var msId = execute(sql, "BaseMapper.update", table.getEntityClass(), int.class, SqlCommandType.UPDATE); + return sqlSession.update(msId, criteria); + } + + @Override + public Integer delete(E criteria) { + var sql = new DeleteByCriteriaSqlProvider().buildSql(criteria, this.table); + var msId = execute(sql, "BaseMapper.delete", table.getEntityClass(), int.class, SqlCommandType.DELETE); + return sqlSession.delete(msId, criteria); + } + + @Override + public void deleteByLambda(LambdaQueryWrapper wrapper) { + var sql = new DeleteByLambdaSqlProvider().buildSql(wrapper, this.table); + var msId = execute(sql, "BaseMapper.deleteByLambda:" + CodingUtils.hashStr(sql), table.getEntityClass(), int.class, SqlCommandType.DELETE); + sqlSession.delete(msId, wrapper.getParams()); + } + + @Override + public void deleteByIds(List ids) { + var sql = new DeleteByIdsSqlProvider().buildSql(ids, this.table); + var msId = execute(sql, "BaseMapper.deleteByIds", table.getEntityClass(), int.class, SqlCommandType.DELETE); + var params = new HashMap<>(); + params.put("array", ids); + sqlSession.delete(msId, params); + } + + @Override + public Integer deleteByPrimaryKey(Serializable criteria) { + var sql = new DeleteSqlProvider().buildSql(criteria, this.table); + var msId = execute(sql, "BaseMapper.deleteByPrimaryKey", table.getEntityClass(), int.class, SqlCommandType.DELETE); + return sqlSession.delete(msId, criteria); + } + + @Override + public boolean exist(E criteria) { + return Optional.ofNullable(select(criteria)) + .filter(list -> !list.isEmpty()) + .isPresent(); + } + + @Override + public Integer batchInsert(List entities) { + if (entities == null || entities.isEmpty()) { + return 0; + } + + var sql = new BatchInsertSqlProvider().buildSql(entities, this.table); + var msId = execute(sql, "BaseMapper.batchInsert", table.getEntityClass(), int.class, SqlCommandType.INSERT); + var sqlSessionFactory = applicationContext.getBean(SqlSessionFactory.class); + + try (var batchSession = sqlSessionFactory.openSession(ExecutorType.BATCH, false)) { + entities.forEach(entity -> batchSession.insert(msId, entity)); + batchSession.flushStatements(); + batchSession.commit(); + return entities.size(); + } catch (Exception e) { + throw new RuntimeException("批量插入失败", e); + } + } + } +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/mybatis/EntityTable.java b/backend/framework/src/main/java/cn/cordys/mybatis/EntityTable.java new file mode 100644 index 0000000..d66a286 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/mybatis/EntityTable.java @@ -0,0 +1,148 @@ +package cn.cordys.mybatis; + +import lombok.Getter; + +import java.lang.reflect.Field; + +/** + * 实体类与数据库表信息的映射类。 + *

    + * 此类用于描述数据库表与实体类的映射关系,包括表名、字段、主键等元信息。 + * 主要用于持久化层的数据映射与操作。 + *

    + */ +public class EntityTable { + + /** + * 表对应的实体类型。 + */ + @Getter + private Class entityClass; + + /** + * 实体类型中不包含 {@code @NoColumn} 注解的字段。 + */ + private Field[] fields; + + /** + * 数据库表名。 + */ + private String tableName; + + /** + * 主键列名。 + */ + private String primaryKeyColumn; + + /** + * 数据库表的所有列名。 + */ + private String[] columns; + + /** + * 用于生成 SELECT SQL 的列名集合。 + *

    + * 如果字段名称中含有下划线,会转换为形如 "aa_bb AS aaBb" 的形式。 + *

    + */ + private String[] selectColumns; + + /** + * 设置实体类型。 + * + * @param entityClass 实体类的 Class 对象 + */ + void setEntityClass(Class entityClass) { + this.entityClass = entityClass; + } + + /** + * 获取不含 {@code @NoColumn} 注解的字段。 + * + * @return 字段数组 + */ + Field[] getFields() { + return fields; + } + + /** + * 设置字段数组。 + * + * @param fields 字段数组 + */ + void setFields(Field[] fields) { + this.fields = fields; + } + + /** + * 获取数据库表名。 + * + * @return 表名 + */ + String getTableName() { + return tableName; + } + + /** + * 设置数据库表名。 + * + * @param tableName 表名 + */ + void setTableName(String tableName) { + this.tableName = tableName; + } + + /** + * 获取主键列名。 + * + * @return 主键列名 + */ + String getPrimaryKeyColumn() { + return primaryKeyColumn; + } + + /** + * 设置主键列名。 + * + * @param primaryKeyColumn 主键列名 + */ + void setPrimaryKeyColumn(String primaryKeyColumn) { + this.primaryKeyColumn = primaryKeyColumn; + } + + /** + * 获取数据库表的所有列名。 + * + * @return 列名数组 + */ + String[] getColumns() { + return columns; + } + + /** + * 设置数据库表的所有列名。 + * + * @param columns 列名数组 + */ + void setColumns(String[] columns) { + this.columns = columns; + } + + /** + * 获取用于生成 SELECT SQL 的列名集合。 + * + * @return 列名数组 + */ + String[] getSelectColumns() { + return selectColumns; + } + + /** + * 设置用于生成 SELECT SQL 的列名集合。 + * + * @param selectColumns 列名数组 + */ + void setSelectColumns(String[] selectColumns) { + this.selectColumns = selectColumns; + } +} diff --git a/backend/framework/src/main/java/cn/cordys/mybatis/EntityTableMapper.java b/backend/framework/src/main/java/cn/cordys/mybatis/EntityTableMapper.java new file mode 100644 index 0000000..cb3cf08 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/mybatis/EntityTableMapper.java @@ -0,0 +1,187 @@ +package cn.cordys.mybatis; + +import cn.cordys.common.util.CaseFormatUtils; +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.beans.Transient; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Stream; + +/** + * 工具类:EntityTableMapper + *

    + * 提供基于反射的字段操作,包括字段过滤、表信息提取等功能。 + *

    + */ +public class EntityTableMapper { + + private static final String FIELD_SEP_TAG = "`"; + + /** + * 获取实体类的表信息。 + * + * @param entityClass 实体类 + * + * @return 表信息对象 + */ + public static EntityTable extractTableInfo(Class entityClass) { + Predicate fieldFilter = field -> !"serialVersionUID".equals(field.getName()); + + Field[] fields = filterFieldsWithoutNoColumnAnnotation(getAllFields(entityClass, fieldFilter)); + + EntityTable info = new EntityTable(); + info.setEntityClass(entityClass); + info.setFields(fields); + info.setTableName(generateTableName(entityClass)); + info.setPrimaryKeyColumn(determinePrimaryKey(fields, "id")); + info.setColumns(mapFieldsToColumnNames(fields)); + info.setSelectColumns(mapFieldsToSelectColumnNames(fields)); + return info; + } + + /** + * 获取字段对应的值。 + * + * @param bean 对象实例 + * @param field 字段 + * + * @return 字段值 + */ + public static synchronized Object getFieldValue(Object bean, Field field) { + if (bean == null) { + return null; + } + try { + field.setAccessible(true); + return field.get(bean); + } catch (IllegalAccessException e) { + throw new IllegalStateException("无法访问字段:" + field.getName(), e); + } finally { + field.setAccessible(false); + } + } + + /** + * 确定主键字段对应的数据库列名。 + * + * @param fields 实体类的字段数组 + * @param defaultPrimaryKey 默认主键名 + * + * @return 主键的列名 + */ + public static String determinePrimaryKey(Field[] fields, String defaultPrimaryKey) { + return Stream.of(fields) + .filter(field -> field.isAnnotationPresent(Id.class)) + .findFirst() + .map(EntityTableMapper::getColumnName) + .orElse(defaultPrimaryKey); + } + + /** + * 生成表名(默认采用驼峰转下划线规则)。 + * + * @param entityType 实体类 + * + * @return 表名 + */ + public static String generateTableName(Class entityType) { + // 尝试获取 @Table 注解 + return Optional.ofNullable(entityType.getAnnotation(Table.class)) + .map(Table::name) // 如果有注解,获取 name 属性值 + .filter(name -> !name.isEmpty()) // 确保 name 属性非空 + .orElseGet(() -> CaseFormatUtils.camelToUnderscore(entityType.getSimpleName())); // 否则使用默认策略 + } + + /** + * 将字段数组映射为数据库列名数组。 + * + * @param fields 字段数组 + * + * @return 列名数组 + */ + public static String[] mapFieldsToColumnNames(Field[] fields) { + return Stream.of(fields) + .map(EntityTableMapper::getColumnName) + .toArray(String[]::new); + } + + /** + * 过滤掉带有 @NoColumn 注解的字段。 + * + * @param fields 字段数组 + * + * @return 过滤后的字段数组 + */ + public static Field[] filterFieldsWithoutNoColumnAnnotation(Field[] fields) { + return Stream.of(fields) + .filter(field -> !field.isAnnotationPresent(Transient.class)) + .toArray(Field[]::new); + } + + /** + * 将字段数组映射为查询列名数组(支持别名)。 + * + * @param fields 字段数组 + * + * @return 查询列名数组 + */ + public static String[] mapFieldsToSelectColumnNames(Field[] fields) { + return Stream.of(fields) + .map(EntityTableMapper::getSelectColumnName) + .toArray(String[]::new); + } + + /** + * 获取字段的数据库列名(带下划线)。 + * + * @param field 字段 + * + * @return 数据库列名 + */ + public static String getColumnName(Field field) { + Column columnAnnotation = field.getAnnotation(Column.class); + if (columnAnnotation != null && !columnAnnotation.name().isEmpty()) { + return columnAnnotation.name(); + } + return FIELD_SEP_TAG + CaseFormatUtils.camelToUnderscore(field.getName()) + FIELD_SEP_TAG; + } + + /** + * 获取字段的查询列名(带别名)。 + * + * @param field 字段 + * + * @return 查询列名 + */ + public static String getSelectColumnName(Field field) { + return getColumnName(field) + " AS " + FIELD_SEP_TAG + field.getName() + FIELD_SEP_TAG; + } + + + /** + * 获取指定类的所有字段(包括父类)。 + * + * @param clazz 类 + * @param fieldFilter 字段过滤器 + * + * @return 字段数组 + */ + public static Field[] getAllFields(Class clazz, Predicate fieldFilter) { + List fields = new ArrayList<>(); + while (clazz != null && clazz != Object.class) { + for (Field field : clazz.getDeclaredFields()) { + if (fieldFilter == null || fieldFilter.test(field)) { + fields.add(field); + } + } + clazz = clazz.getSuperclass(); + } + return fields.toArray(new Field[0]); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/mybatis/interceptor/MybatisInterceptor.java b/backend/framework/src/main/java/cn/cordys/mybatis/interceptor/MybatisInterceptor.java new file mode 100644 index 0000000..c985020 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/mybatis/interceptor/MybatisInterceptor.java @@ -0,0 +1,250 @@ +package cn.cordys.mybatis.interceptor; + +import cn.cordys.common.util.BeanUtils; +import cn.cordys.config.MybatisInterceptorConfig; +import lombok.Getter; +import lombok.Setter; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.ibatis.cache.CacheKey; +import org.apache.ibatis.executor.Executor; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.plugin.*; +import org.apache.ibatis.session.ResultHandler; +import org.apache.ibatis.session.RowBounds; + +import java.lang.reflect.Method; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * MyBatis 拦截器,用于对数据库操作的参数进行加密或解密处理。 + *

    + * 本拦截器支持对更新和查询操作的参数进行加密处理, + * 并在返回结果时根据配置进行解密操作。 + *

    + */ +@Intercepts({ + @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}), + @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), + @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}), +}) +public class MybatisInterceptor implements Interceptor { + + private final ConcurrentHashMap classMap = new ConcurrentHashMap<>(); + private final ConcurrentHashMap>> interceptorConfigMap = new ConcurrentHashMap<>(); + @Setter + @Getter + private List interceptorConfigList; + + /** + * 拦截目标方法并进行加密或解密处理。 + * + * @param invocation 当前调用的信息 + * + * @return 处理后的结果对象 + * + * @throws Throwable 如果处理过程中出现异常 + */ + @Override + public Object intercept(Invocation invocation) throws Throwable { + String methodName = invocation.getMethod().getName(); + Object parameter = invocation.getArgs()[1]; + + // 对更新操作的参数进行处理 + if (parameter != null && methodName.equals("update")) { + invocation.getArgs()[1] = process(parameter); + } + + // 执行目标方法 + Object returnValue = invocation.proceed(); + + // 对查询结果进行解密处理 + if (returnValue instanceof ArrayList list) { + List decryptedList = new ArrayList<>(list.size()); + boolean anyDecrypted = false; + for (Object val : list) { + Object decrypted = undo(val); + if (decrypted != val) { + anyDecrypted = true; + } + decryptedList.add(decrypted); + } + return anyDecrypted ? decryptedList : returnValue; + } else { + return undo(returnValue); + } + } + + /** + * 获取与指定对象相关的配置。 + * + * @param p 目标对象 + * + * @return 对象的配置信息 + */ + private Map> getConfig(Object p) { + if (p == null) { + return null; + } + + String pClassName = p.getClass().getName(); + Map> cached = interceptorConfigMap.get(pClassName); + if (cached != null) { + return cached; + } + + return interceptorConfigMap.computeIfAbsent(pClassName, k -> { + Map> result = new HashMap<>(); + for (MybatisInterceptorConfig interceptorConfig : interceptorConfigList) { + String className = interceptorConfig.getModelName(); + String attrName = interceptorConfig.getAttrName(); + if (StringUtils.isNotBlank(className)) { + Class c = classMap.computeIfAbsent(className, cn -> { + try { + return Class.forName(cn); + } catch (ClassNotFoundException e) { + return null; + } + }); + if (c != null && c.isInstance(p)) { + Map methods = result.computeIfAbsent(attrName, a -> new HashMap<>()); + if (StringUtils.isNotBlank(interceptorConfig.getInterceptorMethod()) + && StringUtils.isNotBlank(interceptorConfig.getInterceptorClass())) { + methods.put(Methods.encrypt.name(), interceptorConfig); + } + if (StringUtils.isNotBlank(interceptorConfig.getUndoMethod()) + && StringUtils.isNotBlank(interceptorConfig.getUndoClass())) { + methods.put(Methods.decrypt.name(), interceptorConfig); + } + } + } + } + return result; + }); + } + + /** + * 处理加密操作。 + * + * @param obj 目标对象 + * + * @return 加密后的对象 + * + * @throws Throwable 如果加密过程中出现异常 + */ + private Object process(Object obj) throws Throwable { + if (obj instanceof Map paramMap) { + for (Object key : paramMap.keySet()) { + if (paramMap.get(key) != null) { + paramMap.put(key, process(paramMap.get(key))); + } + } + return paramMap; + } + + Map> localInterceptorConfigMap = getConfig(obj); + if (MapUtils.isEmpty(localInterceptorConfigMap)) { + return obj; + } + Object newObject = obj.getClass().getDeclaredConstructor().newInstance(); + BeanUtils.copyBean(newObject, obj); + for (String attrName : localInterceptorConfigMap.keySet()) { + if (MapUtils.isEmpty(localInterceptorConfigMap.get(attrName))) { + continue; + } + MybatisInterceptorConfig interceptorConfig = localInterceptorConfigMap.get(attrName).get(Methods.encrypt.name()); + if (interceptorConfig == null || StringUtils.isBlank(interceptorConfig.getInterceptorClass()) + || StringUtils.isBlank(interceptorConfig.getInterceptorMethod())) { + continue; + } + Object fieldValue = BeanUtils.getFieldValueByName(interceptorConfig.getAttrName(), newObject); + if (fieldValue != null) { + Class processClazz = Class.forName(interceptorConfig.getInterceptorClass()); + Method method = processClazz.getMethod(interceptorConfig.getInterceptorMethod(), Object.class); + Object processedValue = method.invoke(null, fieldValue); + if (processedValue instanceof byte[]) { + BeanUtils.setFieldValueByName(newObject, interceptorConfig.getAttrName(), processedValue, byte[].class); + } else { + BeanUtils.setFieldValueByName(newObject, interceptorConfig.getAttrName(), processedValue, fieldValue.getClass()); + } + } + } + + return newObject; + } + + /** + * 处理解密操作。 + * + * @param obj 目标对象 + * + * @return 解密后的对象 + * + * @throws Throwable 如果解密过程中出现异常 + */ + private Object undo(Object obj) throws Throwable { + Map> localDecryptConfigMap = getConfig(obj); + if (MapUtils.isEmpty(localDecryptConfigMap)) { + return obj; + } + Object result = obj.getClass().getDeclaredConstructor().newInstance(); + BeanUtils.copyBean(result, obj); + + for (String attrName : localDecryptConfigMap.keySet()) { + if (MapUtils.isEmpty(localDecryptConfigMap.get(attrName))) { + continue; + } + MybatisInterceptorConfig interceptorConfig = localDecryptConfigMap.get(attrName).get(Methods.decrypt.name()); + if (interceptorConfig == null || StringUtils.isBlank(interceptorConfig.getUndoClass()) + || StringUtils.isBlank(interceptorConfig.getUndoMethod())) { + continue; + } + Object fieldValue = BeanUtils.getFieldValueByName(interceptorConfig.getAttrName(), result); + if (fieldValue != null) { + Class processClazz = Class.forName(interceptorConfig.getUndoClass()); + Object undoValue; + if (fieldValue instanceof List) { + Method method = processClazz.getMethod(interceptorConfig.getUndoMethod(), List.class, String.class); + method.invoke(null, fieldValue, interceptorConfig.getAttrNameForList()); + } else { + Method method = processClazz.getMethod(interceptorConfig.getUndoMethod(), Object.class); + undoValue = method.invoke(null, fieldValue); + if (undoValue instanceof byte[]) { + BeanUtils.setFieldValueByName(result, interceptorConfig.getAttrName(), undoValue, byte[].class); + } else { + BeanUtils.setFieldValueByName(result, interceptorConfig.getAttrName(), undoValue, fieldValue.getClass()); + } + } + } + } + return result; + } + + /** + * 创建插件对象。 + * + * @param target 目标对象 + * + * @return 包装后的对象 + */ + @Override + public Object plugin(Object target) { + return Plugin.wrap(target, this); + } + + /** + * 设置属性。 + * + * @param properties 配置属性 + */ + @Override + public void setProperties(Properties properties) { + // TODO: 如果有配置需求,可以在这里处理 + } + + private enum Methods { + encrypt, decrypt + } +} diff --git a/backend/framework/src/main/java/cn/cordys/mybatis/lambda/ClassUtils.java b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/ClassUtils.java new file mode 100644 index 0000000..f8f1ace --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/ClassUtils.java @@ -0,0 +1,123 @@ +package cn.cordys.mybatis.lambda; + +import cn.cordys.common.exception.GenericException; +import org.apache.ibatis.io.Resources; + +import java.util.Arrays; +import java.util.List; + +/** + * 用于操作类的工具类,提供检查类是否为代理类以及通过不同的类加载器加载类的方法。 + */ +public final class ClassUtils { + + /** + * 代理类名称的列表。 + */ + private static final List PROXY_CLASS_NAMES = Arrays.asList( + "net.sf.cglib.proxy.Factory", // cglib + "org.springframework.cglib.proxy.Factory", // cglib + "javassist.util.proxy.ProxyObject", // javassist + "org.apache.ibatis.javassist.util.proxy.ProxyObject" // javassist + ); + private static ClassLoader systemClassLoader; + + static { + try { + systemClassLoader = ClassLoader.getSystemClassLoader(); + } catch (SecurityException ignored) { + // Google App Engine 中的 AccessControlException 异常 + } + } + + // 私有构造函数,防止实例化 + private ClassUtils() { + } + + /** + * 判断传入的类型是否是布尔类型(包括原生布尔和包装类型布尔)。 + * + * @param type 要检查的类类型。 + * + * @return 如果是布尔类型或其包装类型,则返回 {@code true},否则返回 {@code false}。 + */ + public static boolean isBoolean(Class type) { + return type == boolean.class || Boolean.class == type; + } + + /** + * 判断传入的类是否为代理类。 + * + * @param clazz 要检查的类。 + * + * @return 如果类是代理类,返回 {@code true},否则返回 {@code false}。 + */ + public static boolean isProxy(Class clazz) { + if (clazz != null) { + for (Class cls : clazz.getInterfaces()) { + if (PROXY_CLASS_NAMES.contains(cls.getName())) { + return true; + } + } + } + return false; + } + + /** + * 使用指定的类名和类加载器加载类。该方法确保类是从有效的类加载器中加载的。 + * + * @param name 类的全限定名。 + * @param classLoader 类加载器。 + * + * @return 与类名对应的类对象。 + * + * @throws GenericException 如果类无法找到或加载。 + */ + public static Class toClassConfident(String name, ClassLoader classLoader) { + try { + return loadClass(name, getClassLoaders(classLoader)); + } catch (ClassNotFoundException e) { + throw new GenericException("找不到指定的class!请仅在明确确定会有class的时候,调用该方法", e); + } + } + + /** + * 尝试通过多个类加载器加载类。 + * + * @param className 类的全限定名。 + * @param classLoaders 类加载器数组。 + * + * @return 与类名对应的类对象。 + * + * @throws ClassNotFoundException 如果无法找到类。 + */ + private static Class loadClass(String className, ClassLoader[] classLoaders) throws ClassNotFoundException { + for (ClassLoader classLoader : classLoaders) { + if (classLoader != null) { + try { + return Class.forName(className, true, classLoader); + } catch (ClassNotFoundException e) { + // 忽略异常,尝试下一个类加载器 + } + } + } + throw new ClassNotFoundException("无法找到类: " + className); + } + + /** + * 获取用于加载类的类加载器数组。 + * + * @param classLoader 主类加载器。 + * + * @return 用于加载类的类加载器数组。 + */ + private static ClassLoader[] getClassLoaders(ClassLoader classLoader) { + return new ClassLoader[]{ + classLoader, + Resources.getDefaultClassLoader(), + Thread.currentThread().getContextClassLoader(), + ClassUtils.class.getClassLoader(), + systemClassLoader + }; + } +} diff --git a/backend/framework/src/main/java/cn/cordys/mybatis/lambda/LambdaMeta.java b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/LambdaMeta.java new file mode 100644 index 0000000..914f7cc --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/LambdaMeta.java @@ -0,0 +1,32 @@ +package cn.cordys.mybatis.lambda; + +/** + * Lambda 信息 + */ +public abstract class LambdaMeta { + + /** + * 获取 lambda 表达式实现方法的名称 + * + * @return lambda 表达式对应的实现方法名称 + */ + String getImplMethodName() { + return ""; + } + + /** + * 实例化该方法的类 + * + * @return 返回对应的类名称 + */ + Class getInstantiatedClass() { + return null; + } + + String toSnakeCase(String methodName) { + String fieldName = methodName.replaceAll("^get|set", ""); + // 使用正则将大写字母转换为小写并加上下划线 + String result = fieldName.replaceAll("([a-z])([A-Z])", "$1_$2"); + return result.toLowerCase(); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/mybatis/lambda/LambdaQueryWrapper.java b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/LambdaQueryWrapper.java new file mode 100644 index 0000000..4ac5e66 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/LambdaQueryWrapper.java @@ -0,0 +1,281 @@ +package cn.cordys.mybatis.lambda; + +import lombok.Getter; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; + +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * LambdaQueryWrapper 用于构建 SQL 查询条件,支持链式调用。 + * 它通过 Lambda 表达式动态指定查询字段,避免硬编码字段名,增强代码的可维护性和类型安全。 + * + * @param 实体类型 + */ +public class LambdaQueryWrapper { + // 存储查询条件 + private final List conditions = new LinkedList<>(); + + @Getter + private final Map params = new HashMap<>(); + + // 存储排序条件 + private final List orderByClauses = new LinkedList<>(); + + /** + * 返回 true 表示存在 SQL 注入风险 + * + * @param script + */ + public static void checkSqlInjection(String script) { + if (StringUtils.isEmpty(script)) { + return; + } + // 检测危险SQL模式 + java.util.regex.Pattern dangerousPattern = java.util.regex.Pattern.compile( + "(;|--|#|'|\"|/\\*|\\*/|\\b(select|insert|update|delete|drop|alter|truncate|exec|union|xp_)\\b)", + java.util.regex.Pattern.CASE_INSENSITIVE); + + // 返回true表示存在注入风险 + if (dangerousPattern.matcher(script).find()) { + throw new IllegalArgumentException("SQL injection risk detected in script: " + script); + } + } + + private void addCondition(XFunction column, Object value, String operator) { + if (ObjectUtils.isEmpty(value)) { + return; // 如果值为 null,则不添加条件 + } + + String columnName = columnToString(column); + String paramName = generateParamName(columnName, operator); + addCondition("%s %s #{%s}".formatted(columnName, operator, paramName)); + params.put(paramName, value); + } + + private String generateParamName(String column, String operator) { + return switch (operator) { + case "=" -> "%s_eq".formatted(column); + case "!=" -> "%s_nq".formatted(column); + case "LIKE" -> "%s_like".formatted(column); + case ">" -> "%s_gt".formatted(column); + case "<" -> "%s_lt".formatted(column); + case " ]]>" -> "%s_gt_t".formatted(column); + case "" -> "%s_lt_t".formatted(column); + default -> column; + }; + } + + /** + * 添加等值条件(=)。 + * + * @param column 列名的 Lambda 表达式 + * @param value 值 + * + * @return 当前 LambdaQueryWrapper 实例 + */ + public LambdaQueryWrapper eq(XFunction column, Object value) { + addCondition(column, value, "="); + return this; + } + + /** + * 添加不等值条件(!=)。 + * + * @param column 列名的 Lambda 表达式 + * @param value 值 + */ + public void nq(XFunction column, Object value) { + addCondition(column, value, "!="); + } + + /** + * 添加模糊匹配条件(LIKE)。 + * + * @param column 列名的 Lambda 表达式 + * @param value 值 + * + * @return 当前 LambdaQueryWrapper 实例 + */ + public LambdaQueryWrapper like(XFunction column, Object value) { + if (ObjectUtils.isEmpty(value)) { + return this; + } + addCondition(column, "%" + value + "%", "LIKE"); + return this; + } + + /** + * 添加大于条件(>)。 + * + * @param column 列名的 Lambda 表达式 + * @param value 值 + * + * @return 当前 LambdaQueryWrapper 实例 + */ + public LambdaQueryWrapper gt(XFunction column, Object value) { + addCondition(column, value, ">"); + return this; + } + + /** + * 时间类型的添加大于条件(>)。 + * + * @param column 列名的 Lambda 表达式 + * @param value 值 + * + * @return 当前 LambdaQueryWrapper 实例 + */ + public LambdaQueryWrapper gtT(XFunction column, Object value) { + addCondition(column, value, " ]]>"); + return this; + } + + /** + * 添加小于条件(<)。 + * + * @param column 列名的 Lambda 表达式 + * @param value 值 + * + * @return 当前 LambdaQueryWrapper 实例 + */ + public LambdaQueryWrapper lt(XFunction column, Object value) { + addCondition(column, value, "<"); + return this; + } + + /** + * 时间类型的添加小于条件(<)。 + * + * @param column 列名的 Lambda 表达式 + * @param value 值 + * + * @return 当前 LambdaQueryWrapper 实例 + */ + public LambdaQueryWrapper ltT(XFunction column, Object value) { + addCondition(column, value, ""); + return this; + } + + /** + * 添加范围条件(BETWEEN)。 + * + * @param column 列名的 Lambda 表达式 + * @param start 起始值 + * @param end 结束值 + * + * @return 当前 LambdaQueryWrapper 实例 + */ + public LambdaQueryWrapper between(XFunction column, Object start, Object end) { + String columnName = columnToString(column); + String paramStartKey = "start_" + columnName; + String paramEndKey = "end_" + columnName; + + String condition = String.format("%s BETWEEN #{%s} AND #{%s}", columnName, paramStartKey, paramEndKey); + addCondition(condition); + + params.put(paramStartKey, start); + params.put(paramEndKey, end); + + return this; + } + + /** + * 添加 IN 条件。 + * + * @param column 列名的 Lambda 表达式 + * @param valueList 值的集合 + * + * @return 当前 LambdaQueryWrapper 实例 + */ + public LambdaQueryWrapper in(XFunction column, List valueList) { + if (valueList == null || valueList.isEmpty()) { + return this; + } + + String columnName = columnToString(column); + String paramKey = "array_" + columnName; + String inClause = String.format(""" + %s IN + + #{item} + + """, columnName, paramKey); + + addCondition(inClause); + params.put(paramKey, valueList); + return this; + } + + /** + * 添加升序排序。 + * + * @param column 列名的 Lambda 表达式 + */ + public void orderByAsc(XFunction column) { + String columnName = columnToString(column); + checkSqlInjection(columnName); + String paramKey = String.format("order_%s", columnName); + + orderByClauses.add(String.format("#{%s} ASC", paramKey)); + params.put(paramKey, columnName); + } + + /** + * 添加降序排序。 + * + * @param column 列名的 Lambda 表达式 + */ + public LambdaQueryWrapper orderByDesc(XFunction column) { + String columnName = columnToString(column); + checkSqlInjection(columnName); + String paramKey = String.format("order_%s", columnName); + + orderByClauses.add(String.format("#{%s} DESC", paramKey)); + params.put(paramKey, columnName); + return this; + } + + /** + * 获取 WHERE 子句的字符串。 + * + * @return WHERE 子句的字符串 + */ + public String getWhereClause() { + return String.join(" AND ", conditions); + } + + /** + * 获取 ORDER BY 子句的字符串。 + * + * @return ORDER BY 子句的字符串 + */ + public String getOrderByClause() { + return orderByClauses.isEmpty() ? "" : String.join(", ", orderByClauses); + } + + /** + * 内部方法:添加条件到查询条件列表。 + * + * @param condition 条件字符串 + */ + private void addCondition(String condition) { + if (condition != null && !condition.trim().isEmpty()) { + conditions.add(condition); + } + } + + /** + * 内部方法:将 Lambda 表达式转换为字段名。 + * + * @param column 列名的 Lambda 表达式 + * + * @return 转换后的字段名 + */ + private String columnToString(XFunction column) { + return LambdaUtils.extract(column); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/mybatis/lambda/LambdaUtils.java b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/LambdaUtils.java new file mode 100644 index 0000000..b28cce7 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/LambdaUtils.java @@ -0,0 +1,47 @@ +package cn.cordys.mybatis.lambda; + + +import lombok.extern.slf4j.Slf4j; + +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + +/** + * Lambda 解析工具类。 + *

    + * 该工具类提供了从 Lambda 表达式中提取方法名称的功能,支持多种方式,包括代理、反射和序列化。 + *

    + */ +@Slf4j +public final class LambdaUtils { + + /** + * 提取 Lambda 表达式的实现方法名称。 + *

    + * 根据不同的环境,方法会尝试通过代理、反射或序列化的方式来解析 Lambda 表达式。 + *

    + * + * @param func 需要解析的 Lambda 对象。 + * + * @return 返回 Lambda 表达式的实现方法名称。 + */ + public static String extract(XFunction func) { + // 1. IDEA 调试模式下,Lambda 表达式是一个代理对象 + if (func instanceof Proxy) { + ProxyLambdaMeta lambdaMeta = new ProxyLambdaMeta((Proxy) func); + return lambdaMeta.getImplMethodName(); + } + + // 2. 通过反射读取 Lambda 表达式中的元信息 + try { + Method method = func.getClass().getDeclaredMethod("writeReplace"); + method.setAccessible(true); + ReflectLambdaMeta lambdaMeta = new ReflectLambdaMeta((java.lang.invoke.SerializedLambda) method.invoke(func), func.getClass().getClassLoader()); + return lambdaMeta.getImplMethodName(); + } catch (Throwable e) { + // 3. 反射失败时,使用序列化方式读取 Lambda 元信息 + log.error("Extract lambda meta error", e); + return new ShadowLambdaMeta(SerializedLambda.extract(func)).getImplMethodName(); + } + } +} diff --git a/backend/framework/src/main/java/cn/cordys/mybatis/lambda/ProxyLambdaMeta.java b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/ProxyLambdaMeta.java new file mode 100644 index 0000000..a52b690 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/ProxyLambdaMeta.java @@ -0,0 +1,39 @@ +package cn.cordys.mybatis.lambda; + + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandleProxies; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Executable; +import java.lang.reflect.Proxy; + +/** + * 在 IDEA 的 Evaluate 中执行的 Lambda 表达式元数据需要使用该类处理元数据 + */ +public class ProxyLambdaMeta extends LambdaMeta { + private final Class clazz; + private final String name; + + public ProxyLambdaMeta(Proxy func) { + MethodHandle dmh = MethodHandleProxies.wrapperInstanceTarget(func); + Executable executable = MethodHandles.reflectAs(Executable.class, dmh); + clazz = executable.getDeclaringClass(); + name = executable.getName(); + } + + @Override + public String getImplMethodName() { + return toSnakeCase(name); + } + + @Override + public Class getInstantiatedClass() { + return clazz; + } + + @Override + public String toString() { + return clazz.getSimpleName() + "::" + name; + } + +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/mybatis/lambda/ReflectLambdaMeta.java b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/ReflectLambdaMeta.java new file mode 100644 index 0000000..1203e00 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/ReflectLambdaMeta.java @@ -0,0 +1,50 @@ +package cn.cordys.mybatis.lambda; + +import jodd.util.StringPool; + +import java.lang.invoke.SerializedLambda; + +/** + * 通过反射提供 Lambda 表达式元数据的类。 + * 该类提供了获取 Lambda 实现方法名称和实例化类的功能。 + */ +public class ReflectLambdaMeta extends LambdaMeta { + + private final SerializedLambda lambda; + private final ClassLoader classLoader; + + /** + * 构造函数,初始化 ReflectLambdaMeta 实例。 + * + * @param lambda 被序列化的 Lambda 表达式 + * @param classLoader 用于加载类的类加载器 + */ + public ReflectLambdaMeta(SerializedLambda lambda, ClassLoader classLoader) { + this.lambda = lambda; + this.classLoader = classLoader; + } + + /** + * 获取 Lambda 实现方法的名称,并转换为 snake_case 格式。 + * + * @return Lambda 实现方法的名称(snake_case 格式) + */ + @Override + public String getImplMethodName() { + return toSnakeCase(lambda.getImplMethodName()); + } + + /** + * 获取 Lambda 表达式实例化的类。 + * 通过类加载器加载相应的类。 + * + * @return Lambda 表达式实例化的类 + */ + @Override + public Class getInstantiatedClass() { + String instantiatedMethodType = lambda.getInstantiatedMethodType(); + String instantiatedType = instantiatedMethodType.substring(2, instantiatedMethodType.indexOf(StringPool.SEMICOLON)).replace(StringPool.SLASH, StringPool.DOT); + return ClassUtils.toClassConfident(instantiatedType, this.classLoader); + } + +} diff --git a/backend/framework/src/main/java/cn/cordys/mybatis/lambda/SerializedLambda.java b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/SerializedLambda.java new file mode 100644 index 0000000..3877643 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/SerializedLambda.java @@ -0,0 +1,85 @@ +package cn.cordys.mybatis.lambda; + +import cn.cordys.common.exception.GenericException; + +import java.io.*; + +/** + * 当前类是 {@link java.lang.invoke.SerializedLambda} 的一个镜像。 + *

    + * 该类用于序列化并提取 Lambda 表达式的元数据,提供了与原 {@link SerializedLambda} 类相同的功能, + * 但可以在自定义序列化环境中使用。 + *

    + */ +@SuppressWarnings("ALL") +public class SerializedLambda implements Serializable { + private static final long serialVersionUID = 8025925345765570181L; + + private Class capturingClass; + private String functionalInterfaceClass; + private String functionalInterfaceMethodName; + private String functionalInterfaceMethodSignature; + private String implClass; + private String implMethodName; + private String implMethodSignature; + private int implMethodKind; + private String instantiatedMethodType; + private Object[] capturedArgs; + + /** + * 从序列化的 Lambda 表达式中提取 {@link SerializedLambda} 对象。 + * 该方法通过序列化和反序列化过程提取 Lambda 表达式的元信息。 + * + * @param serializable 可序列化对象,通常为 Lambda 表达式。 + * + * @return 提取的 {@link SerializedLambda} 对象。 + * + * @throws GenericException 如果序列化或反序列化过程中出现异常。 + */ + public static SerializedLambda extract(Serializable serializable) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(serializable); + oos.flush(); + + try (ObjectInputStream objectInputStream = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray())) { + @Override + protected Class resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { + Class clazz = super.resolveClass(desc); + return clazz == java.lang.invoke.SerializedLambda.class ? SerializedLambda.class : clazz; + } + }) { + return (SerializedLambda) objectInputStream.readObject(); + } + } catch (IOException | ClassNotFoundException e) { + throw new GenericException("提取 Lambda 表达式时发生异常", e); + } + } + + /** + * 获取实例化方法类型的字符串。 + * + * @return 实例化方法类型的字符串。 + */ + public String getInstantiatedMethodType() { + return instantiatedMethodType; + } + + /** + * 获取捕获类的 {@link Class} 对象。 + * + * @return 捕获类的 {@link Class} 对象。 + */ + public Class getCapturingClass() { + return capturingClass; + } + + /** + * 获取实现方法的名称。 + * + * @return 实现方法的名称。 + */ + public String getImplMethodName() { + return implMethodName; + } +} diff --git a/backend/framework/src/main/java/cn/cordys/mybatis/lambda/ShadowLambdaMeta.java b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/ShadowLambdaMeta.java new file mode 100644 index 0000000..c2e18f3 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/ShadowLambdaMeta.java @@ -0,0 +1,50 @@ +package cn.cordys.mybatis.lambda; + +import jodd.util.StringPool; + +/** + * 基于 {@link SerializedLambda} 创建的元信息类。 + *

    + * 该类提供了获取 Lambda 实现方法名称和实例化类的功能。 + * 它扩展了 {@link LambdaMeta} 类,提供了对 Lambda 表达式的元数据的访问。 + *

    + */ +public class ShadowLambdaMeta extends LambdaMeta { + private final SerializedLambda lambda; + + /** + * 构造方法,接收一个 {@link SerializedLambda} 对象。 + * + * @param lambda {@link SerializedLambda} 对象,包含 Lambda 表达式的序列化信息。 + */ + public ShadowLambdaMeta(SerializedLambda lambda) { + this.lambda = lambda; + } + + /** + * 获取 Lambda 实现的方法名称,并将其转换为蛇形命名法(snake_case)。 + * + * @return 转换后的方法名称。 + */ + @Override + public String getImplMethodName() { + return toSnakeCase(lambda.getImplMethodName()); + } + + /** + * 获取实例化类的 {@link Class} 对象。 + * 通过从 {@link SerializedLambda} 获取实例化类型的字符串,并将其转换为 {@link Class} 对象。 + * + * @return 实例化类的 {@link Class} 对象。 + */ + @Override + public Class getInstantiatedClass() { + String instantiatedMethodType = lambda.getInstantiatedMethodType(); + // 截取并转换类型名称 + String instantiatedType = instantiatedMethodType + .substring(2, instantiatedMethodType.indexOf(StringPool.SEMICOLON)) + .replace(StringPool.SLASH, StringPool.DOT); + // 使用类加载器加载实例化类 + return ClassUtils.toClassConfident(instantiatedType, lambda.getClass().getClassLoader()); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/mybatis/lambda/XFunction.java b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/XFunction.java new file mode 100644 index 0000000..92f1aaf --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/mybatis/lambda/XFunction.java @@ -0,0 +1,11 @@ +package cn.cordys.mybatis.lambda; + +import java.io.Serializable; +import java.util.function.Function; + +/** + * 支持序列化的 Function + */ +@FunctionalInterface +public interface XFunction extends Function, Serializable { +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/registry/ExportThreadRegistry.java b/backend/framework/src/main/java/cn/cordys/registry/ExportThreadRegistry.java new file mode 100644 index 0000000..a706925 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/registry/ExportThreadRegistry.java @@ -0,0 +1,71 @@ +package cn.cordys.registry; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * 导出线程注册表,用于管理和监控导出任务的线程。 + */ +public final class ExportThreadRegistry { + private static final ConcurrentHashMap TASK_MAP = new ConcurrentHashMap<>(); + + // 私有构造函数防止实例化 + private ExportThreadRegistry() { + throw new UnsupportedOperationException("工具类不应被实例化"); + } + + /** + * 注册任务线程 + * + * @param taskId 任务ID + * @param thread 任务线程 + */ + public static void register(String taskId, Thread thread) { + if (taskId != null && thread != null) { + TASK_MAP.put(taskId, thread); + } + } + + /** + * 停止任务线程并中断它 + * + * @param taskId 任务ID + */ + public static void stop(String taskId) { + Thread thread = TASK_MAP.remove(taskId); + if (thread != null) { + thread.interrupt(); + } + } + + /** + * 检查任务是否已被中断 + * + * @param taskId 任务ID + * + * @return 如果任务不存在或已被中断返回true,否则返回false + */ + public static boolean isInterrupted(String taskId) { + Thread thread = TASK_MAP.get(taskId); + return thread == null || thread.isInterrupted(); + } + + /** + * 从注册表中移除任务 + * + * @param taskId 任务ID + */ + public static void remove(String taskId) { + TASK_MAP.remove(taskId); + } + + /** + * 检查任务是否存在 + * + * @param taskId 任务ID + * + * @return 如果任务存在返回true + */ + public static boolean exists(String taskId) { + return TASK_MAP.containsKey(taskId); + } +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/security/SessionConstants.java b/backend/framework/src/main/java/cn/cordys/security/SessionConstants.java new file mode 100644 index 0000000..5e20125 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/security/SessionConstants.java @@ -0,0 +1,10 @@ +package cn.cordys.security; + +public class SessionConstants { + public static final String ATTR_USER = "user"; + public static final String AUTHENTICATION_STATUS = "Authentication-Status"; + public static final String AUTHENTICATION_INVALID = "invalid"; + + public final static String HEADER_TOKEN = "X-AUTH-TOKEN"; + public final static String CSRF_TOKEN = "CSRF-TOKEN"; +} diff --git a/backend/framework/src/main/java/cn/cordys/security/SessionUser.java b/backend/framework/src/main/java/cn/cordys/security/SessionUser.java new file mode 100644 index 0000000..b7526bb --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/security/SessionUser.java @@ -0,0 +1,76 @@ +package cn.cordys.security; + +import cn.cordys.common.util.CodingUtils; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.BeanUtils; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Arrays; +import java.util.List; + +/** + *

    表示会话中的用户信息,继承自 {@link UserDTO},并包含用于防止 CSRF 攻击的 token 和会话 ID。

    + */ +@Getter +@Setter +@NoArgsConstructor +public class SessionUser extends UserDTO implements Serializable { + + /** + * 加密密钥,用于生成 CSRF Token。建议从配置或环境变量中读取。 + */ + public static final String secret = RandomStringUtils.secure().nextAlphabetic(16); + + @Serial + private static final long serialVersionUID = -7149638440406959033L; + + /** + * CSRF Token,用于防止跨站请求伪造攻击。 + */ + private String csrfToken; + + /** + * 会话 ID,表示当前会话的唯一标识。 + */ + private String sessionId; + + /** + * 从 UserDTO 创建 SessionUser 对象,并生成 CSRF Token 和会话 ID。 + * + * @param user 用户数据对象 + * @param sessionId 会话 ID + * + * @return {@link SessionUser} 对象 + */ + public static SessionUser fromUser(UserDTO user, String sessionId) { + // 创建 SessionUser 实例 + SessionUser sessionUser = new SessionUser(); + // 拷贝 UserDTO 的属性到 SessionUser + BeanUtils.copyProperties(user, sessionUser); + + // 构建用于生成 CSRF Token 的信息 + List infos = Arrays.asList( + user.getId(), + RandomStringUtils.secure().nextAlphabetic(6), // 随机字符串增加多样性 + sessionId, + String.valueOf(System.currentTimeMillis()) // 当前时间戳 + ); + + try { + // 使用 AES 加密生成 CSRF Token + sessionUser.csrfToken = CodingUtils.aesEncrypt(StringUtils.join(infos, "|"), secret, CodingUtils.generateIv()); + } catch (Exception e) { + // 异常处理:加密失败时可以记录日志或者返回默认值 + sessionUser.csrfToken = StringUtils.EMPTY; + } + + // 设置 sessionId + sessionUser.sessionId = sessionId; + return sessionUser; + } +} diff --git a/backend/framework/src/main/java/cn/cordys/security/SessionUtils.java b/backend/framework/src/main/java/cn/cordys/security/SessionUtils.java new file mode 100644 index 0000000..6132bb7 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/security/SessionUtils.java @@ -0,0 +1,125 @@ +package cn.cordys.security; + +import cn.cordys.common.util.CommonBeanFactory; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang3.Strings; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.session.Session; +import org.apache.shiro.subject.Subject; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.data.redis.RedisIndexedSessionRepository; + +import java.util.Map; + +import static cn.cordys.security.SessionConstants.ATTR_USER; + + +/** + * Session 工具类,提供操作用户 Session 的常用方法。 + *

    + * 包含获取当前用户信息、获取 Session ID、踢除用户等功能。 + *

    + */ +@Slf4j +public class SessionUtils { + + /** + * 获取当前用户的 ID。 + * + * @return 当前用户的 ID,如果没有获取到用户信息,则返回 null + */ + public static String getUserId() { + SessionUser user = getUser(); + return user == null ? null : user.getId(); + } + + /** + * 获取当前用户信息。 + * + * @return 当前用户对象,如果未获取到用户信息,则返回 null + */ + public static SessionUser getUser() { + try { + Subject subject = SecurityUtils.getSubject(); + Session session = subject.getSession(); + return (SessionUser) session.getAttribute(ATTR_USER); + } catch (Exception e) { + log.warn("后台获取在线用户失败: {}", e.getMessage()); + return null; + } + } + + /** + * 获取当前 Session 的 ID。 + * + * @return 当前 Session 的 ID + */ + public static String getSessionId() { + try { + return (String) SecurityUtils.getSubject().getSession().getId(); + } catch (Exception e) { + return null; + } + } + + /** + * 踢除指定用户名的用户(从 Redis 会话中删除)。 + * + * @param username 用户名 + */ + public static void kickOutUser(String username) { + // 获取 Redis session 存储库 + RedisIndexedSessionRepository sessionRepository = CommonBeanFactory.getBean(RedisIndexedSessionRepository.class); + if (sessionRepository == null) { + return; + } + + // 根据用户名查找会话 + Map users = sessionRepository.findByPrincipalName(username); + if (MapUtils.isNotEmpty(users)) { + // 删除所有与该用户名关联的 session + users.keySet().forEach(k -> { + sessionRepository.deleteById(k); + sessionRepository.getSessionRedisOperations().delete("spring:session:sessions:" + k); + }); + } + } + + /** + * 踢除指定用户(从 Redis 会话中删除)。 + * + * @param operatorId 操作用户 ID + * @param kickUserId 被踢用户 ID + */ + public static void kickOutUser(String operatorId, String kickUserId) { + // 处理用户会话 + boolean isSelfReset = Strings.CS.equals(operatorId, kickUserId); + if (isSelfReset) { + // 当前用户重置自己的密码,直接登出 + SecurityUtils.getSubject().logout(); + // 需要检查是否有其他会话存在 + try { + SessionUtils.kickOutUser(kickUserId); + } catch (Exception e) { + log.error("踢出用户失败: {}", e.getMessage()); + } + } else { + // 管理员重置他人密码,踢出该用户 + SessionUtils.kickOutUser(kickUserId); + } + + } + + /** + * 将当前用户信息保存到 Session 中。 + * + * @param sessionUser 当前用户对象 + */ + public static void putUser(SessionUser sessionUser) { + // 保存用户信息到 Session + SecurityUtils.getSubject().getSession().setAttribute(ATTR_USER, sessionUser); + // 保存用户 ID 到 Session + SecurityUtils.getSubject().getSession().setAttribute(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, sessionUser.getId()); + } +} diff --git a/backend/framework/src/main/java/cn/cordys/security/ShiroFilter.java b/backend/framework/src/main/java/cn/cordys/security/ShiroFilter.java new file mode 100644 index 0000000..68a9b53 --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/security/ShiroFilter.java @@ -0,0 +1,110 @@ +package cn.cordys.security; + +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 用于管理应用程序中过滤器链的工具类。 + * 包含加载基础过滤器链和忽略 CSRF 过滤器链的方法。 + */ +public final class ShiroFilter { + private static final Map FILTER_CHAIN_DEFINITION_MAP = new ConcurrentHashMap<>(); + + // 私有构造函数防止实例化 + private ShiroFilter() { + throw new AssertionError("工具类不应该被实例化"); + } + + /** + * 添加URL过滤规则 + * + * @param url 需要过滤的URL路径 + * @param rule 过滤规则 + */ + public static void putFilter(String url, String rule) { + if (url != null && rule != null) { + FILTER_CHAIN_DEFINITION_MAP.put(url, rule); + } + } + + /** + * 加载应用程序的基础过滤器链。 + * 该过滤器链是一个映射,关联 URL 模式和过滤规则。 + * + * @return 返回一个不可变Map,包含过滤器链定义,键是 URL 模式,值是关联的过滤规则。 + */ + public static Map loadBaseFilterChain() { + // 静态资源路径 + addStaticResourceFilters(); + + // 认证相关路径 + addAuthenticationFilters(); + + // 其他公共路径 + addPublicPathFilters(); + + return Collections.unmodifiableMap(FILTER_CHAIN_DEFINITION_MAP); + } + + /** + * 添加静态资源过滤器规则 + */ + private static void addStaticResourceFilters() { + FILTER_CHAIN_DEFINITION_MAP.put("/web/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/mobile/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/static/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/templates/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/*.html", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/css/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/js/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/images/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/assets/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/fonts/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/favicon.ico", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/logo.*", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/base-display/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/cordys/**", "anon"); + } + + /** + * 添加认证相关过滤器规则 + */ + private static void addAuthenticationFilters() { + FILTER_CHAIN_DEFINITION_MAP.put("/login", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/logout", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/is-login", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/get-key", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/403", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/sso/callback/**", "anon"); + } + + /** + * 添加其他公共路径过滤器规则 + */ + private static void addPublicPathFilters() { + FILTER_CHAIN_DEFINITION_MAP.put("/display/info", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/pic/preview/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/attachment/preview/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/ui/display/preview", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/ui/display/info", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/anonymous/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/system/version/current", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/sse/subscribe/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/sse/close/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/sse/broadcast/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/organization/settings/third-party/types", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/organization/settings/third-party/get/**", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/organization/settings/third-party/sync/resource", "anon"); + FILTER_CHAIN_DEFINITION_MAP.put("/license/validate/**", "anon"); + } + + /** + * 返回忽略 CSRF 保护的过滤器链定义。 + * + * @return 返回一个不可变Map,包含应绕过 CSRF 检查的 URL 路径的过滤器链定义。 + */ + public static Map ignoreCsrfFilter() { + return Map.of("/", "apikey, authc", "/language", "apikey, authc", "/mock", "apikey, authc"); + } +} \ No newline at end of file diff --git a/backend/framework/src/main/java/cn/cordys/security/UserApprovalDTO.java b/backend/framework/src/main/java/cn/cordys/security/UserApprovalDTO.java new file mode 100644 index 0000000..4014d5b --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/security/UserApprovalDTO.java @@ -0,0 +1,34 @@ +package cn.cordys.security; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; + +/** + *

    用户数据传输对象,用于传输用户信息,包含其他平台对接信息和头像。

    + */ +@Data +public class UserApprovalDTO implements java.io.Serializable { + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "用户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Schema(description = "用户邮箱") + private String email; + + @Schema(description = "头像") + private String avatar; + + @Schema(description = "审核结果") + private String approveResult; + + @Schema(description = "审核原因") + private String approveReason; + +} diff --git a/backend/framework/src/main/java/cn/cordys/security/UserDTO.java b/backend/framework/src/main/java/cn/cordys/security/UserDTO.java new file mode 100644 index 0000000..fb34f5b --- /dev/null +++ b/backend/framework/src/main/java/cn/cordys/security/UserDTO.java @@ -0,0 +1,93 @@ +package cn.cordys.security; + +import cn.cordys.common.dto.RoleDataScopeDTO; +import cn.cordys.common.groups.Created; +import cn.cordys.common.groups.Updated; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.io.Serial; +import java.util.List; +import java.util.Set; + +/** + *

    用户数据传输对象,用于传输用户信息,包含其他平台对接信息和头像。

    + */ +@Data +public class UserDTO implements java.io.Serializable { + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "用户ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{user.id.not_blank}", groups = {Updated.class}) + @Size(min = 1, max = 50, message = "{user.id.length_range}", groups = {Created.class, Updated.class}) + private String id; + + @Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{user.name.not_blank}", groups = {Created.class}) + @Size(min = 1, max = 255, message = "{user.name.length_range}", groups = {Created.class, Updated.class}) + private String name; + + @Schema(description = "用户邮箱", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{user.email.not_blank}", groups = {Created.class}) + @Size(min = 1, max = 64, message = "{user.email.length_range}", groups = {Created.class, Updated.class}) + private String email; + + @Schema(description = "用户密码") + private String password; + + @Schema(description = "是否启用") + private Boolean enable; + + @Schema(description = "创建时间") + private Long createTime; + + @Schema(description = "更新时间") + private Long updateTime; + + @Schema(description = "语言") + private String language; + + @Schema(description = "当前组织ID") + private String lastOrganizationId; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "来源:LOCAL OIDC CAS OAUTH2", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "{user.source.not_blank}", groups = {Created.class}) + @Size(min = 1, max = 50, message = "{user.source.length_range}", groups = {Created.class, Updated.class}) + private String source; + + @Schema(description = "创建人") + private String createUser; + + @Schema(description = "修改人") + private String updateUser; + + @Schema(description = "其他平台对接信息", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + private byte[] platformInfo; + + @Schema(description = "头像") + private String avatar; + + @Schema(description = "权限ID") + private Set permissionIds; + + @Schema(description = "权限ID") + private List roles; + + @Schema(description = "所在的组织ID") + private Set organizationIds; + + @Schema(description = "部门id") + private String departmentId; + + @Schema(description = "部门名称") + private String departmentName; + + @Schema(description = "是否是默认密码") + private Boolean defaultPwd = false; +} diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..5b70553 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,307 @@ + + + 4.0.0 + + + cn.cordys + cordys-crm + ${revision} + + + pom + backend + ${revision} + + + framework + crm + app + + + + + + + + org.springframework.boot + spring-boot-starter-aop + + + org.springframework.boot + spring-boot-starter-jetty + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.projectlombok + lombok + true + provided + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc-openapi-ui.version} + + + io.swagger.core.v3 + swagger-core + + + + + + + org.eclipse.angus + jakarta.mail + ${mail.version} + + + + + org.mybatis.spring.boot + mybatis-spring-boot-starter + ${mybatis-starter.version} + + + com.mysql + mysql-connector-j + runtime + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + com.github.pagehelper + pagehelper + ${pagehelper.version} + + + + + org.apache.shiro + shiro-spring-boot-web-starter + ${shiro.version} + jakarta + + + org.apache.shiro + shiro-spring-boot-starter + + + org.apache.shiro + shiro-web + + + org.apache.shiro + shiro-spring + + + org.springframework.boot + spring-boot-starter-log4j2 + + + + + + org.apache.shiro + shiro-spring-boot-starter + ${shiro.version} + jakarta + + + org.apache.shiro + shiro-web + + + org.apache.shiro + shiro-spring + + + org.springframework.boot + spring-boot-starter-log4j2 + + + + + + org.apache.shiro + shiro-web + ${shiro.version} + jakarta + + + shiro-core + org.apache.shiro + + + + + + org.apache.shiro + shiro-core + ${shiro.version} + jakarta + + + + org.apache.shiro + shiro-spring + ${shiro.version} + jakarta + + + shiro-web + org.apache.shiro + + + shiro-core + org.apache.shiro + + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.vaadin.external.google + android-json + + + + + + org.springframework.cloud + spring-cloud-starter-bootstrap + 4.2.1 + test + + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + org.apache.commons + commons-lang3 + + + org.apache.commons + commons-compress + + + + + + com.playtika.testcontainers + embedded-mysql + ${embedded.version} + test + + + + com.playtika.testcontainers + embedded-redis + ${embedded.version} + test + + + + + org.springframework.session + spring-session-data-redis + + + + org.redisson + redisson-spring-boot-starter + ${redisson-starter.version} + + + spring-boot-starter-actuator + org.springframework.boot + + + + + + + org.apache.commons + commons-lang3 + ${commons-lang3.version} + + + commons-codec + commons-codec + ${commons-codec.version} + + + org.apache.commons + commons-collections4 + ${commons-collections4.version} + + + org.apache.commons + commons-text + ${commons-text.version} + + + commons-io + commons-io + ${commons-io.version} + + + org.apache.commons + commons-compress + ${commons-compress.version} + + + + + cn.cordys + quartz-spring-boot-starter + ${quartz-starter.version} + + + + + cn.idev.excel + fastexcel + ${fastexcel.version} + + + + + jakarta.persistence + jakarta.persistence-api + ${persistence-api.version} + + + + com.auth0 + java-jwt + ${jwt.version} + + + + + diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..71bf99a --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,32 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +# Editor directories and files +**/.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.vscode + +*.tsbuildinfo +.node/ +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/frontend/.husky/commit-msg b/frontend/.husky/commit-msg new file mode 100755 index 0000000..bfb74e6 --- /dev/null +++ b/frontend/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +cd frontend && npx commitlint --edit $1 diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit new file mode 100755 index 0000000..33784bb --- /dev/null +++ b/frontend/.husky/pre-commit @@ -0,0 +1,6 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +cd frontend/packages/lib-shared && npm run type:check && npm run lint-staged +cd ../mobile && npm run type:check && npm run lint-staged +cd ../web && npm run type:check && npm run lint-staged diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..9dfa85f --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,7 @@ +/dist/* +.local +.output.js +/node_modules/** + +**/*.svg +**/*.sh \ No newline at end of file diff --git a/frontend/.prettierrc.cjs b/frontend/.prettierrc.cjs new file mode 100644 index 0000000..b895767 --- /dev/null +++ b/frontend/.prettierrc.cjs @@ -0,0 +1,14 @@ +module.exports = { + plugins: [require('prettier-plugin-tailwindcss')], + tabWidth: 2, + semi: true, + singleQuote: true, + quoteProps: 'consistent', + htmlWhitespaceSensitivity: 'strict', + vueIndentScriptAndStyle: true, + useTabs: false, + trailingComma: 'es5', + printWidth: 120, + arrowParens: 'always', + endOfLine: 'auto', +}; diff --git a/frontend/.stylelintrc.cjs b/frontend/.stylelintrc.cjs new file mode 100644 index 0000000..9d10b7b --- /dev/null +++ b/frontend/.stylelintrc.cjs @@ -0,0 +1,125 @@ +module.exports = { + extends: [ + 'stylelint-config-standard', + 'stylelint-config-prettier', + 'stylelint-config-html/vue', + 'stylelint-config-recommended-less', + ], + plugins: ['stylelint-less', 'stylelint-order'], + overrides: [ + { + files: ['**/*.vue'], + customSyntax: 'postcss-html', + }, + ], + customSyntax: 'postcss-less', + ignoreFiles: ['**/*.js', '**/*.jsx', '**/*.tsx', '**/*.ts', '**/*.json', 'node_modules/**/*'], + rules: { + 'indentation': 2, + 'selector-pseudo-element-no-unknown': [ + true, + { + ignorePseudoElements: ['v-deep', ':deep'], + }, + ], + 'number-leading-zero': 'always', + 'no-descending-specificity': null, + 'function-url-quotes': 'always', + 'string-quotes': 'single', + 'unit-case': null, + 'color-hex-case': 'lower', + 'color-hex-length': 'long', + 'rule-empty-line-before': 'never', + 'font-family-no-missing-generic-family-keyword': null, + 'selector-type-no-unknown': null, + 'block-opening-brace-space-before': 'always', + 'at-rule-no-unknown': [true, { ignoreAtRules: ['apply', 'tailwind', 'variants', 'responsive', 'screen'] }], + 'no-duplicate-selectors': null, + 'property-no-unknown': null, + 'no-empty-source': null, + 'selector-class-pattern': null, + 'keyframes-name-pattern': null, + 'selector-pseudo-class-no-unknown': [true, { ignorePseudoClasses: ['global', 'deep'] }], + 'function-no-unknown': null, + 'declaration-block-no-redundant-longhand-properties': null, + 'no-descending-specificity': null, + 'order/properties-order': [ + 'position', + 'top', + 'right', + 'bottom', + 'left', + 'z-index', + 'display', + 'justify-content', + 'align-items', + 'float', + 'clear', + 'overflow', + 'overflow-x', + 'overflow-y', + 'margin', + 'margin-top', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'padding', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'width', + 'min-width', + 'max-width', + 'height', + 'min-height', + 'max-height', + 'font-size', + 'font-family', + 'font-weight', + 'border', + 'border-style', + 'border-width', + 'border-color', + 'border-top', + 'border-top-style', + 'border-top-width', + 'border-top-color', + 'border-right', + 'border-right-style', + 'border-right-width', + 'border-right-color', + 'border-bottom', + 'border-bottom-style', + 'border-bottom-width', + 'border-bottom-color', + 'border-left', + 'border-left-style', + 'border-left-width', + 'border-left-color', + 'border-radius', + 'text-align', + 'text-justify', + 'text-indent', + 'text-overflow', + 'text-decoration', + 'white-space', + 'color', + 'background', + 'background-position', + 'background-repeat', + 'background-size', + 'background-color', + 'background-clip', + 'opacity', + 'filter', + 'list-style', + 'outline', + 'visibility', + 'box-shadow', + 'text-shadow', + 'resize', + 'transition', + ], + }, +}; diff --git a/frontend/REDEME.md b/frontend/REDEME.md new file mode 100644 index 0000000..54a3ec4 --- /dev/null +++ b/frontend/REDEME.md @@ -0,0 +1,78 @@ +# Cordys CRM 前端工程 + +## 工程简介 + +使用 `monorepo` 模式管理前端工程,拆分为`lib-shared`公共资源包、`mobile`移动端工程包和`web`工程包。 + +## 工程结构 + +```plaintext +├── packages +│ ├── lib-shared # 公共库模块 +│ │ ├── api # API 封装 +│ │ ├── assets # 静态资源 +│ │ ├── enums # 枚举 +│ │ ├── hooks # 钩子函数 +│ │ ├── locale # 国际化封装 +│ │ ├── method # 工具函数 +│ │ ├── model # 数据模型 +│ │ ├── types # 全局类型声明 +│ ├── mobile # 移动端项目 +│ ├── web # WEB 端项目 +``` + +## 工程初始化&运行 + +在`/packages`目录下运行依赖安装命令: + +```node +pnpm i -w +``` + +统一构建工程: + +```node +npm run build +``` + +## mobile 移动端工程包 + +移动端工程由 Vite+Vue3+TS+Vant-UI 基础框架组成。 + +运行移动端项目: + +```node +cd package/mobile +npm run dev +``` + +在`package/mobile`下单独构建移动端项目: + +```node +npm run build +``` + +### mobile 调试&开发 + +移动端项目接入了企业微信登录,所以在 PC 上开发调试时需要模拟登录态方便快速开发调试: + +1. 先运行 `web` 项目,并登录,登录后打开控制台,将`localStorage`中的`sessionId`和`csrfToken`俩属性及值复制 +2. 运行 `mobile` 项目,打开控制台,将第 1 步复制的`localStorage`属性值粘贴后,刷新页面即可模拟完成登录(登录过期的话重新登录`web`后再复制新的属性值到`mobile`页面中替换即可) +3. 在手机端调试,进入页面授权登录后,切换到`我的`菜单,短时间内点击 10 次用户名区域可唤出`Eruda`调试工具 + +## WEB 端工程包 + +WEB 端工程由 Vite+Vue3+TS+Naive-UI 基础框架组成。 + +运行 WEB 端项目: + +```node +cd package/web +npm run dev +``` + +在`package/web`下单独构建 WEB 端项目: + +```node +npm run build +``` diff --git a/frontend/commitlint.config.cjs b/frontend/commitlint.config.cjs new file mode 100644 index 0000000..84dcb12 --- /dev/null +++ b/frontend/commitlint.config.cjs @@ -0,0 +1,3 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], +}; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2b3170f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,95 @@ +{ + "name": "vue-login-cordys", + "version": "1.0.0", + "description": "A simple Vue 3 login page cordys", + "main": "index.js", + "workspaces": [ + "packages/mobile", + "packages/web" + ], + "scripts": { + "build": "pnpm -r run build", + "prepare": "cd .. && husky install frontend/.husky" + }, + "keywords": [], + "author": "", + "license": "MIT", + "dependencies": { + "@cordys/mobile": "workspace:mobile", + "@cordys/web": "workspace:web", + "@lib/shared": "workspace:lib-shared", + "@types/node": "^22.10.2", + "@vitejs/plugin-legacy": "^6.0.0", + "@vueuse/core": "^10.11.0", + "axios": "^1.7.2", + "canvg": "^4.0.2", + "dayjs": "^1.11.11", + "dotenv": "^16.4.5", + "echarts": "^5.5.1", + "element-china-area-data": "^6.1.0", + "html2canvas-pro": "^1.5.8", + "jsencrypt": "^3.3.2", + "jspdf": "^4.2.0", + "localforage": "^1.10.0", + "lodash-es": "^4.17.21", + "mitt": "^3.0.1", + "pinia": "^2.3.0", + "pinia-plugin-persistedstate": "^3.2.1", + "query-string": "^8.2.0", + "vue": "3.5.22", + "vue-echarts": "^6.7.3", + "vue-i18n": "^9.13.1", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@commitlint/cli": "^17.8.1", + "@commitlint/config-conventional": "^17.8.1", + "@eslint/compat": "^1.4.1", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.39.1", + "@types/lodash": "^4.17.6", + "@types/lodash-es": "^4.17.12", + "@types/nprogress": "^0.2.3", + "@types/pretty": "^2.0.3", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^8.46.4", + "@typescript-eslint/parser": "^8.46.4", + "autoprefixer": "^10.4.19", + "eslint": "^9.39.1", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-prettier": "^10.1.8", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-vue": "^10.5.1", + "globals": "^16.5.0", + "husky": "^8.0.3", + "jiti": "^2.6.1", + "lint-staged": "^13.3.0", + "postcss": "^8.4.45", + "postcss-html": "^1.7.0", + "postcss-import": "^16.1.0", + "postcss-less": "^6.0.0", + "prettier": "^2.8.8", + "prettier-plugin-tailwindcss": "^0.3.0", + "stylelint": "^14.16.1", + "stylelint-config-html": "^1.1.0", + "stylelint-config-prettier": "^9.0.5", + "stylelint-config-rational-order": "^0.1.2", + "stylelint-config-recommended": "^7.0.0", + "stylelint-config-recommended-less": "^1.0.4", + "stylelint-config-recommended-scss": "^7.0.0", + "stylelint-config-recommended-vue": "^1.5.0", + "stylelint-config-standard": "^25.0.0", + "stylelint-config-standard-scss": "^4.0.0", + "stylelint-less": "^1.0.8", + "stylelint-order": "^5.0.0", + "tailwindcss": "^3.4.4", + "typescript": "5.9.3", + "typescript-eslint": "^8.47.0", + "vue-eslint-parser": "^10.2.0", + "vue-tsc": "3.1.4" + } +} \ No newline at end of file diff --git a/frontend/packages/lib-shared/.eslintignore b/frontend/packages/lib-shared/.eslintignore new file mode 100644 index 0000000..e92f8ae --- /dev/null +++ b/frontend/packages/lib-shared/.eslintignore @@ -0,0 +1,5 @@ +/*.json +/src/**/*.json +dist +postcss.config.js +*.md \ No newline at end of file diff --git a/frontend/packages/lib-shared/.eslintrc-auto-import.json b/frontend/packages/lib-shared/.eslintrc-auto-import.json new file mode 100644 index 0000000..8086284 --- /dev/null +++ b/frontend/packages/lib-shared/.eslintrc-auto-import.json @@ -0,0 +1,74 @@ +{ + "globals": { + "Component": true, + "ComponentPublicInstance": true, + "ComputedRef": true, + "DirectiveBinding": true, + "EffectScope": true, + "ExtractDefaultPropTypes": true, + "ExtractPropTypes": true, + "ExtractPublicPropTypes": true, + "InjectionKey": true, + "MaybeRef": true, + "MaybeRefOrGetter": true, + "PropType": true, + "Ref": true, + "VNode": true, + "WritableComputedRef": true, + "computed": true, + "createApp": true, + "customRef": true, + "defineAsyncComponent": true, + "defineComponent": true, + "effectScope": true, + "getCurrentInstance": true, + "getCurrentScope": true, + "h": true, + "inject": true, + "isProxy": true, + "isReactive": true, + "isReadonly": true, + "isRef": true, + "markRaw": true, + "nextTick": true, + "onActivated": true, + "onBeforeMount": true, + "onBeforeUnmount": true, + "onBeforeUpdate": true, + "onDeactivated": true, + "onErrorCaptured": true, + "onMounted": true, + "onRenderTracked": true, + "onRenderTriggered": true, + "onScopeDispose": true, + "onServerPrefetch": true, + "onUnmounted": true, + "onUpdated": true, + "onWatcherCleanup": true, + "provide": true, + "reactive": true, + "readonly": true, + "ref": true, + "resolveComponent": true, + "shallowReactive": true, + "shallowReadonly": true, + "shallowRef": true, + "toRaw": true, + "toRef": true, + "toRefs": true, + "toValue": true, + "triggerRef": true, + "unref": true, + "useAttrs": true, + "useCssModule": true, + "useCssVars": true, + "useId": true, + "useModel": true, + "useSlots": true, + "useTemplateRef": true, + "watch": true, + "watchEffect": true, + "watchPostEffect": true, + "watchSyncEffect": true + } +} diff --git a/frontend/packages/lib-shared/.eslintrc.cjs b/frontend/packages/lib-shared/.eslintrc.cjs new file mode 100644 index 0000000..df8c217 --- /dev/null +++ b/frontend/packages/lib-shared/.eslintrc.cjs @@ -0,0 +1,140 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const path = require('path'); + +module.exports = { + root: true, + parser: 'vue-eslint-parser', + parserOptions: { + // Parser that checks the content of the + + diff --git a/frontend/packages/mobile/package.json b/frontend/packages/mobile/package.json new file mode 100644 index 0000000..39a39b0 --- /dev/null +++ b/frontend/packages/mobile/package.json @@ -0,0 +1,62 @@ +{ + "name": "@cordys/mobile", + "private": true, + "version": "1.0.0", + "author": "", + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite --config ./config/vite.config.dev.ts --host", + "build": "vue-tsc --noEmit && vite build --config ./config/vite.config.prod.ts", + "build:local": "vue-tsc --noEmit && vite build --config ./config/vite.config.prod.ts --mode development", + "build:localProd": "vue-tsc --noEmit && vite build --config ./config/vite.config.prod.ts --mode localProd", + "type:check": "vue-tsc --noEmit --skipLibCheck -p tsconfig.json", + "lint-staged": "npx lint-staged", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", + "lint:styles": "stylelint 'src/**/*.{vue,html,css,scss,less}' --fix" + }, + "dependencies": { + "nprogress": "^0.2.0", + "typescript": "5.9", + "vant": "^4.8.2", + "vue": "^3.5.13" + }, + "devDependencies": { + "@types/postcss-pxtorem": "^6.1.0", + "@vant/auto-import-resolver": "^1.1.0", + "@vitejs/plugin-legacy": "^6.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "@vitejs/plugin-vue-jsx": "^4.1.1", + "@vue/babel-plugin-jsx": "^1.2.2", + "@vue/test-utils": "^2.4.6", + "eruda": "^3.4.1", + "less": "4.3.0", + "less-loader": "^11.1.4", + "postcss-pxtorem": "^6.1.0", + "rollup": "4.59.0", + "rollup-plugin-visualizer": "^5.12.0", + "unplugin-auto-import": "^0.17.5", + "unplugin-vue-components": "^0.26.0", + "vite": "^6.0.5", + "vite-plugin-compression": "^0.5.1", + "vite-plugin-eslint": "^1.8.1", + "vite-plugin-svg-icons": "^2.0.1", + "vite-plugin-vue-setup-extend": "^0.4.0", + "vite-svg-loader": "^3.6.0" + }, + "lint-staged": { + "*.{js,ts,jsx,tsx}": [ + "prettier --write", + "eslint --fix" + ], + "*.vue": [ + "stylelint --fix", + "prettier --write", + "eslint --fix" + ], + "*.{less,css,scss,sass}": [ + "stylelint --fix --custom-syntax postcss-less", + "prettier --write" + ] + } +} \ No newline at end of file diff --git a/frontend/packages/mobile/public/favicon.ico b/frontend/packages/mobile/public/favicon.ico new file mode 100644 index 0000000..e06b02b Binary files /dev/null and b/frontend/packages/mobile/public/favicon.ico differ diff --git a/frontend/packages/mobile/public/images/login_bg.png b/frontend/packages/mobile/public/images/login_bg.png new file mode 100644 index 0000000..994d803 Binary files /dev/null and b/frontend/packages/mobile/public/images/login_bg.png differ diff --git a/frontend/packages/mobile/src/App.vue b/frontend/packages/mobile/src/App.vue new file mode 100644 index 0000000..ee3dc8c --- /dev/null +++ b/frontend/packages/mobile/src/App.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/frontend/packages/mobile/src/api/http/checkStatus.ts b/frontend/packages/mobile/src/api/http/checkStatus.ts new file mode 100644 index 0000000..f822c6e --- /dev/null +++ b/frontend/packages/mobile/src/api/http/checkStatus.ts @@ -0,0 +1,80 @@ +import { showFailToast } from 'vant'; + +import { useI18n } from '@lib/shared/hooks/useI18n'; + +import useUser from '@/hooks/useUser'; + +export default function checkStatus( + status: number, + msg: string, + msgDetail: string | Record, + code?: number, + noErrorTip?: boolean +): void { + const { t } = useI18n(); + const { logout, isLoginPage, isWhiteListPage } = useUser(); + + let errMessage = ''; + switch (status) { + case 400: + errMessage = `${msg}`; + break; + case 401: { + errMessage = msg || t('api.errMsg401'); + if (!isLoginPage() && !isWhiteListPage()) { + logout(); + } + break; + } + case 403: + errMessage = msg || t('api.errMsg403'); + break; + // 404请求不存在 + case 404: + errMessage = msg || t('api.errMsg404'); + break; + case 405: + errMessage = msg || t('api.errMsg405'); + break; + case 408: + errMessage = msg || t('api.errMsg408'); + break; + case 500: + errMessage = msg || t('api.errMsg500'); + break; + case 501: + errMessage = msg || t('api.errMsg501'); + break; + case 502: + errMessage = msg || t('api.errMsg502'); + break; + case 503: + errMessage = msg || t('api.errMsg503'); + break; + case 504: + errMessage = msg || t('api.errMsg504'); + break; + case 505: + errMessage = msg || t('api.errMsg505'); + break; + default: + } + if (msgDetail && !noErrorTip) { + if (typeof msgDetail === 'object') { + errMessage = Object.values(msgDetail) + .map((e) => e) + .join('\n'); + } else { + errMessage = msgDetail; + } + showFailToast({ + message: errMessage, + duration: 5000, + }); + } else if (errMessage && !noErrorTip) { + showFailToast({ + message: errMessage, + duration: 5000, + }); + } +} diff --git a/frontend/packages/mobile/src/api/http/index.ts b/frontend/packages/mobile/src/api/http/index.ts new file mode 100644 index 0000000..2a01a1b --- /dev/null +++ b/frontend/packages/mobile/src/api/http/index.ts @@ -0,0 +1,15 @@ +import { showFailToast } from 'vant'; + +import createAxios from '@lib/shared/api/http'; + +import useAppStore from '@/store/modules/app'; + +import checkStatus from './checkStatus'; + +const CDR = createAxios({ + useAppStore, + showErrorMsg: showFailToast, + checkStatus, +}); + +export default CDR; diff --git a/frontend/packages/mobile/src/api/modules/index.ts b/frontend/packages/mobile/src/api/modules/index.ts new file mode 100644 index 0000000..48f7e63 --- /dev/null +++ b/frontend/packages/mobile/src/api/modules/index.ts @@ -0,0 +1,441 @@ +import { showFailToast } from 'vant'; + +import createAxios from '@lib/shared/api/http'; +import useAgentApi from '@lib/shared/api/modules/agent'; +import useClueApi from '@lib/shared/api/modules/clue'; +import useContractApi from '@lib/shared/api/modules/contract'; +import useCustomerApi from '@lib/shared/api/modules/customer'; +import useCustomFormApi from '@lib/shared/api/modules/customForm'; +import useFollowApi from '@lib/shared/api/modules/follow'; +import useOpportunityApi from '@lib/shared/api/modules/opportunity'; +import useOrderApi from '@lib/shared/api/modules/order'; +import useProductApi from '@lib/shared/api/modules/product'; +import useLicenseApi from '@lib/shared/api/modules/system/authorizedManagement'; +import useBusinessApi from '@lib/shared/api/modules/system/business'; +import useLoginApi from '@lib/shared/api/modules/system/login'; +import useMessageApi from '@lib/shared/api/modules/system/message'; +import useModuleApi from '@lib/shared/api/modules/system/module'; +import useOrgApi from '@lib/shared/api/modules/system/org'; + +import checkStatus from '../http/checkStatus'; + +const CDR = createAxios({ + showErrorMsg: showFailToast, + checkStatus, +}); + +const productApi = useProductApi(CDR); +const opportunityApi = useOpportunityApi(CDR); +const clueApi = useClueApi(CDR); +const customerApi = useCustomerApi(CDR); +const messageApi = useMessageApi(CDR); +const moduleApi = useModuleApi(CDR); +const orgApi = useOrgApi(CDR); +const businessApi = useBusinessApi(CDR); +const loginApi = useLoginApi(CDR); +const licenseApi = useLicenseApi(CDR); +const followApi = useFollowApi(CDR); +const contractApi = useContractApi(CDR); +const orderApi = useOrderApi(CDR); +const agentApi = useAgentApi(CDR); + +export const { + getFollowPlanDetail, + getFollowPLanPage, + getFollowRecordDetail, + getFollowRecordPage, + deleteFollowRecord, + getFollowRecordTab, + getFollowPlanTab, + deleteFollowPlan, + updateFollowPlanStatus, + updateFollowPlan, + updateFollowRecord, + addFollowRecord, + addFollowPlan, +} = followApi; + +export const { + getOrderFormConfig, + getOrderFormSnapshotConfig, + addOrder, + getOrderDetail, + getOrderDetailSnapshot, + updateOrder, + deleteOrder, + getOrderList, + getOrderInContractList, + getOrderTab, + addOrderView, + updateOrderView, + getOrderViewList, + getOrderViewDetail, + fixedOrderView, + enableOrderView, + deleteOrderView, + dragOrderView, + updateOrderStatus, + updateOrderStatusRollback, + sortOrderStatus, + addOrderStatus, + getOrderStatusConfig, + updateOrderStage, + downloadOrder, + deleteOrderStatus, +} = orderApi; + +export const { + addProduct, + getProduct, + getProductFormConfig, + getProductList, + deleteProduct, + updateProduct, + batchDeleteProduct, + batchUpdateProduct, + dragSortProduct, + getProductOptions, + addProductPrice, + updateProductPrice, + getProductPriceList, + getProductPrice, + deleteProductPrice, + getProductPriceFormConfig, + dragSortProductPrice, + copyProductPrice, +} = productApi; + +export const { + getOpportunityList, + addOpportunity, + updateOpportunity, + getOpportunityDetail, + getOptFormConfig, + getOptFollowRecordList, + deleteOptFollowRecord, + addOptFollowRecord, + updateOptFollowRecord, + getOptFollowRecord, + getOptFollowPlanList, + addOptFollowPlan, + updateOptFollowPlan, + deleteOptFollowPlan, + getOptFollowPlan, + cancelOptFollowPlan, + transferOpt, + batchDeleteOpt, + deleteOpt, + updateOptStage, + getOptTab, + getOpportunityContactList, + updateOptFollowPlanStatus, + advancedSearchOptPage, + globalSearchOptPage, + getOpportunityStageConfig, + getQuotationList, + addQuotation, + updateQuotation, + getQuotationDetail, + getQuotationSnapshotDetail, + getQuotationFormConfig, + getQuotationSnapshotFormConfig, + deleteQuotation, + approvalQuotation, + voidQuotation, + revokeQuotation, + batchApprove, + batchVoided, + downloadQuotation, +} = opportunityApi; + +export const { + addClue, + updateClue, + updateClueStatus, + getClueList, + batchTransferClue, + batchToCluePool, + batchDeleteClue, + getClueFormConfig, + getClue, + deleteClue, + ClueTransitionCustomer, + addClueFollowRecord, + updateClueFollowRecord, + getClueFollowRecordList, + deleteClueFollowRecord, + getClueFollowRecord, + addClueFollowPlan, + updateClueFollowPlan, + deleteClueFollowPlan, + getClueFollowPlanList, + getClueFollowPlan, + cancelClueFollowPlan, + getClueHeaderList, + pickClue, + getCluePoolList, + batchPickClue, + batchDeleteCluePool, + batchAssignClue, + assignClue, + getPoolOptions, + deleteCluePool, + getCluePoolFollowRecordList, + getPoolClue, + getClueTab, + getClueTransitionCustomerList, + updateClueFollowPlanStatus, + getAdvancedCluePoolList, + getAdvancedSearchClueList, + getGlobalSearchClueList, + getGlobalCluePoolList, + transformClue, + moveToLeadPool, +} = clueApi; + +export const { getDepartmentTree, getUserList, getUserDetail, getUserOptions, getRoleOptions } = orgApi; + +export const { + addCustomer, + updateCustomer, + getCustomerList, + getCustomerFormConfig, + getCustomer, + deleteCustomer, + batchDeleteCustomer, + batchTransferCustomer, + batchMoveCustomer, + addCustomerFollowRecord, + updateCustomerFollowRecord, + deleteCustomerFollowRecord, + getCustomerFollowRecordList, + getCustomerFollowRecordFormConfig, + getCustomerFollowRecord, + addCustomerFollowPlan, + updateCustomerFollowPlan, + deleteCustomerFollowPlan, + getCustomerFollowPlanList, + cancelCustomerFollowPlan, + getCustomerFollowPlanFormConfig, + getCustomerFollowPlan, + addCustomerContact, + getCustomerContactList, + updateCustomerContact, + disableCustomerContact, + getCustomerContactFormConfig, + getCustomerContact, + enableCustomerContact, + deleteCustomerContact, + checkOpportunity, + getContactListUnderCustomer, + addCustomerOpenSea, + updateCustomerOpenSea, + getCustomerOpenSeaList, + switchCustomerOpenSea, + deleteCustomerOpenSea, + isCustomerOpenSeaNoPick, + getOpenSeaCustomerList, + pickOpenSeaCustomer, + batchPickOpenSeaCustomer, + batchDeleteOpenSeaCustomer, + batchAssignOpenSeaCustomer, + assignOpenSeaCustomer, + getOpenSeaOptions, + getOpenSeaCustomer, + deleteOpenSeaCustomer, + getCustomerHeaderList, + saveCustomerRelation, + getCustomerRelationList, + getCustomerCollaborationList, + batchDeleteCustomerCollaboration, + updateCustomerCollaboration, + addCustomerCollaboration, + deleteCustomerCollaboration, + getCustomerOptions, + getCustomerOpenSeaFollowRecordList, + updateCustomerRelationItem, + deleteCustomerRelationItem, + addCustomerRelationItem, + getCustomerTab, + getCustomerContactTab, + updateCustomerFollowPlanStatus, + getCustomerOpportunityPage, + geAdvancedCustomerList, + getAdvancedCustomerContactList, + getAdvancedOpenSeaCustomerList, + getGlobalCustomerContactList, + getGlobalCustomerList, + getGlobalOpenSeaCustomerList, + getGlobalModuleCount, + moveCustomerToPool, + getCustomerOrderList, +} = customerApi; + +export const { + exportContractAll, + exportContractSelected, + generateContractChart, + getContractDetail, + getContractList, + getContractTab, + getContractViewDetail, + getContractViewList, + addContractView, + updateContractView, + fixedContractView, + enableContractView, + deleteContractView, + dragContractView, + addContract, + getContractFormSnapshotConfig, + updateContract, + deleteContract, + getContractFormConfig, + getPaymentPlanList, + getContractPaymentPlanList, + addPaymentPlan, + updatePaymentPlan, + deletePaymentPlan, + getPaymentPlanDetail, + getPaymentPlanFormConfig, + getPaymentPlanTab, + exportPaymentPlanAll, + exportPaymentPlanSelected, + generatePaymentPlanChart, + addPaymentPlanView, + updatePaymentPlanView, + getPaymentPlanViewList, + getPaymentPlanViewDetail, + fixedPaymentPlanView, + enablePaymentPlanView, + deletePaymentPlanView, + dragPaymentPlanView, + getPaymentRecordFormConfig, + addPaymentRecord, + updatePaymentRecord, + getPaymentRecordDetail, + getPaymentRecordList, + deletePaymentRecord, + preCheckImportBusinessTitle, + downloadBusinessTitleTemplate, + importBusinessTitle, + getBusinessTitleList, + addBusinessTitle, + updateBusinessTitle, + deleteBusinessTitle, + revokeBusinessTitle, + getBusinessTitleDetail, + getBusinessTitleInvoiceCheck, + exportBusinessTitleAll, + exportBusinessTitleSelected, + getBusinessTitleThirdQuery, + getBusinessTitleThirdQueryOption, + getBusinessTitleConfig, + switchBusinessTitleFormConfig, + getInvoicedDetail, + getInvoicedFormConfig, + getInvoicedFormSnapshotConfig, + addInvoiced, + updateInvoiced, + getInvoicedList, + getBusinessTitleModuleForm, + getInvoicedInContractList, + getContractDetailSnapshot, + getInvoicedDetailSnapshot, +} = contractApi; + +export const { + addAnnouncement, + updateAnnouncement, + getAnnouncementList, + getAnnouncementDetail, + deleteAnnouncement, + getNotificationList, + getNotificationCount, + setNotificationRead, + setAllNotificationRead, + getMessageTask, + saveMessageTask, + batchSaveMessageTask, + getHomeMessageList, + closeMessageSubscribe, + getUnReadAnnouncement, + getMessageTaskConfigDetail, +} = messageApi; + +export const { + getFieldDisplayList, + getModuleNavConfigList, + getModuleUserDeptTree, + getModuleRoleTree, + getOpportunityRuleList, + getCluePoolPage, + noPickCluePool, + getCapacityPage, + getCustomerPoolPage, + noPickCustomerPool, + getFormDesignConfig, + getFieldDeptUerTree, + getFieldDeptTree, + getFieldClueList, + getFieldContactList, + getFieldCustomerList, + getFieldOpportunityList, + getFieldContractList, + getFieldInvoiceList, + getFieldProductList, + checkRepeat, + uploadTempFile, + previewPicture, + downloadPicture, + getReasonConfig, + getSearchConfig, + uploadTempAttachment, + previewAttachment, + downloadAttachment, + deleteAttachment, + getFieldPriceList, + getFieldQuotationList, + getFieldOrderList, + getFieldContractPaymentPlanList, + getFieldContractPaymentRecordList, + getFieldBusinessTitleList, + getDatasourceFieldConfig, +} = moduleApi; + +export const { + updatePersonalInfo, + getPersonalInfo, + sendEmailCode, + updateUserPassword, + getThirdConfigByType, + exportCenterDownload, + getExportCenterList, + cancelCenterExport, + getConfigSynchronization, + getApiKeyList, +} = businessApi; + +export const { isLogin, signout, getKey, login, getThirdOauthCallback, getThirdCallback } = loginApi; + +export const { getLicense, addLicense } = licenseApi; + +export const { getAgentOptions } = agentApi; + +export const { + getCustomFormAdmins, + getCustomFormDataDetail, + getCustomFormDataPage, + getCustomFormDetail, + getCustomFormList, + getCustomFormRoles, + getCustomFormRoleUsers, + addCustomForm, + addCustomFormData, + saveCustomFormAdmins, + updateCustomForm, + updateCustomFormData, + batchUpdateCustomFormData, + batchDeleteCustomFormData, + deleteCustomFormData, +} = useCustomFormApi(CDR); diff --git a/frontend/packages/mobile/src/assets/icon-font/iconfont.css b/frontend/packages/mobile/src/assets/icon-font/iconfont.css new file mode 100644 index 0000000..fa1fc02 --- /dev/null +++ b/frontend/packages/mobile/src/assets/icon-font/iconfont.css @@ -0,0 +1,1003 @@ +@font-face { + font-family: iconfont; /* Project id 4798260 */ + src: url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAGm8AAsAAAAA/+wAAGlqAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHFQGYACnbgqDklyCyTkBNgIkA4lsC4R4AAQgBYRnB61lGwzYZ0TPpmw73G0DoJ7xCyXHKRqRux1KlOZhpeiMDRsHwMTyoOz////PTBpjtN2QDZBQ1VS/KkhwQSkVTZjR41fQQCPLGFAYc11CiRarcYlraWXr6PsmtpMjVKIgotBO4VvpOxq9clL6iHf0RjWdp7vx6Eir073TddNXk48M9pQPUzY1rlE4RMVsJ/2Eak9VcKpFwaJVaY+JQwtS+m9s+V/oPguMP7FeTIvIRxaRpFDhBKOGCzpx8MEMMx3O5Z8bfXveO8z5jYPZjuuKkqO4yIpVma0wbuGFdFp27An/T/u1c5/MzJfFPKl2CxFPGhst02iFkpcHnsv9uy16wldRiUfRGs+LskQDDCjAsXSCbATg5sSi865YIhgYdFs3gkgTCMwj/MhGSIxp7ouoaiUMSc2IRe/+U9Vm/iek92ZQpC29nVRSm+VfWCwTIEhi11RLGJtxcAGuwNubKKU2SWmlkMTllAIIGJ53W0/FHAz5iAsHX3CCG8fms0RFNx9cCY5dpqZpk9+2sSwrqwuzKbZtW0F3tbGpNk5TW9uueVf3+QfYH+95kz68rTQIoaQlV1GXf4Bx2OtlW6NvB7mJimrpBwAVcJi3QI9PLbVSIiiDAIyrhVavBIIvDM6NkGb+7yIOIZETISXC3GTN/w4MSDThBTX2X3rbvZc2mMtM6rG8u0eCQMsJlwDscWChbdLmrjni+dhf+ytLY4cWPMbQQnhgGQy/54EAdpknWHrZhhMLAazzSplLCuTTe2HQwHZj70gMrmshpIMfMDe/Wd+Do2QoWcIcwUD2jn+Z/1xqW+7WtFrySnIyuZ907y7gyYrYL2IpZhWzk5pIAluuLBPDUtlcsXfvzawMbLd3JKiC27XzDEmAWgTMv+n8r5WQ+Q/8/nkHb8xsiC+dnue/ORfdFt29d0bh3jsjaWYQaCSENZKDJLCtkQBLAtsKgEcjbNKzF2x+yDxvejFoJAcELyB4fgs/Av652hjj6VMqKbf8Rbnp9FuUW/Td1sU2DYwoMkgs5VQGIt8cLmj436WKnYk7NhoTN4AUFsDSElTrf+neRf6njC0r+xLgnLtsdyzdpUN3XctmHRsF5hXtCVFRVcxmZO9+yYor2hBgG0HTYc7F9YOEoE5PCEUklA5jB5mzGsCOCQaMqSCENQOEtbTe2R9Pw2bpjZdj1lW9hhAkhCCpTe/O/V2DeLrugAuGbMofY7OOfbTbb0ZOUAT0EI54XxUEepdqwvrgFQpm2qvXb8GGWMwW7oFqb0kiaLskBgOlkTL7uDFakrDFPsZP/+7XczsFkxFRz0P5hV34e7Hb763gpzYLcWEDCRycLtYerXHYm/zEp532WWZEiq9n95IlX0//Xf9gJDm3qHr7odXt0y/uv3j4YuTlK7yJD8sXpqgtkfnq2/z9YnRkEei1Wz9s5fKdLZ/mGiXsRiZXlnoqtUar0unVRsOBmyZPesmzRWft/+OtNoXA473e7o/n63S+iN+fYBgZRuMkzfKirOqm7QbDfjSeTGfzxXK13myFO8n+IDr6wWvwBrwF78B78AF8BJ8EBVGSFVXTDdOyHbexLuL/+/FMrlCqqKqpU8/WhUbN8VVuc+TEmQuTBwiISMgoqGjoGJhY2Di4ePgEhESMvhCTkJKRU1BSUdPQ0jGdDacvLXrKt9vPr+/54+f3D/yD3f5wPJ0v19v98XyZL5ar9WZbbzRb7U631x8MR+PJdJbOZHP5QrFUrlRrwZOnTkdj8UQyFdi6bfuOnbt279m7b/+Bg4cOHzl67PjyyprVtevWb9i4afPwyOjY+MTk1PTM7Nz8wuLSwOBQdh4+cvTY8RM71u3ctXvP3vX79m/fum3Dxk2rJ8cnRkbHmsx1eoPRxIyqSC4YQSnlJiMAj4dfn77hsZVmN/Bntwhmd+CzByC7E2KPELEniNkzJOwFCZtNir0ikb0haU+UjH0ghb0jdVlp9k7pNi4D+0Tmni7LsrKxL8jZN9CtlQL7gXKz5ZhybZwa+0URu0c1O2I7+8MO9o99tKKbNuhoRw8d2E8nDtCFgzThEM04TAvOsAv+pBtX6cE1AnCdINwgBCNh9BPBTaK4RQy3ieMOCdwliXukMEAag2QwRBb3yeEhBfibQgyTxwhFGKUYY5RgnFI8oQxPKcczKvCcSrygCi+pxitq8JpavGEnvKUO79gV76nHBxrwkZ3xiUYcbUIm3kuEJEiGFEiFNEiHDMiELMiGHMiF/o0P4/MYn9f4fMbnN76A8QWNL2R8YeOLOAMoalCMQXEGJRiUZFCKQWkGZRiUZVCOQXkqsLYCA1dkcCsxeJVxmyq4Q1XcpRruUR33qXGjmvCAWnhIbTyiDh5TF0+oh6fUxzMa4DkN8YJGeEljvKIJXtMUb2iGtzTHO1rgPS3xgVb4SGt8og0+0xZz7fCF9vhKB3yjI77TCT/ojJ90wS+64jfd4A/d4i/d4R/d4z89IHrEhZ5wsWfM9oLpXnGpN1zuHVN94HyfONUXrvSNq/3gWr842R8rPT8F4Y9g/BWCf0Ao/gNheC8cH0Tgo0h8ErV1o+GLGHwVC9/Exndx8EPciQe/xMdvCfZ9CeGlRHglMV5LgjeS4q1k+63J4YYUuCklbkmF21LjjjS4Ky3uSYf70uOBDHgoIx7JhMcy44kseCornsmG57LjjBw4KyfOyYXzcuOCPLgoLy7Jh8vy44oCuKogrimE6wrH8XkRKArFoDiUgJJQCkpDGSirLFuUY6vybFOB7SqyQyV2qswuVditKntUY6/q7FOD/WpyQC0Oqs0hdTisLkfU46j6HNOA4xqyrBErGrNGE1Y1Za1mrNOc9VqwQUs2asUmrdmsDcPaMqIdo9ozpgPjOjKhE5M6M6UL07oyoxuzujOnB/N6sqAXi3qzpA8D+jKoH0P6M50c1pYj2nFUe47pwHEdOaETO3RmnS7s1JVdEtgtkT2S2CuZ9VLYJ5X90tguna0y2CaTDbLYKJtNclgtl0l5jMtnQgEjChlVxJhuLFHMUiUsU8pyZaxQzkoVrFLJYlUsUs1CNcxXywJ1zFORXw2y//RWpxIAmyKGuJcCz7UQb9e9qktoqmBqjxbOWiDk0eNcSKDUAKfUaGyzWuikQa9j4cpXbzquSj3RewZNL3yz+LZEGg9BF9UIS9JZ0gWkP6aqkSuy0xIscpvST3UKJXKjmFSpZfHFVmhdh0bwJfkFkvvzBeTYkrGidI8SSGFtct9+8tiSSWCnrAhL0sXYCp2qFgCFthWvSlcsYV/QBNW48ZSiB0JNVTll1qQ0YwzUEeZyEt6UU4RHaXFIgRKL3fnxQc2wkrrK8DGLsdEm4wf/lUXi0yoxKU4/KszEnOryRg/21yCg9ByrxKzAJm1e2M339alQbD3NdrfZugkoeiy2K7KDulMgpTbW+bxp/naX88TJ9rY2F1nYZvhkiOiBZZWFrOBBrlltfz/nOk/TMJrF8ghYXzUFuS43wtcYarMpRsBQ0WYj7uzmdSU9UZ1arX1XNq4okPTwDH62r504eafuJLTqVk9T0sl4HR3RgTsEmji5itzvZge32TZt+99mOrnmVFiqA14RJod8HBcnV5ADpziAdy2u8dZG27LIDZywAfuOXYeYt0qtd3pUErxheoQ+1Vd7VV601jb3qqcitm3jOSNWa8dGSy7Px07eoRG0bmERkIIIOY53S7MWMWEh8S+k+oYDgWSECmCn4EGwGwhvrTJxMBUJAitSI6MCWKzlqhFSjAUsTNtn0WBGJODKVWZRrs0g4nSC4cDDtVWFOda4IV5PsSzrETt9pghAAk2KYDOODJpePyVIAHLG6wReJSRWzG5hZ1bBrEsKQD0hHFFKgzwD5Si2M3P00pSghKqC4CSOdb9/cqPTdQVsvvjUhRQm9mbqvNlnOBiWvMnFM0rQqlrGNnYUEFoxphKo68fQwHXLpwUeYMHBqTK6On/7vgG6dNKlDkYe4UYYg2OnrZMBe8S8tyxZF9goI42C2s9ua3KqFMN8GXznDYg7vM4oohnljcIgzAYh7rm03W3nhhiLvd9CSKYKxmYBAEZX8J5Rr4ZLDgZRBSsUADHnA9T2mC1DKN60ACKQdCkVvBr7LnmgRbqPnhOvxNmEkBGxw83OZfLAsDqyK2riqxyBRDhW0KW8D2j8PnMojLtMUqQUmkR2IjdHTgUwAsNRe7wQmd39/tEhiP3xG1vZX816hZ0N2y+Ve5F8L+ZTJSgdkIwjNxob+OvdmqvlF8kC04zISuB8ntoxCN9ZahPabcdGhNzbsgDDEPD0FsVCJXc5WVwvIiO2UzOZsPya4daVk/Nbg1jVjgGbMBqTvEtK5IIX6RvbjeTWhFIh+AGm5bVNb+MkONvyt3+KyBZX7a0lY3ETFbf9v2X2Le/XkU7EEqYVetT2To0DdeCZM6GS6vp6JdEcMLSUYTrWEBLla69eRcXLtdkOkDkKlxLirTMsVcom6AAfaHTUgOF3OO+Ww22WwnzE4s+yQAndlx0dPDSoxef2138cHqoIb0aGdaI1c2AFjK+0CkqQ9pjWIJb7gfAkS487iSbnD1bmvMQcI/LpO2AtM2E3D632AC5vMdnNmC5+25L5HYfxgWNH2t/tEcF1RckUqwmLfq1a2dfK/fFshGgvWUgVU9Zdo+YjWmh3v+Kqr+axecE8vxnXLvz41/O/gsXQvnbbqfkBFQkqFkEeVMpL897ixWBp+aX7+I5W2FwlPIoCmqkwIoFCLAXjqpQO391nij0Yg2hWWrTgfmrHrqOUKcw9VPRw7guR3IiSllpeLR7PWgJZtPiqTK9bvwmbn0HWYHdQHTI/dj04mQuBp0+aU/2Ttr5cQkjIbL7DRy/p40ULnzuDZ438ncwUr+tvgsm/yp2UXWgPXvn40WWqg29NRTRQyIx7Q7RUU7JQUjuPMwDhpEuZEc9yr9pu8el1WRwWS05b+WvtslRY0Sz5AsmkW2H4QAK6PkQuwxnTC7K+fls+vm9SKnysjltvnz1h3XqGrL1TV779YP+FCLbRhvn19POCwALJZ8ogmj81k3Pmw2lvC7Ocz9U09De/Fs20JyLgthgKwMF98V7KxSPc9wPPY+OKx3HbKol97uml9+7cPa6NIFd7ZTEv7x7/KCwdTcXZa5sP84MgAc4O36DLL4rzLcHJV8bkBNOriFF3GzzMx6fBvrVz4wEEiD6RsC7O6X2vf+zyxMDALTZ0FYsDfR6SJ4N4J3jUZepqjg2scotWxEJqE02X9vcIP0+ecxEXsppvBR7UDPIBw0NvpDke+XNBM0Rao0mx/RPXztsKl+rcXTaBrIH8ogUHVAukW1BNlrJ4DROT6RVhqtJMrL/YqOpjpyatTPfzdGroUTyBgwZWUIF/bCiOi12A2EFSdBqQ7DEGO1lVNcfFmDNYtlfasWFFTawqvp7Ms7KbUeDNzIREO1Ikf1zst8BeaxXpnC/4dhb7ijMXnaCfnoSHehhfco+awxtU435AC7Rs8Oo0rT5+d+ZGF/fWFo4Mp3r/lRxZj121p+fiNV5znXrI5FPVkIWOAenPJRCrMLnTEkCU8MNWxv2Bdv5bQaIiH3e3Bu2CviBwFAqMbudG1K9sW5gnDNewsIoeGAq6ki6qSy9XLlE1lRO30x2Mq1FvvddhugayiA6HV4IUSGCPPZi7PhVVu0Zsy83BYh7A7+xR4V2sKC5st1Bv8ttSCpcXVgaeMZrIR9RcTaWMO0Z4hHwuqPDofj2laxJ2P1UCVOUw/DpoeoGvywceuMCFa8m8r1K5i6TWgBKh5VpTsqoUAX5VegL/h/s1oleipOY17R8qF0yf0pDpqvqf/72HLza6IR4RZxedxUKDNatQkf26d81odaVg/jZWs5FoREkOl+9nQeROdj8No2m3zdY35GaxcHdIHSEBMZWhKsDIraya6ALxMxJ0OLpH2mTQMfCksZqGz0a0am8GIN+ZZBs9utl0zTfytXufmtsIwFMxYeOnOknlnDzg6FuQEvhKBLqg4GQyN0TKFIgKjsdknNzQBH9BcEeT1qJcXA6omokVgfgKL7k4x14itcg7efptT7h7qUj+XJsVibXpNVWp3j/DdNN2/SFN88qUCzmY2edGA4IuREViuXBw/pYVpudg9kyJM7LNd6vUfpfh4zuRsnMVQO4PVybgHabEIWSK4aIo7aTJBkoOT3XWh6C1cNevr7Prbg2r8dlTBOww99QjzBa3cAqPPc8qVNIEXEoxtksE0qoBLXBabsVLYJitXsRpcHnBcvKVdEc5fdtLaUUFflAN4km/iMDQ7tfDdTLRfpInX29Y5w5MCzYXjaUt5cGr7irgNGDnhyFhvk+/FR5bj4/5DJuQ9bq7F4H+QsD8wbkOpCSX6JJeCR9F46zMNgrQkMeAKZSPpjEVwR5dYXP9GTAzTlswvzxdAQUapiUQYdy5Q8GkgxH7Laf+jqs+7AXzvqqYyYdzm3qrDJ+iT0VENKeucBky/fNyMkFU+FitiOCg98d+evYvvzPRmcg4oqMpgoCpXnONT338irPpF7yInU5Y3rYLdeQ2jUh0bFy9GxRNSuuGC+dEVpNV+lpzKEApGoCoM2ZCWwP2Efn1B+NtUownoyRg6hA+Q8hQueE4CpdSgKeVpRS0oktTCETFwwljf3StoMq10NtCBKjo4PHO1/1BbT0mbJ+GCxOgeI9aDU8Tnd61rRg0S2rdqzvE8QWxb328//7MTHc0TVcUmaZFqvp2zjoANkgeFrQ+oI+KoZZZ2gS0PdfgmkPz+XphvrLzR8BAqB6IQGOlZKKv20F9WwnzZJymEZoI97uQrGlNXPP6RAc1PFQv0nfOJLLurQIffGLjmbmHm+8swLxANXuP0el2QpLE+Bm5fD9X7z/y5Iln5x9rfXMiE0oebQthOr88980FzgOpE4NYuf1xOzeQiuPJsUyxdxaW1zc7OErOj7evNFQAtNFSWnQLZLOba8qurvcMo6gBkTX9hO4m3SvrMWQZc1wZdckTqKJWESMVvbdSLMGLkBLmSaT+XQzBw+riAIR/4hGL7NNPY06DvmDIIhSPSX1xVReET6NZX9A6fYcKGfauabmDp6obogib7wYXJB/kojrq7SvV063B+O8209P+necK6SG34CQ3YsQuSS0O8J7oaX+9T9C+Z5998cNCCg4e6pJrZsBA3/0uhPZDu3gaX4xVG++TD7QpZ4SsPSfZBp83JX1MB4/9mTuENB8wiLu1kc4nfefjhaXzjoMvc8dh7ffa1ZMuJMGJCq+5MTkX58gZ2nqQ+5Ukga9i3HibY39xLt5Iu4+85vuG32eQ2Hh4FVRbeqXJB8b1AGG49sw5ygmS7hNPn3hWBGhyo7IE/Oe+J4rfi02GTkvzqZXhdpkR2r3nTFz8yFFw3F8jpJTLr55GGVg+qD2lh8W6kHVEVpcHkeFm4qi1ZlwOxsZAZA0lXcRoWNfL03GP0gTmbSWSiIDwHO10CpgPpyoxBpzTye45QbldD+YJKjA3cG/pBamYIBpkIKORIZ9Iq6M2Aghk3rD3684rjYxCNi6NHgrCxElI4ApZNSbPI78zbgfl1twsVu9S0jxgpStJ/7hHi6ul1vorqvZyRX1Vryw8r0JQNkFCJDvbeyDCMk6ltB0y6tQ4DwSt7JpT9RMem1If3iSzLFhGWjeUQSaYwMQuYdFqUxVpx+W8hSDEmJ8hQ/KKLjdUaNKr6p4en4ls1nzYumTP2ZpjNwntc1U7DmYNhf5wWh++3dezTyByJz6sVNIa/GsdlzvbaF1tOgmHnTPtJu6xbFaNblcmrJN8WSjFnnunsMkJZ1eX9sryL/n4B8uR4B/tyDDtNryxXAtbiIqZ6pICOnlKvplgWDKFRM6uZQ367dccT6s/tu0fa+fGF7Et9AVXD6RHTb3yVibjtbOWdGlbOGRlfE/fCNOQpLw2D+5WrrZ9+e3g19cdlTFJsfiTL8uzN2KtGy7mZhZMntFYt1t8mUNwa01/fVrlF6giLcJvLwHmApmNGfRRYXRUmt8G3MnGxN+IEqakHbt4MsoqFHo5lVIOcus2BMrMxvms2/7L6sYQ3VxPWKdp8F5f3aqeLbyBTxQvyvA5LXwUhtradtrtbXO79rUN7nN+yDABt8RqTsiilass59l5yBnOD7aZPJN8zdCfTU0CTtIdKUSyWSqbCxbPsRwBE4VKV/KgzKMuhcAiMRMkw5ldFNGcpr+0UzZZmRtl5ZyD1rqjlUC/FGZqxwOV/SeQ5Wl10eeg+diJQVhdWfOLvNeflhEMbmbSUs/CmazVBQi+9KJqpyVEky7a2X55Ukxh1ulWSOYMhdQ8d2wI6hoDYaMo3c8oKto4RJEMb1WLkXw5g8P8Q+gT3ojdu7a2fenrXli4kDeeCy9aJmef7KodVHflh7vk4HwDnKwezDlI4d4m8YTPj32EzGdmnx+7pmNe271jsTbki1eaHGKbsWhHWe0FDWzw9vBCuqE05tFUmsigGCfRXFixJsxkp0ERYI8uhGvPeeson/bhwryZg86RQc3rEMhcVHPWj6/CLOZft/L5OcIKV4dPDPhGb/zMJaMpZ2M4NPuSZ4UrRPODQer2uUWDoevckngk7QlZtTf0F1fCHz9LqFIqzDGo9tfBBdSeVGj4DWU2s2t7ddJ6X21PJpwoHcfgw+H2hTClVNqbtYtHf2t6W07Zw+x5umUstvuFRrctGCXUR0cVq1zd8N5wnszf9t88G0GpGXnA0Hl3QU578oPY7C3HYoMgNrD1dCgMnb7YpjpBnDHChXQOm6XKdTM5SdeUGhu/cUwHW458sivv5uKKNnbdcSwyA+dk8Ez7fmzS2b/WKmtLq3faP3JYcU8MZn2NePKwA6B8QCgfIMoCRkx8+IbVqxG5MNGxpo7/2RGVU9GKZoxc8GzNLpsiEz3o7HjgWmTJ0GJ8sd0AyjwkGmwfRtpG9CrtWuAkwMioxvZA9LmaGM9FW/U3OrWLHXmNXmrmBY5IdfU4etXsBX4NDMlFr+OhG7/h4eqkG2kXnChfOOfZ7BcY8xkuIxKPbq0sTDgw7A33597M9myueCRikw5pyhM6m1W75lNEmXQDPlb/elZf3SrHLqcJRLUacyZf/uoLZURKHY0HzbbdHG+Z/IRQIMANoaduz/H8+jy/gPne6QJC4nZ3FpE64gHFOwmfxtyHQPsiSAj5GCOF8n4qgPsUT7r1jIuSphSVNFIlZlSoUC/rTHx+5ZSapoyS+rA4EiR41CzF5Vop98hgqh1grFpkQEhPTa5qova+0F81F42mRZtKULZrm95CUPOdIZc2nQO4ftNmvURuNeu77F54LVdSnQRHaD4jc9RtBr1khprnyhOOyjD1kDX0h8Qcts9m7svZwx9P6vT+QYuX6fdGzB50TqHw5Ux2Y5jNPZTMqoSvqOGfOgB/f8OaVR3org6EXjUYuQ/0VBWZapNBImR7ENbtIRoVYZM/aJjO0OYt2+/VALKmqfGT14ZN6TVcQU2SnH5VYfHqK7k3iTWkLCpk3Zll41iQw2qK31W1jXFWcZCDu9wuMT7dEZ6G4g5egCLgmYAvzcepVct94WulM7dYGe57/lfAGn/z4hCeL/wqYR08HM74EMiLKWWWNBmVlq0xv+H+HgS6dejIezES+DiqoHaCmYRTH95e2pg0fLZA+kuf6xOuQlvnVqkk69mls0k/TSTRCsxqz82hRgtnfb3UhwNvLaGymObu02q5GwW7QpLcXsSlCHtW8JcsZVoc80g+iSSpz11mSvX/dkDY+XkL+Q2iaBW/IEbpXQQ1f6Da0EW+TVR4kBoRrqiRy0jislSEFooewgnYTIpTm14gXNtzBvu7ywlFY/smXWvZMZfWda36VqeCZTcvSf2dWKj5mI7kf07FY/OEk1lGOsPW6jx9NuKTcqxch8YsERGKnqFYs/Al073OulnSsV1WKls8sd7lmni3bqwtqUR9slgvmZiAu8jQBuIOHwR4rpZ5CxGquN+pMBCEPpCWnphYTo0FLDkphrYC8AeE3FHTGA0N9deHdnmA/jojmU3IIQqT71Ur1u164vQRhhwDnJEPhFiJRBtVt2nFKcMFgNospvQJD34ZefcUmA4DEtxa+U9VlUZkn7z827MZHSQxZZkOM4s1AmqWTEWRs9PYjGhsTBOwAeWhLFY8lf1+8gSFnnATqyADieYpZKhbIpmx/ryXM9JWHIWBhneLhUB4HAk8mufGnqkdMyaFapxbtB/JcGBXKtu1ajx5i+0ELA/p2SrCqWlSElKPW3AUCcuD+o+hraAWyqnHRuO6xNVhIIsnJFJAMacyl5HkbRFsng7lK1+Rr/IiXiDPdaVd9v5XKhJhR14polh2Rgc5/STEM4QNCKlHi16iorf6tnxz485tOuSaO+9NvMzUjWNy82zHTZ4P0bx94bApDCyB3XpldxW2jKkH0lgqi+msqCajeD0GMjwhE7LTXdJjbxyJZztP5uylk/hHAt4Clvr1Kq2sNtaOXawaY58atRuwljHJ0Xg6KIqny5JczLGYoFdHYxKe3sGBbh0C6DYmpGTxxMTYv/OIiTDmBco/peY7fIht5Lx39Bfsd1B8J7TwZJxeesNIG4aQMrW0BRepxtITLOvSFYmgfqZNkeV52n5WWXJHBiLLIpV5sehQwArBE2cbpjnveicjWuH5rYbRnI48S9v9Afv2gxOMdLKQa7wE11E1dmS/KuU5iseG2zhEBd0TikKJ+0zXHzwmPx69ucLNHdcc3o8KUP6FAxOv8KORuIfccUGpB4Vz9zuZLBhVQH8i8ixt9xPq6lq3d9x0qhIZIcGEAqPYYafU2N25rh5bBU3e/cu8wBYP3ToARV1ww63A8dMt2Ozj5I3WbZli6LAhxId/mKlBZY8l/Wt05FDNE2MVW4Ilk+AhAQMBIU2FD5XN1dpeqTgSaiVNJnptUK0M/ahzUjpUKA8lLnqDLioxSlFlE7d6TcXdN9oyRiGvQp/MZbCADHZENa7pZ8LgqVgevlS9joiJ194qh1Al5s8+Wly/w5uvtuip2QSXDDMhSG/dR7NbqV3znESw9NTyWHCcWdKuJOHKXMkeMDiMCr5TEMOK0jTOFC5G2F1JYvSgj5Xi5qpNgMUTFBb/9lKlEUg/rclUjFW1Kie8Vev5QAfBW0nw2fGv9HCWwFi/mZkY4vNeftVbFY59xyQ1YvCwD9KZDyYrRlQcCiZQ51a21nsg/AEwNtRDVssc3mOr0dbQ2mV7VOIfBFd91Gc3jQHNQFuM1ANCq5fMGcJII3CY52jdxyKB62xqF4iJYIvwUJYNzBF+wKyNGlbs0ZHDrfM7rjpksWNsYvrSsOOTI8SROhr3pyjudKH9GIanmqHyIKNFWMcqcTYpiEon8bOqdSYfflDxykF9AIbZwH1rf27UdwjTuUC6UrMZCZINAo55IjTHQOk185leNISuvBL4hCShJ8APTKs9qfCZhrlIT4ueh8A4G7y/lqT/FGpV3xn0N0EW/nykaQATGLP9VGEjwbkY3Shj1LcBi2gdGL2VJq2dS7N3tmuwonRLWK8IfWdUVULMgP9rAlJOSXXBQ/pU28urUo/dZGGfzoItkiAPyknam23Dm+kdt0lN/3VWwsIoo6xWlGhwB4TtOSVvGKSLaRKMZ8dg3lIJOZX0ArrZoObAPCEQDmvCs0KNsoAbdnYJqri4wRYCKAM06sFdiyCH49lzw0cwP31UivAX7HqjviBZZ6BBkblu+cXwSGqWiU+fzOLSaf9zL7302GMwf/LRojIb/zSyiyIZ2az+D8iMcASQXmKL6s84RC6pevAkYZ3OA7IGIgve6q5OtBvInrZTpNvA1dnb2SNjP32M28krmETq8Dtm5CdckoWFL+OAfL/+gmm1/Y1JAwLWlcT3KFVRoRkSiQtyUgp3Lhnzh6JWEnWxslb+1ltV2jtX/v6PNzLemya9Ac3crnL63XlYNEgWLQIOwwn1dFgiM6yG13E9mk2X69dAM2IqNE7UnrzUz4pCEA13WJ+GDAP70nPP5a8fAV0d6PYPj8H8scfKz8+8sBq6/RM2SXwdmof3xjZLjkorf/PNWerbV/xuUCdMp+GZVFNN0F79fHPFywvQqwYTiz5cgrZdZ6Sl3F0Y1yFAY4B5AtIsqM3aQ9e4oKXxgYYmu0ZtnF74DKnays+Q/oTcsYcffnqXNz6ar/TjXgbErSzIw8QpOQ6WyU/4VqOvpPEig3tM+842eODRJNlLj70G8wJVAm6IS7PKujNlnNeb1LMrvdieizKoUT+lyF5g9eoszG2IiRhvgXcODQlIzL/gL8AaHtH2eOc7aCWD7bHjorg5PK7Aay08ge3A5ZbfZbe/tbQzxqYsKZWIpGuoxmDPixbclBmk3sl7O7W6SVb4rR6/x7qMvRWuR/5ctKquMDCG7L7TALjA5SSB3pgAG6+It6RtTenaWNbjztIEeMUWyTNFTqf8BUa819A0sDhUqkpNzszRuUVVMRFp7udD7g60WrZm3fe3YIqYZaRNOvELwvW7e7Fs74cFJMhFwW7a6/wYRsmpQbYxnjOjT8Zl1WZqXmrFa9Yc76WdPQuHInMd7K+L/RnY6cRRGItQXiQ6Mkr0cWPXlLlGq2NJcqzbP64WU32kZ5kR/5BIbVaTfz19FXotg1ibfCnjXoEuPXxH6Mmp+FzPTpqdffnPvfOgqN+9hm1w0/AoJPb9zKhtxfKuDpVZ0nZD3pW0kpHNu1TTtK+NDail1HyBLUWvgWj/0D47fm10TOlr+olOVpRp6n/cOifCL6I2tOspC+nFwQKxTb8WjHKfLtkpN6dep6ZNZ3/y0ztb/VDox8+sXfPNGFyglZc/I7bvue958DYAQ4WksrQ0JlUc1/ZYd3Q5UrC0DFRDxM9W2BS9MpIil1F6jVFryejEhe3uJdKhOpY34TxOVXSuOy+ZZTQeF2ZyTZE9zDBJVxgJEvXfZk155fIK4kQ43dvBSAzLerLFnsbNpfd2tx5YhgAMPVcpm6OMPmz6dTFjW7G8UCpswVHtkwd7SpJAlYldSCAGKB7ZicOKih8aD4TfaiCna6bdVuOb99VX9apZusEmpqt2nWndVTZVDtXM0pRYvGOeXsFgPDcbnVinh0faF2qzwd6B7cWRPNQ/RG50ILzpH0EeamgG3ZT+/RnhcFS4S9rulj7rSY2dHu6tDEOnR35wlwIesPFMnJClPgRbBKwD8A0Dvc0aiNilpUriWn/XkKtZ5ib10G2MRGdg4cFh7uRapkCIdabJkmUVwMhNkw+VgPlwoWKMuVECqxYqrEOHlCsn6+CnT0+nhz47M4+iSPHx7yIDd1esj9tuC8fqPHbZcsxt4UkB9d3voEWxb0VFIeMcKyaTFuUENYOdsRSztkVO0AjHJzd2ouPq2YoFhErRgvaqVDe6Ox7CjDRQKwmQ89eoZVRkEAWy/07r6GKTH3j2KMbgleGmYm1evzmeH9Q0QZl221uXkW9uSRCd/UWuh7kI7jp/+S5XDwsF0L+SvCZhKsV2Hzp7tHbPkeFkOdCnId5/tHJ4eeh+kQ1kOCPmjgykL9RuMDwYtVMbT9iFeSfrr+lJT3YFp8Voe47mF1dtiR9yVUkr5yps69UzS3qivvVtuvrs+9SVstWP/xiUY/R9xdqsi9IJdqEee4+7uIZarmR299qtx1qXn6w9F8fXolb0ZB9QU+krZDILj2p2WY1Wr2onTVd3v44BXpffk/0fCimwW62Ha52aO4sHOOaDS68MxnGdMfma3CAkj9rqfNugfdsV+jFXHjqL8YXyal35cCW7FYE+kZui3Kh+ycLu9L7p0T3JYGTMKjQwqIzt3ybZ0nVYA1eLuHbr3y0Q0LacSYmQK56zWlyvLXCLNunSAFj8Cp2J2A3VH/73y4qhPGn/dFd5cIuwvB1UkgQ8c8sAD0A544ow+6BKyJZ8Vvh0b/8cROCc/34fLR8MovkYEWJOGE54QJ6wAMHsnZLq0jsZ1+YCXtK5rJOc84guc1EfGnRmcca5CXttUkcH9/sKKeZZemR5u4cntCgFxlV2Vha6DMKmEtf9jGV7p7vs+r6+GIuJQ0CWnXxC+La7QUaIMjmuPASyuzFpfkBYWkYodv6asCsJjHpdRptfASMU9l1o3kvR+eIRNLsZbX2IoyzAF9pSTEGGdcWa7EHLRWDwKedj1GLgncMgIHjrU7nvh7SaDlUPe4lW8ZheCUDQ71Sjb3/HLxa+VgeyGg6/u4uIC/rfnYjM2XexMEtb48FJEck4FHEz1HFnNba0JlgxLuvdhf7vyB5+lOcLLVHFDfkUPPLPV3uhmQRnQR2nykw1FJBBlPWeN4L2QAR7kX/+eGIjdb5miYYo8jvjW0EYqKSYyyIUB2R6J0E+YxlHC8XSargeeVgFqsoAi+tgD7shpjuo/P+/pfb+Ykn+jYxXfzfj7U8efz38nTMd+X/1Y/t3WOrq2rfLn3zN/n2x4UCtHq2QQonDGi4JAzIlX7pCU2PaHJ15DTToPpMs0kbprfmscyq+Rv3CbJg5FSfG7LTfGUP86FiHG6mBWy8XaqVxLVANzcQ9isZH7Qbt9DoNX2Mh8OUEPwencX7aW4g7CtKj7YPtr3WAfwsEvZywDmDdy2V1cSScHZIdDAmji4WSonc67IwmQaHGUXGH8vIAVXHwgBIgti8bkkr+EAFIOwUZEHGOPaQys3fIrT7AgLFdZIO7rNCrV1GrZhrVjGiu2dnB0NWrLNN9Zwu6noajBJGfv1oTCApzJ0K5a5ssWVHg593KdrALQxuu72GQDB2MaUwg2ffWwJmz5nmHreGp1U7mKIFBQJ1vlIIAvh9vdW4t//dv+aVL6IFDw/+9kXTvQ4OC+szMFPv2Qfb5jgUScavz7aFLF2n2x2TWMkau359/EsuyUILZWSS9TEF8gz4Hxec7jZEh7u5uYUBrN7t8nwLHfInkSgDcy3W997J0tfotG/LQeUBsLlr6rXq1/OW9YJSr4+rltFaaLlegwlWoLOwyIp25SS60BDo8gRecAfF1uZwRS4zudeiNJtrLAJG9KBtiA0g9UI8H7gu496XbIGwAVh6KXqRMii5cQKEbHEk1DBfi4Wg9KerXLddq5l//u3TbnfUtk1H1V7dNqM133rTS4Xr4q8ISkDohrIoSTCKQ3rQVBohBI6tcM44arPxVcVFhycGcj15EiAFZdFPs6M7MvWS6d/FD6Pt261oyiwxgM1QzlV8VF1fFn/q/7nxcVVXcVP7/ulcw4hRSZ504j/ObIQmTMKTuPzEB9hP8ip5nnahJ1hCX/kUjXWDfIdGk8HeNQPMd/v2UubGbxD3q0Msl5VvfhfXEmF6Ho01gWNT4inpiUnQcKklH2NsPWPD3Tx+dt+He2uWzt5l+SFS3BK72mRUpUSUXzuX/PEDThGXWLiUCDACmLapc59t+JYRJH6xakfKQkeSY6CC+/itzX+bbv+3FjlKnpF9WU71ykMAMOdm+ztdqxQ12i2lfhWP5BVAuipgK7ucqiLtOogL/XolTRR/7bMi1iTNFRWAzMjfmpS1VG9s7LHenjoHMUmNXylqq7cuYuflYxeVbH7uV3SdLCYgSRsGiB0RpLR6tNBRNkEGALOWGxw3etYmekIdDL8/ygdaiscfWItCeyurBEP9UCEZtYhKsEwfk6CB6XE634XCvkDVO9D1iHqXTRPsXJd61T14jWg81Ms1prbRR3I3xsDzrR//N3xs66BQxg3CVMaiVxsu0RP5+//gcYpm5Sr5CIvku9NDIJeZccyCQTDPxEH5HiRugVkihoAaSAClQSDrBq1EUamKaQ8c3j4KqnFjbHprDC1FWhuTkhFQqQ3g5lTylksdDXZyPRB12iSIZog1E4GeG0loh+3Y2xG5PctOgB/wCc1PtiTujHaJ3Eu2BPdR6RzOoG5zR0OfRh+NG9lmN5oZpC/tGUdH4+Fl2H729lZTakdxZAgEIcWeMQ8xOItCpPOiAGj/xoEWqZzaKAlSOA0oDeB7yPSwxWadT1wXkR9x/sf6x7c8pS2D9hn/Av1lH0XKCa8amHP2Ra3sue1Wfe1+Z+gx8pr197jOcIpVIxX3wZO7UnWG4ctCB087l1iQxH7mBc8R1DHvGOuIpE2CS/1zPUxNQYmoaQvTdYb/Dlxji/sG9mJN/Ove5Xk5ZD1wO9yQnJSd3uC5nn4F1XeEL7EZbXFawetx7ysp0sKycZqIwozZwzfFXcwpWmNAjENyAm02NYrFzYQZ0lFKW8Ak9KqPy3WeLfa6x89z//na1NuRyVP/gjrq8vtpe/fa3ex77mo/YfTZfRjX7s96FkAE5F4rFSxUoXoY2CwQJ+ydj/ClxVVXxU/leMyMrbrsK2M3j+G/mhxaeEB6pbDq2CJLOSLY4m7usPDv7Q2hYxeZsEMKKFKNqGFMA7pHnVk5leo1KVWqgiTGRUvlFkyoXK84wt/H62DHHi5H2JE5Cu5uxTRiVRvmNinVOzrSNPCC6cf36DdGjhUGwnOb6I5Vzye2yADZ9vT7+qfkWX1hQa1PrI9WMQwJvqVdqEiN0zxkH2bH6nirxjg2pXlJvuU3mQI6rxDPJu6DAO8lT7JqbamdzbkarSytrpmNrcvIpMs85hHyqdMo4s1xaFywQe5lZ1FkcvHjFUT5f1M1HfHPzYyWhILQd+WveR/csFpqfxvN0AWn+/mn5/ulpfgXYnTpZwL/nAg7i9PM51QB+ywQKnu1cVuZG/yybcYK54MJnEljrjrO+ZHwNoybFZlISM21tz9PlO35KQ7qYXL/x9gYX+/b4idKGwDb2zudbUZf7szW3/Wcpf84GuIzYtrC5DMU55HtkX31wBa3W/l9b0OGJLhtU0by47QuIXCC6ect461ZE+Av/de+dAl7evftYPq+xcdMm13VtLmoZQGDqzI3X6jSFLJSVFY/IBQKd5LTcPRP+LQxlMKcmfht0r0rnm8C2qYhbELRK6PgqtsImgT2XXn1iakmf9b1oJVM0NzNFl9LgXDBaaT1QcubE1KpqX5G3tyhLwK4s3OMjdxaHS881wOX5Ej9vAP/c9fnsHBVKZtmeI0ejsWPXqRaboDR0BBpRbMqgsq1bFRD6+HgP19aCuEzMtWpDttVZKw7HYygRWMqxKfxqE3EKqEznr46bwsd1C+V03Trgsze2eg0hx6LDgjfLTyKIlJWL3OcF5yAENeJCJu1TH6Gc3kmUo+8/rBYeFssnbT8eixzw3htbtcbc/rOXWe6sW7Zrs0VoncDuLy/6GW8dfdcuus67h37Pts929903yxnfPXoPed/QgG2P1uZswl7hq7vL1kKf7ZAQ5RporTS4qEcrLX/Fiha0DFegS5ctU+r8ppoawyzzn1JJs5ctRRNs0LKWGOVZVgv7+ooVOQ/98vZdOetx1nMxLeAzY2SXdjl5ZS5PERKi4OXeFrBbEbLr3365+l5bPH8JHVfaNKVI50xaGYF9nY408OQF3i2kOXFTPX1MWoHPepR+a/8DW0+2uV0elz1S9bG5imETDaY8Z+wqcLi6NFYvB3CBRidXtaOTZLsdZsrKONTFA3gVjJTS7lCVUsalDroCZA/5nuFv8R6o9bP30vRWb9iO2BvjwO3VbQwtWQ7bwZNy8/r0HAC4PbRWCF1bngnCRaj9WtzK/qrbQKpbN6UOBO2t8LVZM+72mY4LV0JWNK+dkkwExKsPvjDng4c12bbknbp9urXpdt91+PrZHpgIfmV0pnC4yUqubLxiChMph5Ok5CRKuVw5Eir0FfHsxnrie4CHGdd+2UNq0osVvMCt4S7/1t66jRjcxm/46wU5kvj6iSQ/v8hvaAsmtsmPvWW8yaZPNSAGzRugANV8V+KFKAPJ1Y47Tpg3BLVCXIP36Y22j+4N0KrpfYffHiPZC5Awu4l9hXa+HdfOyki78QQZnaX1ufq/yWa8pfPDkIGPWfm804EH7LBbmlMI2nWQawspG+isHfO1ji7WJE7V6dOc3uJTAPYi8UBI1GHEDiHNJyHeR5VOII679HI0vtxQOb0NQc+FrhxyjEtWrA7T6aSY3YxiokHGYRnJOmZDC0HcwybX2seU+JxkG+PonIkNWvZ4sMGxY2CQI89rvmfJtGU/OESKd1zcW44ryZbkxmF1eHQwAkg0kis37o2/A4VICnR20Xp1OuuDAVx257aPG7hJs8z+LuohxvTa98YQdd0BypxuKP6ooTRhbAR5gIx7QHgv6ko87Drkepgo2zC8tjjTqs8KLvDPJrtb8mkrXFcMWus5+oQ82FNv2b2RD/FE26/U8BrEvqe+wKYmqM+dniOdLW2fGYXaf6xur24Epl8as3Fli+Tl437LJUs3pGta5JrXpUTw+gSqOUErcw9KOAS70/xprAsPsLgyxSZMUJpcF2Yz9YplgDk7Ij8y39Y80PLKVEp44lRBeZjNlCsWAea2QcpgJTtR/iwNQAUDsLueccEq0le0Zgc/22PvHQ45Wjv99QfsPZuTIHlS3TWf6fXVyy/qsbffOE9oFkimkq5ZTtrHfj09wNZh8VFWzAhLZBaYc45O/WX369Kb4E17SDR+2zMz3t56jCcpXqUpX/Fr2HxLNA4xCBIbpn0YXLIz6fvrPZuXe4b/ypgpEFwL/BwqNgt0o5KvJ+kPtfWGQLrDknvsuP89EsyoleftqGS730PWgk37yLYZXQ6E440x+k+7eIie5XPQwULdGqFbt+vfpjWwx0HbziSOBrNJXZv6mwwCkFe5QMqJGtckhfAcyEu7pYMuVUBOaBF8wMyyUfFDTO4YgnWRsjOmawuJDyhPZl+6fzPG8tTeO568ELk8pHjOHQLzj+ZnYFUfei0G3nnhSNvCOZcemHuedzFC1pJHIMawLMMyLTsdujy+uvrbD8U8IDY2a2Ibk4lTlX6re4DHDqPTzRdD6yXMVlFPtxwDiiFEJT8FtbCapUVbqE2h6SJqeVguk+pGzfnUphFhWp+2g43r+F1/L3NSV4xDTBfJRdQscZyaWVbeF55J3YDwl6of3uhxuQqB3R1fUlLEwRbEQ/SFE+kDki5ZmzhjwIX5M5ZOe32/8UmvKPiO7e9kNlKQVYZNUggHl4c7pATezXHlupj/mbHcwvrRQZ1QNrRoKLYAmDhw0xQB/r8dUVZdsfc7l+UyJ/Jjw44L7rI/zoyMxmQMHEuzNm1mA9AZrALrQ24w0+h2UTRPq7nO051MgdRB5tTOO7+VIWxc5mJm25gxnMQSuk72ETh2QAlOxns8BWDXmoD0NOCXdIBRJZYb7OvNfUIKxR2HIsvWCVUHhCvm+GbTHg5bjtFpsOnaNo+bcy9UdqMLlrMWmINC0vr2B6s5BDMTE9saYFJz3Cjy35b57INS8e/q42iSUwK6bt0D45PLu3Znb1zykBZUbJIGjjTySEIer83cTuTVfsjqvGBmLo/4d9YIbVM7mvY5QZqUkfEll0X+327P6jO1BxZOzMxFv1BLGtsJ5nHiyyc81UDC80Uywopz/l37N31vO49Y3pnxIMliQeyEQvMxoYCX9nsRcbKZ6SQik2g5vSHKsdGjzNrELt2Uwdu1q+6vvV9eJnOpZu6mNj6ZjW+HyxgrQVLEfK8PlVOCL2f6Fl2uCal87LkgnPgze4Q2KcsjaZpiYn5ckkUbb5I0CdWU5jeePTarnNeSFT+ee0DxVFNPEyKduBol+Df/nva7Ja9FOM8UfplpwSZFgN7jwXUst5M+7gRbcnDQ03beQ8TSKvJd697FVpYsBZU0YGFKzX5yIgtKDE9krWDPcXBOvEnMYk0q/kdiZSmCS6Z9lwxGJ8fdjY5QAVX8XaZ+XtlG+giW2Bvw4WDH75RYuUnrvQTPr+UrB8N3+nYF+3TtDB+UeyIeSKKgXDo12Vc07jA5qVZYlpHiExDyD+EGPDkxWTgoVCQqRINzGlLTlUWJ2ZCYXcl+/6QImzBzOwA77E3OTepZH8/1OwtocMTW/Nni4bejxfo1n4+FbdKfSth5fASfWI0XvuvPy2XTcJ6cO5ILcL8mLQ9o9M5iZwzGNdhoD44d7KC+HktLb5u6DcsDbjxy5E/az8g3uJfJH99MHnpLNlG1aclJR4OPtcosdiv13kN0lOzuzCIDMsvZnQyCr1NozhBlqbmCgJpLKkKZeTuZZnWwndnHROHVnPleOyo10byy6rF6vQZK0OhWXOhmh2WvolJyg1LmPNGhwjz/c05kd0pUWIBKWDDFXZCCwyNZIjyKAoHQw6ZkFoNF7lHeyavXl1xOLudIfSQSH6nvPgEn+Uh8pXK5VbxcKKirEywOEMlCOZkGcE6Q4bl/6tvjGj45v35VsFXn/qXfSIGcaRSge2MuEU+4lk4QzfxhRfZIWtFJaO9ckeKuSNO3R8ZEwgcPFA1Ukg84okCR8IFPDg18ZqTpGFmSDzgtQt1T8IqIzQwa8x4kZnPJqMdeWCdA6kSRgSVd8RkSripchKROEE55W+WK5Sk1KR4CZxZDmMgQsATOM3Xfar6dmQRm4UyA0/2epBlMtlYXn4maAeJyQDXTIstOs1BrhdHufLOY6TozmDODLBecr8htFhxk/vF1afTrEn5k9GBRdLC6+LBasMF6iFBG4I4bD5hymWeYQ+Vf7q1HXmUIH2cODEwQm9AVEeIVlhiFGDCJNLCcThRLwiqrtez289biKjsvqQ/FneFO8W7JaPHG1Xy9E8yrrMXn2ye+eKcKpc7qY5RVD1werKLYf/D7AHyzZRQaGe70E1VDfhBJnbAzONE2ILPua952EiRB95lW0mqjo1i2aU+xsCdJ1w3Y+Gh74raUNdpJKCccf3ucIDHrAjKG75LZyfIyrfHSh0y7LDsjLJv5eGaLrsGlwVWOuvVyGnQxsZsZNKtlsTEf6Fn0zOFMu0p539JyNr05/YZ/YVEk6AaBoIiJ3cSAGDNjY85mtKT3gVAz2KfZB+PDGj5GjpENNO3YgGhgB94wvj394NiXxOKw3MYKHuU6XZFIuoIHaVZI+LEY08HCWSVfy9qttxbN9PSS+pJZzjSyz9L0Zh9cjeOVYDfTWqTf7fIFUBBsGv+Y/f9+uN9HerdHH20SFsW1b1D2XHojB5a3v2yTB+KrhLw66uzV8zQdDJZz6/wF65daVGV7LvDDalNF/O2mFwSRAK6m6/v+bTOjdevWDkbHqVOtlG1kd8pWcsetpo6O2mmbN9czLCtmnps9zD3JOB+UFM/NvoMb0gdNAVvs3tRgwxf1H8xhis/eRKvaQu8VviumTV5LRZgNbfmOM4o63Uc+cQAHf9wJd1Z5JTC3nbNPp2SHiFO8uCFZQUFZJQJeQDx9GWNZY+NmxmZ8phPtbT2thYWtzv0yJTWd9UZClHmHWISpnoXkbnT5uErmfpLKkmSifSyj1KORmvb6Ky53QCYhvoEMp7Pt402nUBobTnO7isi5gF2RQ7P6w2uBX+5G558KqvlLLRNfvcsYUrM8g09ADJtEnwqTxo+Yk90pSBWhSDvbB55qZx5GLNEZTtlwmlwleJk2cPoX5Gs6ovqKfFG9YNS072kNyFLplWvdvWQwZ8+hwfiBQ3uqbu8SjQe1Rv3xaH2rLVnEINeUTSUzRK5mresfHYhqDbojauSvlAC5Y6N0vgRIIMRcllxHi2h0lFNc8lqPH3DVbOZqyHKmkRlowQJp4/GW7e6ojNnDBJBXIB/KV1z7DBAUBDCplt+h5WuBs1s+bdLVRg80uKBD84aJ0AAxGfCyuT4t3tSM2Prrntoer58nugDb0zEPrwxT3MkYBaIEaVTulqxCVYpgE8Nsx2byC5blkwdZ/eFKPagV1mWumV1czCvJyBUmPUNVXT1GCCMonyZaLa8xIqpJDR6zWEABeHXP1vulBD3vuu0FFOx6HpSyL6Pe7AwS1DP/g0HrqL3wfu0m2h7Ia99feDUyfJjfF4RcNwsolQ+Ey/L2EfkmZK1t2yQQpYWW0cvmZUZ8/Fnj2izfBB+RxFvqm80XTUMEUwSx7gg5DkGHTerNe24RsjsSK/hDmYYIq0dAQQ8kOf2IyJ6pZKq5FJ0d4hPMpjPPxPeB6CPtMaXcWE8c8G0r6dA5xToFaVDemUDeiMEiD8itHqyGVt9fXcX3UdcDK01nwFpw3BPjdMSV+ZPrfuNEZui4oQyAzeDg7KDArBIBu4rxYjaw6hnyXIWoniPPVGkbWPg63wMM60Cwk2xr8n2vcdEXPqm51FJBEyCDoMJMpY2WpX6Xk82tiq+Ija2Ir/pXwO6K2F3/35eyc/g1+WE5oaE5YflDCRIssOsPvaRU6yMKwsMLagXsqsWDpnk9L0wpfJ6iqMExJrOPibVxmAMxdlJCceTG0Io5TxOZyzOtVeSyUM1nwF6MqWNyC1EhoATMKewawcEMDWjMJsj53oPmTUhTzgCaUcqjUkKFAjUu8rOLYSaNaIqQb+rbaZNmygG9HS1DyQ4wDAauw+0LRdqaGE5Zfd+1P4fDi2K3AVbpIRFaAcABHn/sdRb73+4NXPgt/lshkvYV+ZpmBMr0v6SnB0eHlImDnIopDaIHOnQ+4opGxntWCYY9+OGN8BOjttBuPc3ZQoA0f1kLmws7faSAFhyeFAoazUQc7Uv9OpxcWHboXc6vYRpzgOxO0VHcdSyChIbqQs7qBKrRMWRMy5/eumzkbF+FyLuT2MfnfwqjuHyF4M+17fbtkr52DqmFxGlnTX6srx3lzwds0MvXYBwANHoMwfb1q/vvpJFlxESHZKfqJijVJtk22U5WVyezO67FJhVqqnZKdkgkyshpruPjxsM3hcjUWmThVMQtC5HaloW1yCyAM5PjVAuMHAR8PcIvZJgcwZvU0QwJvrRQI4eMVDmcnhDUidQVRIarQG1E4eRwD8bOlOMA2Q02SmqakU1kFnklLWBKWlRhRERhfYTKuAGbVKbC+DXeAi8vQZpAuNI4uKYW8f++O7U2077707RkFtkA/0Lg/zEPm7cpIR2AZXf7mydzSgp8Z/R/SGBKE+DO0n6BqFPao00QCPuXt8auXR53QnQur3C8dGKsNK/gXJb8Wt7Qjfyv/VfTiHYT/i5aVGEIhlckFTV6A2YAB4cAvYwNEI52sPqAJBHna8Wjcoo7odlgRghAiUAN9HxDp1sUNOQDTL06BUVqW7KzON9E4msCi1dl0Isrj4kpnyVg1ywcf4ubxOEkKWJPEkcBXsNjj6e6NZzek1ZvQMbILHIb5PbtmOJaw1SX9M9rkbVgjYAXjPyDxBCtG0Jn/PdrBOCjTdURiUgS9OtlM0Ib3xUvf0FJSGJlhD2T6zfDhf/1ac6QPHpp2mzQjRj4RECD37TZ5lUQr9KIOfYB2cFYfYZfNXBiY61KQwGojo8hGJ3KzKqILY2JKY2t+KI7H1NaGlMR+0W3xlwNcv7EieGrV8G0aVEktQPvo3N2jUjYl35D15fRd67lhJzxgedYdLtt+vS8XHce9d+jS5e2MpadaXVuHcvy09oLXVeE4AvcESgnxz3MPTeP1eB6lEEC9wUfQ5a7UfZ9Gm+l2WR+KoDAqsw0ShS7TNQx8t8zwajGSbvHuHLD/1p8VsbsujnyZ1ZecrODCLD//uptxQ3/97sH7mmZo38risP/a0D4gYaS76a4BsknUsKIC8tcAVgLKw4YjUGNmF6E8cJ4XPYZXPJX6fcbkX1zg9p6ZJYG/nVX+CznqO+I+vRSqqDea+mI2LEYi3vjVARpDMrNDWqcr303zqI1FUWuQc+GuzmIkpvsK5X6JnOJy3mTvXgJjWJxYwLPazIPjAgc2wxtjoIRoHZUZDCEfgxhF3h1LfPB/3Wwrts5BoA0IK4HPgx8+uSFeMlGL69lCUCnQFB7CdKdbodRfhoHgtBPGXVI3X7d4OG2eVQV6jrHr7ky6RPSmRAEp1PlRjmkiVh4HOxM6ajMKyttQpprNkp2g7tWUe1be72qzQMm9WCK794tHQOG2kTX0565VQbOa85Y2/W9wrW8f1gzaSAdn/j2sZ/DnsFrIBKjw4Hh4z/Pz3OcKkTqt0rg+pQTn4umhxRb/Ome6ztPjWAP33mV313PKovWkwUFxoICORXg/KDMwMBMtYBdaqzgzPinEo1XTttN3wlETr/ZJM9QLV6a1WTLfBh1V66ak2+Xy4MEF3hww+gfBlaTC+bnrIvvTGJ2/LFFUg+AD2ioDqQjux6dQc4cRg7v951Bqp91sjYoIEBcL34nP81fxuWm+KcVxE8NL+Dl5oYWhE+pPFYWplwcdyWd67oc3+wv61CGZsYX+KdxZTK/NP/83A6lv6w5/rLZeq7ELQ5THiurnFIQnhvKyw0vACG2nC23T5s+BI2zzJLFPcye1asmTTJqjatWn2GeWbzkDHwGPDTdSu7elVxe/sk1MbG42NOruOgWAW5miKGWqx9NDVtZUi9if0vwKJwHd3huLZm1WtQZwGCBfasK5yV4fMsJiMqMSCerKcX88wkSiajAVeV2Cc6IToviKvxzgF4T+tM0XPnSqazEg/5hM3Yk7GAkKIa/uqwslby0gxQWxQ3y/6OaB3lpSkurt/zHfNWRMcPYkeRUh8eQsbUm409GnoxT/A6rnVxnv27LmrYqpKptzZZ1I0uejI+MP6H4PQgB5+wKeo18EOA4hvQEBxADMJxkjK95xzjfgmCgSaOfDcCT8dFpF39mjNOeyb91IZ+/TwBGuxE5AvSBBsSgmlbPeRjFcMOYQUV8jtp0EFSMVFX2a7VtDGhH/+Kn/yDQobQ7FCHTijlWSt0oTgC4FvhhrWRhA8LXp992RvWTheoaHFmcTl6kVC4ipy8mp0X3SyorJcP/vkURDNFr5L8QW3glok/U+1VLrlA+1dy/tqjZvHFSJa8mdMnVSc2TpgdX8WqCKfKofvLpvHod6Wb+yZ6GCkKJeS63wK9h/6Ry81Jurl+BX6NTMV9qEGuC9WDlQLXrrkVW1snWVovOUCMdaEZLZHQyrUWgwuQD0Apbpzw2TtDqnU2lVxEwLugz7Vn4IYAA1UjNjSPEQLUJSNKW16zXFz1KaS9XWyVbrX5JK73kceLmQNVrQOJXVY5otetDoMlIOdkbQl5Q4snYfLto9Mw3ycgfd3lVTITbRasKOh3X23SVgFerrSTvK1CylZ9JLaOGUlHq6Xx9g5tU/6mBWk1tGddwUBiXtPto4i5R/MFF8as2aWVcVga1v9uvsg5f4u5+Kiuj04ho7i68fxhQDn4tFR0rCDYAzPZZLnZItDrrJjBN9ECeJxJErtdI0p1Dc8ZzbOUwT5afwGC9uceH5naRpNdEbonmz9NNE92EfYnWYoflQ3OGR0RhbJBgkgAlUMsjgMlOUu9LXq1LlEttqCiL3kazfSlUkVwsX5gK1BNzlU9NTaa/bQo04+1JMlI8QPj1dYIFAl6+yXykZip/wRS+oAZ0vpyUGyQLTEzhpHFOAAAssC80KdeMAMzNLBLTZ8wVJSHsz8vnxqcdmVX/U+aQRBk1LbHLBQwWP4dAOlEKQcNWG9R8/VzQAIpfFl8uujzM1G71fHSxvJbh8Wj71uLgI+XNV/8MtHWOa/8+f0Ta/Z0DbB/LNJcfoXvP/jfZMbnPCXDtmPSSsSplMlfq6yvlJh8cM+kf1G0QcCshhT+gPghveG3iUXc9nS8/c5wLHGqvfSp50fK8Zfqna7UOXPCZKOerztSZeIzOmLHu2bhqrJZKmHpKREIni241IFajkJ5vUE/+XyofMOmflsh7dg2SamBGdDGz1MDbBXpcrwdLqXJ6PlADvmQJ1okFRiTlhf45AMt5QHj2dOTEycrK4R2lBrGwqe3JUjjabpLYMB+O4wXnnL7QGI7T8V0HmHwDYmCqYATWaBdqGBG6Aa6f1Tnzjx38Lbtptuj11+XhnwDukeVEBNpIiSrExjGE4pExbEyvQogxiHpwePyN0L/T+c+dCH2QAs83OppShrzJtoXfrrPy/xkaUr+z/2NbjnKTBfdbaC74fCkC8eiT9bl/ALoZEqazjO94xN9No8/TX8ff9Ql46FUguKuk20XcgO4nT8xwkA0A87cQEUotGtOFcxPhqJQESPPTaudJpbRB8Sd3Wv3UQAm/ZMCJwrmN6YA/L85PaVHD38VFAg0OECWr1J1Ip6rw9Evgt+M1+Or1TWXU79utgqy3f6eWkXIPNyrhgefbwh+N/qUqX4IhmAHBEKDVVhcYCYPikclojUM5Vg4H/FuZzFQtt0SVps/+uQpD7gxOmmFwljCe44XvgWrbRcPb7dcLWNRLdYM09J+GwWes5w3QYDl363F8qBulspwhTQ5bDAlxYe1OBY033f/vNwV3e9rzr4VJRaa+ePSq2R8/hgT+6MhShqNKsYpTU9Pf1TVAg3b0L32OTgXaaCwGVgJRGIExjId8GGPoO2bjcfBzj0Yz6v7Bnx4BiIqPZEmDzO4odw7FGzYWERKZHyuVj3cGm8H16sjJ4eGTI9VPBOyeHL6Xra9EpZwEH58EjlQXIMDqlnJ0um60st7J2YBWsxIi6Pk7McS0Us4daPFVlI1J/DrbbfZEiZ+AuiW8rblqE3sze0ZVRBu0xU8g4pHncJtU2TNdChzgipHn8O5FTBPvFEBUiECdClktiE2vKRYeXlAzBxF8oC4TI64gf/3k521t5cIPwnJHv7x9tKTCzdBN69um9Fuq44ediZOuNHheP02vjR2MqygfKa9wdKgoN5ZXAL4h3zn88QYU34lgBixtDb2jyGjLxkuXRskAcWD1kqVLjar2PsM2k26kZ9pmPNam9YC08jTAufV9Z93v3OX5u9LsRMb9jh0zZhyIzBIs2lGh45s3H0OmY6Hjjh4zwXtpndO0J5DsdMczgJs2ZGKMiaLXYkBiktqINCAYEBdFMOYu56AAp2wG6907FsPxlIE4BQQ5E8zM6+JCJnzDhvXrAVOjRtQSbSq3S8e7piGP4mCHwiLWoV6R0pn16h2NsR7RAJMfm9uPxcMI3GQgxhnwDkGoKYbxxrWYZ3EYAxQzxA8Gc46puVCgCcARoPFbv37D5QTeACUE27zTQybZ50IQgx6xtwdDJuYEl9Eeg3+uHEXFOcQZbz5dwNHEyQ1x5i/CQPyTNgABQ+L8P2JaDve60H6lqch2tgzaNinJ0TrRYbPboVma1IaTI+S03hSnXXqnBZmM/ft3XiBe1DmsEeuyTIcCJ/4DncXOlxeqZsyrILzjT6ojagNYnvkdMc20V2WzNioRk/+gWbP0eg0dd+RWKLKSUm8/a9a2trL4NesLAOZqXvC20xGVj/PY0NuwA147Q59mHok2wkJYBsCfAC1ylMH6/LlcQtTj69eTzGyxLXzkN6w2dnDLrN9D2WD7kHXIm4U4CMOFDpTt4SDnuBMH4Vgu9Jut92Z5H3JXUsvhMhiFFX2loJ0ZkTBJX0lrA3w0tjxidCwa8fYVJXnnJB1eWW8OX2YC/Pu85YicJps0zzk1tNI/emy0PCJGRe/zkWJAS+pFavMDAKT39auRfWaawWAMICZAhUynbKezMkvGMGId375ZurHZATisgYs0B97USgmhZmvMQglpgQSzeJA0JShwttRMLP0UkmpuBZVVBFXEVARtAqOasH/VnUmd6jarbDhNwsu2SoPbXhW7FcExWTjvYhFVT0YF/EqZJcEGzMDka9H0tB7YzC66JDxOZTE9sDxPtEmJIdhGBFSqK5fmigPLLafHFRaHa8e+XUpOYkspJirbTHYAiW5UbGZsblKBxKTp1csxfcWPnMjH8V5QC2keiCRQffCpHy5Llrj8OHWVhSQiLpw2EhHBIoa0PAKnZQHm/b9H5nhKasrzo53EuT8iHxm0Bt/QvmETshHjeW/x5mF/rZHmBYek2o3YbYHvlMpL3aL080CdCjjUnw46CJCzot8ZhBzoSO0AuKUoPvBSezqroT3QrVxsS7e1lZS7BbY3pLPaLwUeTKeLmw/lwU/j5DQ/mZ9fsn/qMQHDNbJ1+U1OFTOFboV1thKBtR3asCzdM9t7cm1hWH5Y2M7xKEEa54cWhtcW7UcjU6pF18v61bgqmheQtDnb9cDpffuGN27cDvBT+L1skP27vhy89cwPla0SRkkWZwK/Nx4TsvUlcoXmWYgaAdpb/y2qRBDOwkpat7OE8bIp47mwGrqw6gX/9qKqmrmG5rYrriK6pTL2n7cri4neFV1a1vxP5V8NR5fGVaTc8VIhnilfVV5+A2unZVZb8BAe4/pMMPOlHdPINzI5LKs1P2iv1lhR2C488KDDeoeVndM6a6oCLvf6yk3w9UmwRrxQGerllHYDdHxwjUairbYXH/nLMojrm5Dg247HADKRJfTzyeHdybHrHKHo1mjYy3ioOAPHoVM+01z2MvbT62m0ewfuYVj2lClDDt6ynrAlj5CO9+/uToOqPVJRx1qXqelZU9xr7XKSfKvI09xevVqFpKc/vvkKG2GznbE5y3jADow2dB+3uXZ1ZIaz5oJAw0eQ9Xr9lCnZ2KatNfwHwP31+ux7U/RrD87PSd3jH268zdI7nJvbUC0Dld6T8mKhKvxQASAgeZHqDoIhfqFtptz2eLidfWphcDgS7vSPrLCEf5y+H5SZiShbmA2ZEdYECbNF2SYgBuroduuuZDZB37VWkKdI6VoGWVxxsdbbLaCy77WKPCEr7XeoiZh3lj6y7+Ex84dAsrgltjyqpCS6POaTgMF6cbHlLST98nhBDANhMv9IRLKctLJYvv70RoO5IiBXnhSRzD8SPZmgiNsZJi5wzCYocPIkwh+u5pkrJvifbY5GXalRdtTY0PD5s5HJBCtDjorNxtHtItFuWz2IUb0BMYxqwLb8Xj5DDaTRGO6C8tgN7dAxymiN429LPLdhgzH9iaEhJd2uLq5z4o6CJzgObCvuGH9ScLacCqJN6+Y/vg/fb/kc9b8dVKX8R9VO8E/xT5ab+XGq6vmn6j81yEAPCHDxxYu6ixfBDxPYSli1Snf1qqurDpzdFpK+5kaEjdo4MWF0c9u+3QgCPgywT7sjKcxuNiJSRodMnhpUII8O609W10bMS+C19YfJo4MKpoZMVkaLEHY3M8UdOc0eWLk9KjnbVw2AMtszkhRykwSBiWRcbnjNilbG5lXLQQopJ7eLVtJHbsOjn4PMSWrpxbdtBHnj2/8oPAI7t4/g6Xf0bpjkJHAYAXUWbyv7713MBq2WyTR+/tzQIMlefTu9pbVXArKb8RgWr+iUOcKYmKivN27f7sbS7y3l+LfrgY5R3i7rsToxKeAg+9CVDFjGy9dLJPT2fuwtSIfojpjsbAS+sRIahEdFFKmioh5HqY5iVHhFjYInTzqe0NLX4eJ6cUJCMDnM1I8hwwIGMIQhyNiEyPxIxFPWQI7TYAADwXdcPUVi/j6+d1Tqj7JHj4zv3115Bp1mpD76+6Hxw1sVQNQIIhYkFBJQ1aTjDHkYGiYJEx9i3+mGSu5685aEWamrwupLE1qhjR7rz4vicqAaaqVLuUvl2p0lvkW+1WZTzNQh9/MCorTlAbmrwiqbkrkrtorEni1AXx2oYbZWHnN82uAXEVvjOsW1JvYMNzx1xGZmbNHsas2LuEgin+bHn4EhmBoWzei4hlPgJ7qWK9G1WVnX4UEHjctalyGltRVo9QgG60UgMIZslAgMXNgD9Pyxhx1fx2csDLgLoVu3+m/eNLLYli/87979BjPB/6XTyJfmP8qvvM7FZR0IppE+fZIgkmv/2HvSDnqKc25c3ec5709uHKma/VhC0hl8DjJYM2eywlj+/gBiI8a0mr06WgJNKpYqzXJaM5pQHeBvcDlw+mMbvI2vW0mj2Ara1gCNHiBASZlkyhFwrAnDwuhW59b4SDCgkePgD6dr8DUdv3QSQLABH5f+RIgYrvWo+GpErRrFDfpr9Bjk+zFMPRhEv8m/7W8N4ASTrMcxPRNoHTM3IjVoCF8uB36qXr/q9/FBqehw9C9xCRJL6LsTuxXHhmQ1wo8YgxeCiVQzmx2NPhYj1zx/6y2xzIpID/NDA5TmV0PQ4KysYDTkqrkyAPULS4/IstxpkR+UGpCSEpAalG+hAkFFnUkS9aMZSDmpCQmSy4NKBewqxYMm3L9NE077JlRSgyBO1tLZ3YJTCfJFcLJqa9Xx+tGma81RFQWO8NurzdHHlQHcHz8qARK8lHy2lPp9jdUCZzmjbn9XLLBa851aCvzNqKHXtRhc3Du+9NxqW2+xQS9/dIzTMrUEPUzAiNAPjC5+9Xo4u47hBYGUVj4+/Nhl7h/PduuP7V5WP19flkNnXaFlIBK6xod2PCjsQc6QUztZrj++LkiA+TL1M0WzdafjcaNA0IJlrQKqAFpq7B/pN4aWOpcwkpIYJc6loqamslHD8O93FdXV/Vq0e+4c+ZUr3U1NEN8sHjA1V1LQ7Yug0eWNYfww8aI+ow6d2He4dmkeSWmIO2EUgFF95+aIdT+9St+xkNF1ZRu3bZijlz6DOMAAeexGPBalgEPb1qzJRrJbMA7z07tMi9FTHMaUypEpU0ZSUyoTo+qYK8zKuiuoVq9BqCovWiI+Ds2Qo81Q/pUrGzf+K7DBbQRhts4yCjJTnNip4TOY7w0YpMiIGi5IuD6DbeDF0JsFvaGH8qxkIb4ZYHR6SZHx8IGjB41lxV8QvfYSkowxJDAJZWDOckY3NYuJL2GbbwivFoNIcJZEm1U/9WJJVoHlVXlZrjCmaJ1DCl0QJXm7R+4Wse1CVfuf/vhjeYKNC9JDPf3NfUl208Tr4Jc/sricgqivpnC8pcTjJYtqcilL6PW7spogoGhXXdtSvVHg9JUTHDh+RhDlLLicl3E4JdXC5E6v9M9VHIDT273T5DOvDI/915Z3J67ErpMdvOLWi85cvlaIjimLrZgFtdJqfnWOn7XEba78p6WsLFr4SX8zAwnQ4AbSNHpVpn86Nyg1JM26z0funZUUKouYbA3i3W+bioG1smjNJrnSNInew8VAcDbXc6LDWuogCubMDvnDCuYW9C9PyBvG1tNhp7cyZnOC7UVW0o6JJAcfKDgoOzuoCFgIfvPLAZ1Lfaxlc41zv47D+vcOG99e/m/RHTfkazuKxIE9E9d4Nzxu3JHLfkcweGjyaClW3KcL8uj+XW8C4H1PXuuWnbpZVrDeuRG1N6VMi2NPd5jBXz/R9mCuyKwH9MlzZrAIo6pZhkjDLNUoYfuGL8/pAz1morkP2iZ2BjrMYE+Pm0YxtUcbndcXyDZnp9a63eubumtZ0LJdU/uemuRv+Ao5MaxJPqyEjxWmBDUE2di4mDT98hI7WLFvR2yaT1iYoBJ7FllxkjzrwxcS5i/17nO15ngkCgMXm7jY2HBbCaZbZUqOJQQ7QV83SJYJXBN1/LJ5XaHKEJ6yiqUQ9oXw5HfA+Ip0gRhNNEQbSMB1Z1xyiqMseeY3qtcO18VhldcuBZAZTW7FO78t/Dx/l5q7ixgIXbqwFFXgXfbe2xfk9yUR19113dw+bOpx93Sxpb2ga7ONrPNbO267sg7RgTsvUzf4B3b1hYAQj0TbCOse0rFdh+4LFlqbmU0qMi6/6xipxzrCxMSTLQoJ3pflo0mSyXUpum55d4pcp7uevKsperqTdxdEX8LdwCmb+wthqWPc1d6vuoOVZfop1f3Kx7X+x7zOyM65PxoubZOMBe9LvnfZjwCGmEJftuv94+TNgjFx49NQC46G8f3x+13LvGD2o5wtw4gL7ADubPdUq190e6pyQjtqQXZ2v1KsTrtRilgvh5Z98x8/ht83gK/Lhkasl91YBlgl8VQVW76WwMLg/KL8Bf4JtYXheWFheeGFwwIGC+zO4Zf+tQn+C44fOvQbQXOsfaO8ecHOqQGj19rdqq+9AbOmexJtegkshqWZXxGrG1bcB56D4pGvmbAUFv9XRh0OviaKZzVKVwp4X+KGZRQ19EtCVvTc7xQuNv8zVNHQSGeMot7gvQf14L4t7tB76ykgtimddpU31jymdcapRmcIRH0xf7UQaFCSJZbXa/WQ+T9AzmT9b/sB+pfSoHNCqh+y/tdDKk+HdFCfWEwarFraZ1XSj0F4PyPM7+Yvn1L6LFKpyyOpKY1pvckglQMBL/Tm2ds+N2EloZQM1jKhagxI2+8Ak8ZynuZPNZFrdx1T8UZvKV5abGx0XLiy1lR/u8GN3jCwl0Jsycr+80e1ze5LbvSt8L9k3onpBLdyMv3AEMapeSBvlMnjZkj6jY3WRO1f50jGTRs4rTsEyCGPMclYXRjVmARPegvB+dGYOoxWgNdsHqbOxJsaT3SdSnAxWJ3RShC5RMBgBzSMtgjIwRp3LfZqvQsEA511ncdDwUAVrUNAxn5kvZYNV49jS0sBR+MyRr2Mi+EBWP0yUDrQW4e5VedMaqHFqDngfwqyQPXaKyI66vEoO7PVK4nVnqV1e192HXzpPFeB07sfhTM2cuBt8eQBGBailWQlBAWlc76Ele+noYhavhp9vUjQCNVpePbuC91VJID0ygk+8C5BF0b/6ObSG0dgaYHe9Qu9p1M9UgQWEAYS613J4DCkiJHQzttujYpqNd9DtWHAbmLo1QLZacmKdC/EKDVfgFpIelxjAFtoPmVZJPVsi55Jw4yUBiWSUEg9iujF4Bo5OreHMwHp5sI1qciywcIwXnMqzacYjIe35jEqiApyHpEhglS+9DhsvCAta+57U+sXGjOfL2m0Z+wsBic99HZCSlhrf8papY/WgagS8WSNmtyRld5C2jATxf8gPbtG9a4BLKeBZzrXu9SZnsZH9AKGlo0dK2r7wPrKUJ1KKrMGw2BZWRukAhPWGkZdmEVbyhu5hNK77VxvBhMwG+jCyBm3ufWQBDXFYCePjN4b71nP7VHPk8CSvStYhj0GcwLferV6oCE6rLKo+dO1XUsIcntP2MxQ566iAO9a+3YB232GgFJnOqlh93oSnNmyOKGGdswWasKILABG7OfshY06h8Eo2pFxa8nRZWHR1xm6uOt8JxghgH5ZUNcVVbOrhPcY+F5j50Mfuuq5nIEG3H+P4Oa/Z9LH1RWBGb7npqPvWXjBnyN/Zrw032sqm9he8rUtAoD/oKuz7IgXaKB/g+b58Qt/fFx5J2TKTTV8+N+JUEkInG/jaZJ/K6X/CQzUhaASrEcABnppAvz9+j0LLrtHbpHMXOKmYVetptpsdc22ORqPW9PgurVoblp7JxpPPFjUB05xB+ybH7TKnFWrmfHb6uZszNG4bU0rrq3FnH9r781k9YwH+/9rX98NIbDshtOOskpViXJn/4FjZ7BNkf+gOWH/bn9z9/DQb1HQnqKGH8YPEXVXTZfdb3ZXhsy6a6b3mONmimi/uL2trbpvsi6f/NIQAstuOCk3paxS9aP+3vcPHDuDEc3/6TGndOd3+5s7gvm3VEh5jaT7YfwQXLTu6mN/XXa/4VVDZt21drV7zHEzMaLtF7eSXqXSG31+2TPTl12hk2kw9PJv99nVCfsP/kP8/AMC/9N/7r/8R//u/3+ZYmLj4vkIAEIwgmI4QVI0w3K8IEqyomq6YVq243p+EEZxkmZ5UVZ103b9ME7zsm77cV73834/AEIwgmI4QVI0w3K8IEqyomq6YVq243p+EEZxkmZ5UVZ103b9ME7zsm77cV73837//nA8nS9XQZRkRdV0w7Rsx/X8IIxucZJmeVFWddN298fz9f58f39BlGRF1XTDtGzHBRO4koGhkbGJqZm5heXPMH8ztbGFERTDCZKiGZYTCHmRWCKVyRVKlVqj1ekNRpPZYrXZHU6X2+PtE2mIoKySapccpOJJezAJlotWKGjB+/RqUe0IgVaB2VfrXiaP19wrJMbrLrPoUVJWVqud92tCWOYPX1l2DRrX7/rDn9zphSpkAk6ZOzY/zz3JE6GgrHfccNOhOwm6f9n/w7tQuJ9eHbjvcIGA1NBc5TI3k0Ga+hlmtQIwplzQSL4Dhub4tOQi+NBAnFSuhg32aVCwsj87hXVhtXh8GWuTCsruQ41KMm4CEwQdrrWQuD2caiiB1owcHx0OOEqRG3vai4Viz+DbLqdzOgZijn/dzNFIuz9O6DkjFvBhxrI9Oziw5rnAtu8b6EEyoj9Wa02eDVG0gc6c25tUsPN3oJDDwPh1uFbz/WVdmqE7UouTWDRvGm9LC4xY0+bV3FqL849b6snsUGr3xneWoxotbV4e4TNGns6yqPwC04EKapsXh/EhI2fZt3P65T//Q/6n9Y+seX4+I/CB62wUMx8WUvwYX/lT0QtR7LkHK3VcqDpqKiRjAM+rPz0MNm++Eo6YJwjhTJ43g1/IVa5yB/7w5/9dXL5/msC238FAS5kR7OyGu7r1qHDEWktfKCU1yibJsPsd6FLQmAQ3xSMpgClfdEY3rN/BB35scSeNxime3M5jrHEJ5byDcnnWTLNvsQFl3wLtOxxWXzoHNcbkyBjvHi9pTQUbWCwo4TDvNwOjFLD45fj+/bP+I1m53/EDUkXZf1g7sQZ/KznJyJjypJRxJak+KRr9uwmkHI0iUJLTKBDdUJzR0CM0QCFlEKdQHhzgFJMgbxnvOUFE7L56wWVA8+BQ0YeqtvwaaZRPnHWk4oxNawaOP5OFDmQ/gMO5BM9+t7wE0An8IYhNBoX0WEcj4CS9m/NUSW8zzDoKBak434u0zpyKHgUYbcT0j3wKGNOMp692nEeDL9uoFbTrgp4LIx8JAsdG1k/4v+qSSK5aBXDqXNT0a7eoN3RHG017Wx2iI1XagHoNHtOipTOe/exwnZX7Iqs5Jo+z1iEw4zg32324AMiMHP1l2o/QLNvdNMwdIMDadsckudnU22YU5jG8Zf2UKvtVP3BLterGAZ4sVbkkRr/GjInPYlKmYMEH7dp8YFHDPLw3ViirARmPhMSYal9lPakBwmweo0yW2WTCjx17kqk0rIY+yWx5Izmt6RFNa7XZvlDY32qGmRzTokKh1mSMRJxhYzihUascnV2bRQ9cVTjaAU0WHU2Zq6mE8QwEdDQNCFLZ3gvdkQMoBFdEcy1lPieBV+F91UfLWpC/0wwPpN0v7h4wQJ6HRh6ms1OC9RjGenqvhzZyyq5dyhxhMMIdZn0CY3sGLE/Ng3aIVcYDZNlpENSmhFPPO9x30vYccdxCI2fwqeUoHBBLOXfDIu1ejUkkBu83LSj9GlpjyrLu2z/O+nQYp8PBIAiPFjJTc2ea5JIfhhHPpszjRRaJBlVA/EhP+/5IuifDHCAj436Lmxv+VftLYAMMpzG1x6dZzy4qeBL+ggsQ718iig8I9lS3GqCFc6x8vuBDbOnPfgSLT0NRPBhSIbxVJCj33ccjHqLgZPq+L83GYGJY1Fov17BCQUhPMbTQNoDWPe/Bx6iDmWROXd75kFBH7Z5jV9TACc3XjXUkV/OLuQYeeDGDZhoYnMsTc1QzJRjP1aqSf9WrzR3DrO1U2s/ACU7kbYQwM3WfELC4HtGaksTMZxWU0o9pFpb9gsq4O9w1Ibd5ND9twAeCWWylnkyPVEOX7xtRClqquIDljE66GGQ0IVigQJk/gKSqkNits+5cOP+uJA+10+XFi2q3kcH9OYg4aL7w2acN5TlL87ayqxApCDW14YedO5fSYKfOph1KMXWF0RsfgR+YfAfnSjZHsFVpNUmgCXrkHvKEeQ5elk4fTCXJ8lqE/I5sWCgyWOl/XVanLpxlkE+/APWQjDVKo6J94BVdHDlGnfz9f4Qpp8YQVW1ZXzhrTualHYG6r9n1Ylc8DkWZtZfxOv0Mxo17IxkmVFia+bKrB959IiOv5AKvYXjV2VuxZG81daE3LCZiS3jI3qrKWTOcvSUFFdhVUBBa9/gqrLL7aMHLpb2A9gAEe61bjwz3saQZHnbHF8xj6HPwrEZmak47JgNYIoFo3/KkhjxrFyfndE6BYbRCmGxKkM/cTY09gTxk6MwYMSzzWWNt8gWYv7LUioFmDGU9RYVts552wIqTV18TWFwP4LQlMNNjU/pi4cSXUaylSY1CAmZAjvsYcl9v53wZlG8WnhANqrPTTE2aCID6HyYQFhWC1Q2fgh8KEZCnLyLn1f0TRNs6KEWH6iqPxiTkDLMz9yW2BNt2r+h8Jmmtz9STDNnrLUhsZL5g3t2KambowzC6hQUCuvdzJ0Xsfr0raWtq0YViWisdDoq1W/S2VnVewObNn5pmWnrWUrl76JJO/ek7db68dd/MEOl6V9DbHQSWXn8NDLZk1jx/AgA=') + format('woff2'), + url('iconfont.ttf?t=1762331650922') format('truetype'), url('iconfont.svg?t=1762331650922#iconfont') format('svg'); +} +.iconfont { + font-size: 16px; + font-family: iconfont !important; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.iconicon_functions_outlined::before { + content: '\e7e8'; +} +.iconicon_timeline::before { + content: '\e7e7'; +} +.iconicon_waterfalls::before { + content: '\e7e5'; +} +.iconicon_list::before { + content: '\e7e6'; +} +.iconicon_file-unknown_colorful1::before { + content: '\e7e4'; +} +.iconicon_link2::before { + content: '\e7e3'; +} +.iconicon_bot::before { + content: '\e7e2'; +} +.iconicon_365::before { + content: '\e7db'; +} +.iconicon_facial_clue::before { + content: '\e7dc'; +} +.iconicon_jin::before { + content: '\e7dd'; +} +.iconicon_facial_deal_win::before { + content: '\e7de'; +} +.iconicon_facial_business::before { + content: '\e7df'; +} +.iconicon_30::before { + content: '\e7e0'; +} +.iconicon_7::before { + content: '\e7e1'; +} +.iconicon_data_person::before { + content: '\e7d8'; +} +.iconicon_data_plan::before { + content: '\e7d9'; +} +.iconicon_data_record::before { + content: '\e7da'; +} +.iconicon_folder_collapse::before { + content: '\e7d6'; +} +.iconicon_folder_expansion::before { + content: '\e7d7'; +} +.iconicon_dashboard1::before { + content: '\e7d5'; +} +.iconicon_export::before { + content: '\e746'; +} +.iconicon_phone_outgoing::before { + content: '\e743'; +} +.iconicon_that_person::before { + content: '\e66a'; +} +.iconicon_file1::before { + content: '\e742'; +} +.iconicon_enterprise::before { + content: '\e732'; +} +.iconicon_send_colorful::before { + content: '\e7d4'; +} +.iconicon_edit1::before { + content: '\e71d'; +} +.iconicon_my::before { + content: '\e714'; +} +.iconicon_general::before { + content: '\e6ea'; +} +.iconicon_serious::before { + content: '\e6ef'; +} +.iconicon_succeed_filled::before { + content: '\e6ba'; +} +.iconicon_block_filled::before { + content: '\e6ec'; +} +.iconicon_testing::before { + content: '\e624'; +} +.iconicon_off_screen::before { + content: '\e62d'; +} +.iconicon_full_screen_one::before { + content: '\e631'; +} +.iconicon_disable::before { + content: '\e661'; +} +.icona-icon_file-compressed_colorful_ash::before { + content: '\e63f'; +} +.icona-icon_file-compressed_colorful::before { + content: '\e640'; +} +.iconicon_file-image_colorful_ash::before { + content: '\e642'; +} +.iconicon_file-ppt_colorful_ash::before { + content: '\e643'; +} +.iconicon_file-pdf_colorful_ash::before { + content: '\e644'; +} +.icona-icon_file-compressed_colorful1::before { + content: '\e645'; +} +.iconicon_file-image_colorful::before { + content: '\e646'; +} +.iconicon_file-word_colorful_ash::before { + content: '\e647'; +} +.iconicon_file-sketch_colorful::before { + content: '\e648'; +} +.iconicon_file-ppt_colorful::before { + content: '\e649'; +} +.iconicon_file_video_colorful_ash::before { + content: '\e64c'; +} +.iconicon_file-excel_colorful::before { + content: '\e64d'; +} +.iconicon_file-pdf_colorful::before { + content: '\e64e'; +} +.iconicon_file_video_colorful::before { + content: '\e64f'; +} +.iconicon_file-CSV_colorful_ash::before { + content: '\e650'; +} +.iconicon_file-sketch_colorful_ash::before { + content: '\e651'; +} +.iconicon_file-CSV_colorful::before { + content: '\e652'; +} +.iconicon_file-sql_colorful_ash::before { + content: '\e653'; +} +.iconicon_file-text_colorful::before { + content: '\e654'; +} +.iconicon_file-word_colorful::before { + content: '\e655'; +} +.iconicon_file-xmind_colorful::before { + content: '\e656'; +} +.iconicon_file-sql_colorful::before { + content: '\e657'; +} +.iconicon_file-text_colorful_ash::before { + content: '\e658'; +} +.iconicon_file-xmind_colorful_ash::before { + content: '\e659'; +} +.iconicon_file-unknown_colorful::before { + content: '\e660'; +} +.icona-icon_file-jar_colorful::before { + content: '\e7c5'; +} +.iconlogo_dingtalk::before { + content: '\e7cb'; +} +.iconlogo_lark::before { + content: '\e7d1'; +} +.iconlogo_wechat-work::before { + content: '\e7d2'; +} +.icona-icon_file-json::before { + content: '\e7d3'; +} +.icona-icon_file-JMX::before { + content: '\e69e'; +} +.iconicon_file_har::before { + content: '\e6a1'; +} +.iconicon_filter_clear::before { + content: '\e7cf'; +} +.iconicon_filter::before { + content: '\e7d0'; +} +.iconicon_file-excel_colorful_ash::before { + content: '\e64a'; +} +.iconicon_font_color::before { + content: '\e7ce'; +} +.iconicon_add_pic::before { + content: '\e7c4'; +} +.iconicon_text_underline::before { + content: '\e7c6'; +} +.iconicon_text_italic::before { + content: '\e7c7'; +} +.iconicon_add_text::before { + content: '\e7c8'; +} +.iconicon_align_text_right::before { + content: '\e7c9'; +} +.iconicon_align_text_left::before { + content: '\e7ca'; +} +.iconicon_text_bold::before { + content: '\e7cc'; +} +.iconicon_align_text_center::before { + content: '\e7cd'; +} +.iconicon_map::before { + content: '\e7bb'; +} +.icona-icon_multiple_selection_of_departments::before { + content: '\e7bc'; +} +.iconicon_calendar1::before { + content: '\e7bd'; +} +.iconicon_link1::before { + content: '\e7be'; +} +.icona-icon_collectbeifen12::before { + content: '\e7bf'; +} +.iconicon_department_single_choice::before { + content: '\e7c0'; +} +.iconicon_find::before { + content: '\e7c1'; +} +.iconicon_handwritten_signature::before { + content: '\e7c2'; +} +.iconicon_button::before { + content: '\e7c3'; +} +.iconicon_select_data::before { + content: '\e7ab'; +} +.iconicon_single_line_text::before { + content: '\e7ac'; +} +.iconicon_multiple_choice_of_members::before { + content: '\e7ad'; +} +.iconicon_subform::before { + content: '\e7ae'; +} +.iconicon_picture::before { + content: '\e7af'; +} +.iconicon_phone::before { + content: '\e7b0'; +} +.iconicon_local::before { + content: '\e7b1'; +} +.iconicon_dividing_line::before { + content: '\e7b2'; +} +.iconicon_multiline::before { + content: '\e7b3'; +} +.iconicon_member_single_choice::before { + content: '\e7b4'; +} +.iconicon_radio::before { + content: '\e7b5'; +} +.iconicon_serial_number::before { + content: '\e7b6'; +} +.iconicon_text_recognition::before { + content: '\e7b7'; +} +.iconicon_pull_down_single_choice::before { + content: '\e7b8'; +} +.iconicon_hashtag_key::before { + content: '\e7b9'; +} +.iconicon_pull_down_multiple_selection::before { + content: '\e7ba'; +} +.iconorder-descending::before { + content: '\e7a0'; +} +.iconicon_video::before { + content: '\e7a1'; +} +.iconicon_zoom_in::before { + content: '\e7a2'; +} +.iconorder-ascending::before { + content: '\e7a3'; +} +.iconicon_zoom_out::before { + content: '\e7a4'; +} +.iconicon_usergroup::before { + content: '\e7a5'; +} +.iconicon_wifi::before { + content: '\e7a6'; +} +.iconicon_view_module::before { + content: '\e7a7'; +} +.iconview-column::before { + content: '\e7a8'; +} +.iconview-list::before { + content: '\e7a9'; +} +.iconicon_wallet::before { + content: '\e7aa'; +} +.iconicon_usb::before { + content: '\e78c'; +} +.iconicon_user_add::before { + content: '\e78d'; +} +.iconicon_swap_right::before { + content: '\e78e'; +} +.iconicon_user_avatar::before { + content: '\e78f'; +} +.iconicon_thumb_up::before { + content: '\e790'; +} +.iconicon_time_filled::before { + content: '\e791'; +} +.iconicon_tools::before { + content: '\e792'; +} +.iconicon_user_clear::before { + content: '\e793'; +} +.iconicon_time::before { + content: '\e794'; +} +.iconicon_unfold_less::before { + content: '\e795'; +} +.iconicon_thumb_down::before { + content: '\e796'; +} +.iconicon_user_talk::before { + content: '\e797'; +} +.iconicon_tips::before { + content: '\e798'; +} +.iconicon_unfold_more::before { + content: '\e799'; +} +.iconicon_upload::before { + content: '\e79a'; +} +.iconicon_user::before { + content: '\e79b'; +} +.iconicon_usergroup_add::before { + content: '\e79c'; +} +.iconicon_swap::before { + content: '\e79d'; +} +.iconicon_usergroup_clear::before { + content: '\e79e'; +} +.iconicon_user_circle::before { + content: '\e79f'; +} +.iconicon_queue::before { + content: '\e782'; +} +.iconicon_refresh::before { + content: '\e783'; +} +.iconicon_pin::before { + content: '\e784'; +} +.iconicon_poweroff::before { + content: '\e785'; +} +.iconicon_photo::before { + content: '\e786'; +} +.iconicon_precise_monitor::before { + content: '\e787'; +} +.iconicon_print::before { + content: '\e788'; +} +.iconicon_play_circle::before { + content: '\e789'; +} +.iconicon_play::before { + content: '\e78a'; +} +.iconicon_remove::before { + content: '\e78b'; +} +.iconicon_server::before { + content: '\e776'; +} +.iconicon_rollfront::before { + content: '\e777'; +} +.iconicon_scan::before { + content: '\e778'; +} +.iconicon_rotation::before { + content: '\e779'; +} +.iconicon_pause_circle::before { + content: '\e77a'; +} +.iconicon_pause::before { + content: '\e77b'; +} +.iconicon_pin_filled::before { + content: '\e77c'; +} +.iconicon_play_circle_filled::before { + content: '\e77d'; +} +.iconicon_qrcode::before { + content: '\e77e'; +} +.iconicon_previous::before { + content: '\e77f'; +} +.iconicon_rollback::before { + content: '\e780'; +} +.iconicon_play_circle_stroke::before { + content: '\e781'; +} +.iconicon_swap_left::before { + content: '\e766'; +} +.iconicon_stop_circle::before { + content: '\e767'; +} +.iconicon_sound::before { + content: '\e768'; +} +.iconicon_setting::before { + content: '\e769'; +} +.iconicon_share::before { + content: '\e76a'; +} +.iconicon_search::before { + content: '\e76b'; +} +.iconicon_service::before { + content: '\e76c'; +} +.iconicon_star::before { + content: '\e76d'; +} +.iconicon_round::before { + content: '\e76e'; +} +.iconicon_star_filled::before { + content: '\e76f'; +} +.iconicon_shop::before { + content: '\e770'; +} +.iconicon_stop_circle_filled::before { + content: '\e771'; +} +.iconicon_slash::before { + content: '\e772'; +} +.iconicon_save::before { + content: '\e773'; +} +.iconicon_secured::before { + content: '\e774'; +} +.iconicon_root_list::before { + content: '\e775'; +} +.iconicon_menu-application1::before { + content: '\e75f'; +} +.iconicon_mobile_vibrate::before { + content: '\e760'; +} +.iconicon_mirror::before { + content: '\e761'; +} +.iconicon_move::before { + content: '\e762'; +} +.iconicon_page_first::before { + content: '\e763'; +} +.iconicon_notification_filled::before { + content: '\e764'; +} +.iconicon_minus_rectangle1::before { + content: '\e765'; +} +.iconicon_menu_unfold1::before { + content: '\e752'; +} +.iconicon_page_last::before { + content: '\e753'; +} +.iconicon_pause_circle_filled::before { + content: '\e754'; +} +.iconicon_money_circle::before { + content: '\e755'; +} +.iconicon_mail1::before { + content: '\e756'; +} +.iconicon_minus_circle_filled1::before { + content: '\e757'; +} +.iconicon_more::before { + content: '\e758'; +} +.iconicon_menu_fold1::before { + content: '\e759'; +} +.iconicon_next::before { + content: '\e75a'; +} +.iconicon_notification::before { + content: '\e75b'; +} +.iconicon_minus_circle1::before { + content: '\e75c'; +} +.iconicon_mobile::before { + content: '\e75d'; +} +.iconicon_order_adjustment_column::before { + content: '\e75e'; +} +.iconicon_jump::before { + content: '\e740'; +} +.iconicon_lock_off::before { + content: '\e741'; +} +.iconicon_loading::before { + content: '\e744'; +} +.iconicon_laptop::before { + content: '\e745'; +} +.iconicon_mail::before { + content: '\e747'; +} +.iconicon_minus_rectangle::before { + content: '\e748'; +} +.iconicon_location::before { + content: '\e749'; +} +.iconicon_lock_on::before { + content: '\e74a'; +} +.iconicon_link_unlink::before { + content: '\e74b'; +} +.iconicon_menu-application::before { + content: '\e74c'; +} +.iconicon_minus_circle::before { + content: '\e74d'; +} +.iconicon_logout::before { + content: '\e74e'; +} +.iconicon_link::before { + content: '\e74f'; +} +.iconicon_layers::before { + content: '\e750'; +} +.iconicon_login::before { + content: '\e751'; +} +.iconicon_file_paste::before { + content: '\e718'; +} +.iconicon_error_circle::before { + content: '\e719'; +} +.iconicon_file_word::before { + content: '\e71a'; +} +.iconicon_enter::before { + content: '\e71b'; +} +.iconicon_file::before { + content: '\e71c'; +} +.iconicon_fork::before { + content: '\e71e'; +} +.iconicon_file_copy::before { + content: '\e71f'; +} +.iconicon_flag::before { + content: '\e720'; +} +.iconicon_folder::before { + content: '\e721'; +} +.iconicon_file_image::before { + content: '\e722'; +} +.iconicon_file_powerpoint::before { + content: '\e723'; +} +.iconicon_file_add::before { + content: '\e724'; +} +.iconicon_file_unknown::before { + content: '\e725'; +} +.iconicon_error_circle_filled::before { + content: '\e726'; +} +.iconicon_help::before { + content: '\e727'; +} +.iconicon_fullscreen_exit::before { + content: '\e728'; +} +.iconicon_file_pdf::before { + content: '\e729'; +} +.iconicon_gift::before { + content: '\e72a'; +} +.iconicon_gender_female::before { + content: '\e72b'; +} +.iconicon_home1::before { + content: '\e72c'; +} +.iconicon_heart_filled::before { + content: '\e72d'; +} +.iconicon_info_circle::before { + content: '\e72e'; +} +.iconicon_file_icon::before { + content: '\e72f'; +} +.iconicon_heart::before { + content: '\e730'; +} +.iconicon_history::before { + content: '\e731'; +} +.iconicon_hourglass::before { + content: '\e733'; +} +.iconicon_info_circle_filled::before { + content: '\e734'; +} +.iconicon_folder_open::before { + content: '\e735'; +} +.iconicon_help_circle_filled::before { + content: '\e736'; +} +.iconicon_help_circle::before { + content: '\e737'; +} +.iconicon_image::before { + content: '\e738'; +} +.iconicon_gender_male::before { + content: '\e739'; +} +.iconicon_fullsreen::before { + content: '\e73a'; +} +.iconicon_image_error::before { + content: '\e73b'; +} +.iconicon_folder_add::before { + content: '\e73c'; +} +.iconicon_file_excel::before { + content: '\e73d'; +} +.iconicon_forward::before { + content: '\e73e'; +} +.iconicon_internet::before { + content: '\e73f'; +} +.iconicon_check_circle_filled::before { + content: '\e6f1'; +} +.iconicon_chevron_right_circle::before { + content: '\e6f2'; +} +.iconicon_creditcard::before { + content: '\e6f3'; +} +.iconicon_close_circle_filled::before { + content: '\e6f4'; +} +.iconicon_circle::before { + content: '\e6f5'; +} +.iconicon_chevron_left_circle::before { + content: '\e6f6'; +} +.iconicon_chevron_left_double::before { + content: '\e6f7'; +} +.iconicon_chevron_right::before { + content: '\e6f8'; +} +.iconicon_control_platform::before { + content: '\e6f9'; +} +.iconicon_close::before { + content: '\e6fa'; +} +.iconicon_check::before { + content: '\e6fb'; +} +.iconicon_chevron_right_double::before { + content: '\e6fc'; +} +.iconicon_check_rectangle_filled::before { + content: '\e6fd'; +} +.iconicon_cloud_upload::before { + content: '\e6fe'; +} +.iconicon_chevron_up_circle::before { + content: '\e6ff'; +} +.iconicon_chevron_down_circle::before { + content: '\e700'; +} +.iconicon_chevron_up_rectangle::before { + content: '\e701'; +} +.iconicon_close_rectangle::before { + content: '\e702'; +} +.iconicon_chevron_down_rectangle::before { + content: '\e703'; +} +.iconicon_chevron_left::before { + content: '\e704'; +} +.iconicon_check_circle::before { + content: '\e705'; +} +.iconicon_chevron_right_rectangle::before { + content: '\e706'; +} +.iconicon_check_rectangle::before { + content: '\e707'; +} +.iconicon_close_circle::before { + content: '\e708'; +} +.iconicon_cloud::before { + content: '\e709'; +} +.iconicon_chevron_down::before { + content: '\e70a'; +} +.iconicon_delete::before { + content: '\e70b'; +} +.iconicon_chevron_up::before { + content: '\e70c'; +} +.iconicon_code::before { + content: '\e70d'; +} +.iconicon_dashboard::before { + content: '\e70e'; +} +.iconicon_clear::before { + content: '\e70f'; +} +.iconicon_chevron_left_rectangle::before { + content: '\e710'; +} +.iconicon_cloud_download::before { + content: '\e711'; +} +.iconicon_desktop::before { + content: '\e712'; +} +.iconicon_ellipsis::before { + content: '\e713'; +} +.iconicon_edit::before { + content: '\e715'; +} +.iconicon_download::before { + content: '\e716'; +} +.iconicon_discount_filled::before { + content: '\e717'; +} +.iconicon_caret_down::before { + content: '\e6c9'; +} +.iconicon_backtop::before { + content: '\e6ca'; +} +.iconicon_caret_right::before { + content: '\e6cb'; +} +.iconicon_bulletpoint::before { + content: '\e6cc'; +} +.iconicon_caret_right_small::before { + content: '\e6cd'; +} +.iconicon_add::before { + content: '\e6ce'; +} +.iconicon_caret_left::before { + content: '\e6cf'; +} +.iconicon_caret_down_small::before { + content: '\e6d0'; +} +.iconicon_caret_left_small::before { + content: '\e6d1'; +} +.iconchart-bar::before { + content: '\e6d2'; +} +.iconicon_arrow_left::before { + content: '\e6d3'; +} +.iconicon_calendar::before { + content: '\e6d4'; +} +.iconicon_chart_pie::before { + content: '\e6d5'; +} +.iconicon_arrow_right::before { + content: '\e6d6'; +} +.iconicon_caret_up_small::before { + content: '\e6d7'; +} +.iconicon_browse::before { + content: '\e6d9'; +} +.iconicon_books::before { + content: '\e6db'; +} +.iconicon_app::before { + content: '\e6dc'; +} +.iconicon_arrow_up::before { + content: '\e6dd'; +} +.iconicon_backward::before { + content: '\e6df'; +} +.iconicon_cart::before { + content: '\e6e0'; +} +.iconicon_attach::before { + content: '\e6e1'; +} +.iconchart::before { + content: '\e6e2'; +} +.iconicon_chat::before { + content: '\e6e3'; +} +.iconicon_add_rectangle::before { + content: '\e6e4'; +} +.iconicon_add_circle::before { + content: '\e6e5'; +} +.iconicon_barcode::before { + content: '\e6e6'; +} +.iconicon_arrow_down_rectangle::before { + content: '\e6e7'; +} +.iconicon_browse_off::before { + content: '\e6e8'; +} +.iconicon_backtop_rectangle::before { + content: '\e6e9'; +} +.iconicon_arrow_down::before { + content: '\e6eb'; +} +.iconicon_call::before { + content: '\e6ed'; +} +.iconicon_caret_up::before { + content: '\e6ee'; +} +.iconicon_chart_bubble::before { + content: '\e6f0'; +} +.iconicon_data::before { + content: '\e6ae'; +} +.iconicon_clue::before { + content: '\e6af'; +} +.iconicon_business_opportunity::before { + content: '\e6b0'; +} +.iconicon_set_up::before { + content: '\e6a8'; +} +.iconicon_not_bookmarked::before { + content: '\e6a9'; +} +.iconicon_product::before { + content: '\e6aa'; +} +.iconicon_customer::before { + content: '\e6ab'; +} +.iconicon_home::before { + content: '\e6ac'; +} +.iconicon_collect::before { + content: '\e6ad'; +} +.iconicon_search-outline_outlined::before { + content: '\e6c2'; +} +.iconicon-alarmclock::before { + content: '\e6c8'; +} diff --git a/frontend/packages/mobile/src/assets/icon-font/iconfont.js b/frontend/packages/mobile/src/assets/icon-font/iconfont.js new file mode 100644 index 0000000..cb8c739 --- /dev/null +++ b/frontend/packages/mobile/src/assets/icon-font/iconfont.js @@ -0,0 +1,65 @@ +(window._iconfont_svg_string_4798260 = + ''), + ((l) => { + var a = (h = (h = document.getElementsByTagName('script'))[h.length - 1]).getAttribute('data-injectcss'), + h = h.getAttribute('data-disable-injectsvg'); + if (!h) { + var o, + i, + v, + c, + m, + z = function (a, h) { + h.parentNode.insertBefore(a, h); + }; + if (a && !l.__iconfont__svg__cssinject__) { + l.__iconfont__svg__cssinject__ = !0; + try { + document.write( + '' + ); + } catch (a) { + console && console.log(a); + } + } + (o = function () { + var a, + h = document.createElement('div'); + (h.innerHTML = l._iconfont_svg_string_4798260), + (h = h.getElementsByTagName('svg')[0]) && + (h.setAttribute('aria-hidden', 'true'), + (h.style.position = 'absolute'), + (h.style.width = 0), + (h.style.height = 0), + (h.style.overflow = 'hidden'), + (h = h), + (a = document.body).firstChild ? z(h, a.firstChild) : a.appendChild(h)); + }), + document.addEventListener + ? ~['complete', 'loaded', 'interactive'].indexOf(document.readyState) + ? setTimeout(o, 0) + : ((i = function () { + document.removeEventListener('DOMContentLoaded', i, !1), o(); + }), + document.addEventListener('DOMContentLoaded', i, !1)) + : document.attachEvent && + ((v = o), + (c = l.document), + (m = !1), + p(), + (c.onreadystatechange = function () { + 'complete' == c.readyState && ((c.onreadystatechange = null), t()); + })); + } + function t() { + m || ((m = !0), v()); + } + function p() { + try { + c.documentElement.doScroll('left'); + } catch (a) { + return void setTimeout(p, 50); + } + t(); + } + })(window); diff --git a/frontend/packages/mobile/src/assets/icon-font/iconfont.json b/frontend/packages/mobile/src/assets/icon-font/iconfont.json new file mode 100644 index 0000000..83e005b --- /dev/null +++ b/frontend/packages/mobile/src/assets/icon-font/iconfont.json @@ -0,0 +1,2319 @@ +{ + "id": "4798260", + "name": "Cordys", + "font_family": "iconfont", + "css_prefix_text": "icon", + "description": "", + "glyphs": [ + { + "icon_id": "36471606", + "name": "icon_functions_outlined", + "font_class": "icon_functions_outlined", + "unicode": "e7e8", + "unicode_decimal": 59368 + }, + { + "icon_id": "45749556", + "name": "icon_timeline", + "font_class": "icon_timeline", + "unicode": "e7e7", + "unicode_decimal": 59367 + }, + { + "icon_id": "45699122", + "name": "icon_waterfalls", + "font_class": "icon_waterfalls", + "unicode": "e7e5", + "unicode_decimal": 59365 + }, + { + "icon_id": "45699123", + "name": "icon_list", + "font_class": "icon_list", + "unicode": "e7e6", + "unicode_decimal": 59366 + }, + { + "icon_id": "34134721", + "name": "icon_file-unknown_colorful", + "font_class": "icon_file-unknown_colorful1", + "unicode": "e7e4", + "unicode_decimal": 59364 + }, + { + "icon_id": "45596831", + "name": "icon_link", + "font_class": "icon_link2", + "unicode": "e7e3", + "unicode_decimal": 59363 + }, + { + "icon_id": "45533015", + "name": "icon_bot", + "font_class": "icon_bot", + "unicode": "e7e2", + "unicode_decimal": 59362 + }, + { + "icon_id": "45453687", + "name": "icon_365", + "font_class": "icon_365", + "unicode": "e7db", + "unicode_decimal": 59355 + }, + { + "icon_id": "45453688", + "name": "icon_facial_clue", + "font_class": "icon_facial_clue", + "unicode": "e7dc", + "unicode_decimal": 59356 + }, + { + "icon_id": "45453683", + "name": "icon_jin", + "font_class": "icon_jin", + "unicode": "e7dd", + "unicode_decimal": 59357 + }, + { + "icon_id": "45453686", + "name": "icon_facial_deal_win", + "font_class": "icon_facial_deal_win", + "unicode": "e7de", + "unicode_decimal": 59358 + }, + { + "icon_id": "45453685", + "name": "icon_facial_business", + "font_class": "icon_facial_business", + "unicode": "e7df", + "unicode_decimal": 59359 + }, + { + "icon_id": "45453684", + "name": "icon_30", + "font_class": "icon_30", + "unicode": "e7e0", + "unicode_decimal": 59360 + }, + { + "icon_id": "45453682", + "name": "icon_7", + "font_class": "icon_7", + "unicode": "e7e1", + "unicode_decimal": 59361 + }, + { + "icon_id": "44783033", + "name": "icon_data_person", + "font_class": "icon_data_person", + "unicode": "e7d8", + "unicode_decimal": 59352 + }, + { + "icon_id": "44783034", + "name": "icon_data_plan", + "font_class": "icon_data_plan", + "unicode": "e7d9", + "unicode_decimal": 59353 + }, + { + "icon_id": "44783032", + "name": "icon_data_record", + "font_class": "icon_data_record", + "unicode": "e7da", + "unicode_decimal": 59354 + }, + { + "icon_id": "44765698", + "name": "icon_folder_collapse", + "font_class": "icon_folder_collapse", + "unicode": "e7d6", + "unicode_decimal": 59350 + }, + { + "icon_id": "44765697", + "name": "icon_folder_expansion", + "font_class": "icon_folder_expansion", + "unicode": "e7d7", + "unicode_decimal": 59351 + }, + { + "icon_id": "44764201", + "name": "icon_dashboard", + "font_class": "icon_dashboard1", + "unicode": "e7d5", + "unicode_decimal": 59349 + }, + { + "icon_id": "44646133", + "name": "icon_export", + "font_class": "icon_export", + "unicode": "e746", + "unicode_decimal": 59206 + }, + { + "icon_id": "44384502", + "name": "icon_phone_outgoing", + "font_class": "icon_phone_outgoing", + "unicode": "e743", + "unicode_decimal": 59203 + }, + { + "icon_id": "36511677", + "name": "icon_that_person", + "font_class": "icon_that_person", + "unicode": "e66a", + "unicode_decimal": 58986 + }, + { + "icon_id": "44245520", + "name": "icon_file", + "font_class": "icon_file1", + "unicode": "e742", + "unicode_decimal": 59202 + }, + { + "icon_id": "44245480", + "name": "icon_enterprise", + "font_class": "icon_enterprise", + "unicode": "e732", + "unicode_decimal": 59186 + }, + { + "icon_id": "38020829", + "name": "icon_send_colorful", + "font_class": "icon_send_colorful", + "unicode": "e7d4", + "unicode_decimal": 59348 + }, + { + "icon_id": "43908817", + "name": "icon_edit", + "font_class": "icon_edit1", + "unicode": "e71d", + "unicode_decimal": 59165 + }, + { + "icon_id": "43648007", + "name": "icon_my", + "font_class": "icon_my", + "unicode": "e714", + "unicode_decimal": 59156 + }, + { + "icon_id": "43356063", + "name": "icon_general", + "font_class": "icon_general", + "unicode": "e6ea", + "unicode_decimal": 59114 + }, + { + "icon_id": "43356062", + "name": "icon_serious", + "font_class": "icon_serious", + "unicode": "e6ef", + "unicode_decimal": 59119 + }, + { + "icon_id": "32853333", + "name": "icon_succeed_filled", + "font_class": "icon_succeed_filled", + "unicode": "e6ba", + "unicode_decimal": 59066 + }, + { + "icon_id": "34061213", + "name": "icon_block_filled", + "font_class": "icon_block_filled", + "unicode": "e6ec", + "unicode_decimal": 59116 + }, + { + "icon_id": "36193154", + "name": "icon_testing", + "font_class": "icon_testing", + "unicode": "e624", + "unicode_decimal": 58916 + }, + { + "icon_id": "36193894", + "name": "icon_off_screen", + "font_class": "icon_off_screen", + "unicode": "e62d", + "unicode_decimal": 58925 + }, + { + "icon_id": "36194721", + "name": "icon_full_screen_one", + "font_class": "icon_full_screen_one", + "unicode": "e631", + "unicode_decimal": 58929 + }, + { + "icon_id": "36196999", + "name": "icon_disable", + "font_class": "icon_disable", + "unicode": "e661", + "unicode_decimal": 58977 + }, + { + "icon_id": "36196448", + "name": "icon_file- compressed_colorful_ash 2", + "font_class": "a-icon_file-compressed_colorful_ash", + "unicode": "e63f", + "unicode_decimal": 58943 + }, + { + "icon_id": "36196449", + "name": "icon_file- compressed_colorful 2", + "font_class": "a-icon_file-compressed_colorful", + "unicode": "e640", + "unicode_decimal": 58944 + }, + { + "icon_id": "36196451", + "name": "icon_file-image_colorful_ash", + "font_class": "icon_file-image_colorful_ash", + "unicode": "e642", + "unicode_decimal": 58946 + }, + { + "icon_id": "36196452", + "name": "icon_file-ppt_colorful_ash", + "font_class": "icon_file-ppt_colorful_ash", + "unicode": "e643", + "unicode_decimal": 58947 + }, + { + "icon_id": "36196453", + "name": "icon_file-pdf_colorful_ash", + "font_class": "icon_file-pdf_colorful_ash", + "unicode": "e644", + "unicode_decimal": 58948 + }, + { + "icon_id": "36196454", + "name": "icon_file- compressed_colorful", + "font_class": "a-icon_file-compressed_colorful1", + "unicode": "e645", + "unicode_decimal": 58949 + }, + { + "icon_id": "36196455", + "name": "icon_file-image_colorful", + "font_class": "icon_file-image_colorful", + "unicode": "e646", + "unicode_decimal": 58950 + }, + { + "icon_id": "36196456", + "name": "icon_file-word_colorful_ash", + "font_class": "icon_file-word_colorful_ash", + "unicode": "e647", + "unicode_decimal": 58951 + }, + { + "icon_id": "36196457", + "name": "icon_file-sketch_colorful", + "font_class": "icon_file-sketch_colorful", + "unicode": "e648", + "unicode_decimal": 58952 + }, + { + "icon_id": "36196458", + "name": "icon_file-ppt_colorful", + "font_class": "icon_file-ppt_colorful", + "unicode": "e649", + "unicode_decimal": 58953 + }, + { + "icon_id": "36196460", + "name": "icon_file_video_colorful_ash", + "font_class": "icon_file_video_colorful_ash", + "unicode": "e64c", + "unicode_decimal": 58956 + }, + { + "icon_id": "36196461", + "name": "icon_file-excel_colorful", + "font_class": "icon_file-excel_colorful", + "unicode": "e64d", + "unicode_decimal": 58957 + }, + { + "icon_id": "36196462", + "name": "icon_file-pdf_colorful", + "font_class": "icon_file-pdf_colorful", + "unicode": "e64e", + "unicode_decimal": 58958 + }, + { + "icon_id": "36196463", + "name": "icon_file_video_colorful", + "font_class": "icon_file_video_colorful", + "unicode": "e64f", + "unicode_decimal": 58959 + }, + { + "icon_id": "36196464", + "name": "icon_file-CSV_colorful_ash", + "font_class": "icon_file-CSV_colorful_ash", + "unicode": "e650", + "unicode_decimal": 58960 + }, + { + "icon_id": "36196465", + "name": "icon_file-sketch_colorful_ash", + "font_class": "icon_file-sketch_colorful_ash", + "unicode": "e651", + "unicode_decimal": 58961 + }, + { + "icon_id": "36196466", + "name": "icon_file-CSV_colorful", + "font_class": "icon_file-CSV_colorful", + "unicode": "e652", + "unicode_decimal": 58962 + }, + { + "icon_id": "36196467", + "name": "icon_file-sql_colorful_ash", + "font_class": "icon_file-sql_colorful_ash", + "unicode": "e653", + "unicode_decimal": 58963 + }, + { + "icon_id": "36196468", + "name": "icon_file-text_colorful", + "font_class": "icon_file-text_colorful", + "unicode": "e654", + "unicode_decimal": 58964 + }, + { + "icon_id": "36196469", + "name": "icon_file-word_colorful", + "font_class": "icon_file-word_colorful", + "unicode": "e655", + "unicode_decimal": 58965 + }, + { + "icon_id": "36196470", + "name": "icon_file-xmind_colorful", + "font_class": "icon_file-xmind_colorful", + "unicode": "e656", + "unicode_decimal": 58966 + }, + { + "icon_id": "36196471", + "name": "icon_file-sql_colorful", + "font_class": "icon_file-sql_colorful", + "unicode": "e657", + "unicode_decimal": 58967 + }, + { + "icon_id": "36196472", + "name": "icon_file-text_colorful_ash", + "font_class": "icon_file-text_colorful_ash", + "unicode": "e658", + "unicode_decimal": 58968 + }, + { + "icon_id": "36196473", + "name": "icon_file-xmind_colorful_ash", + "font_class": "icon_file-xmind_colorful_ash", + "unicode": "e659", + "unicode_decimal": 58969 + }, + { + "icon_id": "36196937", + "name": "icon_file-unknown_colorful", + "font_class": "icon_file-unknown_colorful", + "unicode": "e660", + "unicode_decimal": 58976 + }, + { + "icon_id": "37227608", + "name": "icon_file- jar_colorful", + "font_class": "a-icon_file-jar_colorful", + "unicode": "e7c5", + "unicode_decimal": 59333 + }, + { + "icon_id": "37243340", + "name": "logo_dingtalk", + "font_class": "logo_dingtalk", + "unicode": "e7cb", + "unicode_decimal": 59339 + }, + { + "icon_id": "37243341", + "name": "logo_lark", + "font_class": "logo_lark", + "unicode": "e7d1", + "unicode_decimal": 59345 + }, + { + "icon_id": "37243383", + "name": "logo_wechat-work", + "font_class": "logo_wechat-work", + "unicode": "e7d2", + "unicode_decimal": 59346 + }, + { + "icon_id": "38747707", + "name": "icon_file- json", + "font_class": "a-icon_file-json", + "unicode": "e7d3", + "unicode_decimal": 59347 + }, + { + "icon_id": "41446178", + "name": "icon_file- JMX", + "font_class": "a-icon_file-JMX", + "unicode": "e69e", + "unicode_decimal": 59038 + }, + { + "icon_id": "41701029", + "name": "icon_file_har", + "font_class": "icon_file_har", + "unicode": "e6a1", + "unicode_decimal": 59041 + }, + { + "icon_id": "43095419", + "name": "icon_filter_clear", + "font_class": "icon_filter_clear", + "unicode": "e7cf", + "unicode_decimal": 59343 + }, + { + "icon_id": "43095418", + "name": "icon_filter", + "font_class": "icon_filter", + "unicode": "e7d0", + "unicode_decimal": 59344 + }, + { + "icon_id": "36196459", + "name": "icon_file-excel_colorful_ash", + "font_class": "icon_file-excel_colorful_ash", + "unicode": "e64a", + "unicode_decimal": 58954 + }, + { + "icon_id": "43088962", + "name": "icon_font_color", + "font_class": "icon_font_color", + "unicode": "e7ce", + "unicode_decimal": 59342 + }, + { + "icon_id": "43088706", + "name": "icon_add_pic", + "font_class": "icon_add_pic", + "unicode": "e7c4", + "unicode_decimal": 59332 + }, + { + "icon_id": "43088703", + "name": "icon_text_underline", + "font_class": "icon_text_underline", + "unicode": "e7c6", + "unicode_decimal": 59334 + }, + { + "icon_id": "43088704", + "name": "icon_text_italic", + "font_class": "icon_text_italic", + "unicode": "e7c7", + "unicode_decimal": 59335 + }, + { + "icon_id": "43088696", + "name": "icon_add_text", + "font_class": "icon_add_text", + "unicode": "e7c8", + "unicode_decimal": 59336 + }, + { + "icon_id": "43088700", + "name": "icon_align_text_right", + "font_class": "icon_align_text_right", + "unicode": "e7c9", + "unicode_decimal": 59337 + }, + { + "icon_id": "43088701", + "name": "icon_align_text_left", + "font_class": "icon_align_text_left", + "unicode": "e7ca", + "unicode_decimal": 59338 + }, + { + "icon_id": "43088699", + "name": "icon_text_bold", + "font_class": "icon_text_bold", + "unicode": "e7cc", + "unicode_decimal": 59340 + }, + { + "icon_id": "43088697", + "name": "icon_align_text_center", + "font_class": "icon_align_text_center", + "unicode": "e7cd", + "unicode_decimal": 59341 + }, + { + "icon_id": "43075208", + "name": "icon_map", + "font_class": "icon_map", + "unicode": "e7bb", + "unicode_decimal": 59323 + }, + { + "icon_id": "43075205", + "name": "icon_multiple_selection _of_departments", + "font_class": "a-icon_multiple_selection_of_departments", + "unicode": "e7bc", + "unicode_decimal": 59324 + }, + { + "icon_id": "43075206", + "name": "icon_calendar", + "font_class": "icon_calendar1", + "unicode": "e7bd", + "unicode_decimal": 59325 + }, + { + "icon_id": "43075207", + "name": "icon_link", + "font_class": "icon_link1", + "unicode": "e7be", + "unicode_decimal": 59326 + }, + { + "icon_id": "43075202", + "name": "icon_check", + "font_class": "a-icon_collectbeifen12", + "unicode": "e7bf", + "unicode_decimal": 59327 + }, + { + "icon_id": "43075204", + "name": "icon_department_single_choice", + "font_class": "icon_department_single_choice", + "unicode": "e7c0", + "unicode_decimal": 59328 + }, + { + "icon_id": "43075203", + "name": "icon_find", + "font_class": "icon_find", + "unicode": "e7c1", + "unicode_decimal": 59329 + }, + { + "icon_id": "43075201", + "name": "icon_handwritten_signature", + "font_class": "icon_handwritten_signature", + "unicode": "e7c2", + "unicode_decimal": 59330 + }, + { + "icon_id": "43075200", + "name": "icon_button", + "font_class": "icon_button", + "unicode": "e7c3", + "unicode_decimal": 59331 + }, + { + "icon_id": "43075224", + "name": "icon_select_data", + "font_class": "icon_select_data", + "unicode": "e7ab", + "unicode_decimal": 59307 + }, + { + "icon_id": "43075223", + "name": "icon_single_line_text", + "font_class": "icon_single_line_text", + "unicode": "e7ac", + "unicode_decimal": 59308 + }, + { + "icon_id": "43075222", + "name": "icon_multiple_choice_of_members", + "font_class": "icon_multiple_choice_of_members", + "unicode": "e7ad", + "unicode_decimal": 59309 + }, + { + "icon_id": "43075221", + "name": "icon_subform", + "font_class": "icon_subform", + "unicode": "e7ae", + "unicode_decimal": 59310 + }, + { + "icon_id": "43075220", + "name": "icon_picture", + "font_class": "icon_picture", + "unicode": "e7af", + "unicode_decimal": 59311 + }, + { + "icon_id": "43075212", + "name": "icon_phone", + "font_class": "icon_phone", + "unicode": "e7b0", + "unicode_decimal": 59312 + }, + { + "icon_id": "43075219", + "name": "icon_local", + "font_class": "icon_local", + "unicode": "e7b1", + "unicode_decimal": 59313 + }, + { + "icon_id": "43075218", + "name": "icon_dividing_line", + "font_class": "icon_dividing_line", + "unicode": "e7b2", + "unicode_decimal": 59314 + }, + { + "icon_id": "43075217", + "name": "icon_multiline", + "font_class": "icon_multiline", + "unicode": "e7b3", + "unicode_decimal": 59315 + }, + { + "icon_id": "43075216", + "name": "icon_member_single_choice", + "font_class": "icon_member_single_choice", + "unicode": "e7b4", + "unicode_decimal": 59316 + }, + { + "icon_id": "43075214", + "name": "icon_radio", + "font_class": "icon_radio", + "unicode": "e7b5", + "unicode_decimal": 59317 + }, + { + "icon_id": "43075215", + "name": "icon_serial_number", + "font_class": "icon_serial_number", + "unicode": "e7b6", + "unicode_decimal": 59318 + }, + { + "icon_id": "43075213", + "name": "icon_text_recognition", + "font_class": "icon_text_recognition", + "unicode": "e7b7", + "unicode_decimal": 59319 + }, + { + "icon_id": "43075211", + "name": "icon_pull_down_single_choice", + "font_class": "icon_pull_down_single_choice", + "unicode": "e7b8", + "unicode_decimal": 59320 + }, + { + "icon_id": "43075210", + "name": "icon_hashtag_key", + "font_class": "icon_hashtag_key", + "unicode": "e7b9", + "unicode_decimal": 59321 + }, + { + "icon_id": "43075209", + "name": "icon_pull_down_multiple_selection", + "font_class": "icon_pull_down_multiple_selection", + "unicode": "e7ba", + "unicode_decimal": 59322 + }, + { + "icon_id": "43002118", + "name": "order-descending", + "font_class": "order-descending", + "unicode": "e7a0", + "unicode_decimal": 59296 + }, + { + "icon_id": "43002117", + "name": "icon_video", + "font_class": "icon_video", + "unicode": "e7a1", + "unicode_decimal": 59297 + }, + { + "icon_id": "43002115", + "name": "icon_zoom_in", + "font_class": "icon_zoom_in", + "unicode": "e7a2", + "unicode_decimal": 59298 + }, + { + "icon_id": "43002116", + "name": "order-ascending", + "font_class": "order-ascending", + "unicode": "e7a3", + "unicode_decimal": 59299 + }, + { + "icon_id": "43002114", + "name": "icon_zoom_out", + "font_class": "icon_zoom_out", + "unicode": "e7a4", + "unicode_decimal": 59300 + }, + { + "icon_id": "43002113", + "name": "icon_usergroup", + "font_class": "icon_usergroup", + "unicode": "e7a5", + "unicode_decimal": 59301 + }, + { + "icon_id": "43002112", + "name": "icon_wifi", + "font_class": "icon_wifi", + "unicode": "e7a6", + "unicode_decimal": 59302 + }, + { + "icon_id": "43002110", + "name": "icon_view_module", + "font_class": "icon_view_module", + "unicode": "e7a7", + "unicode_decimal": 59303 + }, + { + "icon_id": "43002111", + "name": "view-column", + "font_class": "view-column", + "unicode": "e7a8", + "unicode_decimal": 59304 + }, + { + "icon_id": "43002109", + "name": "view-list", + "font_class": "view-list", + "unicode": "e7a9", + "unicode_decimal": 59305 + }, + { + "icon_id": "43002108", + "name": "icon_wallet", + "font_class": "icon_wallet", + "unicode": "e7aa", + "unicode_decimal": 59306 + }, + { + "icon_id": "43001922", + "name": "icon_usb", + "font_class": "icon_usb", + "unicode": "e78c", + "unicode_decimal": 59276 + }, + { + "icon_id": "43001923", + "name": "icon_user_add", + "font_class": "icon_user_add", + "unicode": "e78d", + "unicode_decimal": 59277 + }, + { + "icon_id": "43001924", + "name": "icon_swap_right", + "font_class": "icon_swap_right", + "unicode": "e78e", + "unicode_decimal": 59278 + }, + { + "icon_id": "43001925", + "name": "icon_user_avatar", + "font_class": "icon_user_avatar", + "unicode": "e78f", + "unicode_decimal": 59279 + }, + { + "icon_id": "43001926", + "name": "icon_thumb_up", + "font_class": "icon_thumb_up", + "unicode": "e790", + "unicode_decimal": 59280 + }, + { + "icon_id": "43001927", + "name": "icon_time_filled", + "font_class": "icon_time_filled", + "unicode": "e791", + "unicode_decimal": 59281 + }, + { + "icon_id": "43001928", + "name": "icon_tools", + "font_class": "icon_tools", + "unicode": "e792", + "unicode_decimal": 59282 + }, + { + "icon_id": "43001929", + "name": "icon_user_clear", + "font_class": "icon_user_clear", + "unicode": "e793", + "unicode_decimal": 59283 + }, + { + "icon_id": "43001930", + "name": "icon_time", + "font_class": "icon_time", + "unicode": "e794", + "unicode_decimal": 59284 + }, + { + "icon_id": "43001931", + "name": "icon_unfold_less", + "font_class": "icon_unfold_less", + "unicode": "e795", + "unicode_decimal": 59285 + }, + { + "icon_id": "43001932", + "name": "icon_thumb_down", + "font_class": "icon_thumb_down", + "unicode": "e796", + "unicode_decimal": 59286 + }, + { + "icon_id": "43001933", + "name": "icon_user_talk", + "font_class": "icon_user_talk", + "unicode": "e797", + "unicode_decimal": 59287 + }, + { + "icon_id": "43001934", + "name": "icon_tips", + "font_class": "icon_tips", + "unicode": "e798", + "unicode_decimal": 59288 + }, + { + "icon_id": "43001935", + "name": "icon_unfold_more", + "font_class": "icon_unfold_more", + "unicode": "e799", + "unicode_decimal": 59289 + }, + { + "icon_id": "43001936", + "name": "icon_upload", + "font_class": "icon_upload", + "unicode": "e79a", + "unicode_decimal": 59290 + }, + { + "icon_id": "43001937", + "name": "icon_user", + "font_class": "icon_user", + "unicode": "e79b", + "unicode_decimal": 59291 + }, + { + "icon_id": "43001938", + "name": "icon_usergroup_add", + "font_class": "icon_usergroup_add", + "unicode": "e79c", + "unicode_decimal": 59292 + }, + { + "icon_id": "43001939", + "name": "icon_swap", + "font_class": "icon_swap", + "unicode": "e79d", + "unicode_decimal": 59293 + }, + { + "icon_id": "43001940", + "name": "icon_usergroup_clear", + "font_class": "icon_usergroup_clear", + "unicode": "e79e", + "unicode_decimal": 59294 + }, + { + "icon_id": "43001941", + "name": "icon_user_circle", + "font_class": "icon_user_circle", + "unicode": "e79f", + "unicode_decimal": 59295 + }, + { + "icon_id": "43001843", + "name": "icon_queue", + "font_class": "icon_queue", + "unicode": "e782", + "unicode_decimal": 59266 + }, + { + "icon_id": "43001831", + "name": "icon_refresh", + "font_class": "icon_refresh", + "unicode": "e783", + "unicode_decimal": 59267 + }, + { + "icon_id": "43001841", + "name": "icon_pin", + "font_class": "icon_pin", + "unicode": "e784", + "unicode_decimal": 59268 + }, + { + "icon_id": "43001840", + "name": "icon_poweroff", + "font_class": "icon_poweroff", + "unicode": "e785", + "unicode_decimal": 59269 + }, + { + "icon_id": "43001838", + "name": "icon_photo", + "font_class": "icon_photo", + "unicode": "e786", + "unicode_decimal": 59270 + }, + { + "icon_id": "43001834", + "name": "icon_precise_monitor", + "font_class": "icon_precise_monitor", + "unicode": "e787", + "unicode_decimal": 59271 + }, + { + "icon_id": "43001837", + "name": "icon_print", + "font_class": "icon_print", + "unicode": "e788", + "unicode_decimal": 59272 + }, + { + "icon_id": "43001835", + "name": "icon_play_circle", + "font_class": "icon_play_circle", + "unicode": "e789", + "unicode_decimal": 59273 + }, + { + "icon_id": "43001833", + "name": "icon_play", + "font_class": "icon_play", + "unicode": "e78a", + "unicode_decimal": 59274 + }, + { + "icon_id": "43001832", + "name": "icon_remove", + "font_class": "icon_remove", + "unicode": "e78b", + "unicode_decimal": 59275 + }, + { + "icon_id": "43001880", + "name": "icon_server", + "font_class": "icon_server", + "unicode": "e776", + "unicode_decimal": 59254 + }, + { + "icon_id": "43001878", + "name": "icon_rollfront", + "font_class": "icon_rollfront", + "unicode": "e777", + "unicode_decimal": 59255 + }, + { + "icon_id": "43001877", + "name": "icon_scan", + "font_class": "icon_scan", + "unicode": "e778", + "unicode_decimal": 59256 + }, + { + "icon_id": "43001876", + "name": "icon_rotation", + "font_class": "icon_rotation", + "unicode": "e779", + "unicode_decimal": 59257 + }, + { + "icon_id": "43001847", + "name": "icon_pause_circle", + "font_class": "icon_pause_circle", + "unicode": "e77a", + "unicode_decimal": 59258 + }, + { + "icon_id": "43001850", + "name": "icon_pause", + "font_class": "icon_pause", + "unicode": "e77b", + "unicode_decimal": 59259 + }, + { + "icon_id": "43001846", + "name": "icon_pin_filled", + "font_class": "icon_pin_filled", + "unicode": "e77c", + "unicode_decimal": 59260 + }, + { + "icon_id": "43001849", + "name": "icon_play_circle_filled", + "font_class": "icon_play_circle_filled", + "unicode": "e77d", + "unicode_decimal": 59261 + }, + { + "icon_id": "43001848", + "name": "icon_qrcode", + "font_class": "icon_qrcode", + "unicode": "e77e", + "unicode_decimal": 59262 + }, + { + "icon_id": "43001844", + "name": "icon_previous", + "font_class": "icon_previous", + "unicode": "e77f", + "unicode_decimal": 59263 + }, + { + "icon_id": "43001845", + "name": "icon_rollback", + "font_class": "icon_rollback", + "unicode": "e780", + "unicode_decimal": 59264 + }, + { + "icon_id": "43001842", + "name": "icon_play_circle_stroke", + "font_class": "icon_play_circle_stroke", + "unicode": "e781", + "unicode_decimal": 59265 + }, + { + "icon_id": "43001894", + "name": "icon_swap_left", + "font_class": "icon_swap_left", + "unicode": "e766", + "unicode_decimal": 59238 + }, + { + "icon_id": "43001895", + "name": "icon_stop_circle", + "font_class": "icon_stop_circle", + "unicode": "e767", + "unicode_decimal": 59239 + }, + { + "icon_id": "43001893", + "name": "icon_sound", + "font_class": "icon_sound", + "unicode": "e768", + "unicode_decimal": 59240 + }, + { + "icon_id": "43001890", + "name": "icon_setting", + "font_class": "icon_setting", + "unicode": "e769", + "unicode_decimal": 59241 + }, + { + "icon_id": "43001892", + "name": "icon_share", + "font_class": "icon_share", + "unicode": "e76a", + "unicode_decimal": 59242 + }, + { + "icon_id": "43001884", + "name": "icon_search", + "font_class": "icon_search", + "unicode": "e76b", + "unicode_decimal": 59243 + }, + { + "icon_id": "43001889", + "name": "icon_service", + "font_class": "icon_service", + "unicode": "e76c", + "unicode_decimal": 59244 + }, + { + "icon_id": "43001891", + "name": "icon_star", + "font_class": "icon_star", + "unicode": "e76d", + "unicode_decimal": 59245 + }, + { + "icon_id": "43001887", + "name": "icon_round", + "font_class": "icon_round", + "unicode": "e76e", + "unicode_decimal": 59246 + }, + { + "icon_id": "43001888", + "name": "icon_star_filled", + "font_class": "icon_star_filled", + "unicode": "e76f", + "unicode_decimal": 59247 + }, + { + "icon_id": "43001886", + "name": "icon_shop", + "font_class": "icon_shop", + "unicode": "e770", + "unicode_decimal": 59248 + }, + { + "icon_id": "43001885", + "name": "icon_stop_circle_filled", + "font_class": "icon_stop_circle_filled", + "unicode": "e771", + "unicode_decimal": 59249 + }, + { + "icon_id": "43001883", + "name": "icon_slash", + "font_class": "icon_slash", + "unicode": "e772", + "unicode_decimal": 59250 + }, + { + "icon_id": "43001882", + "name": "icon_save", + "font_class": "icon_save", + "unicode": "e773", + "unicode_decimal": 59251 + }, + { + "icon_id": "43001881", + "name": "icon_secured", + "font_class": "icon_secured", + "unicode": "e774", + "unicode_decimal": 59252 + }, + { + "icon_id": "43001879", + "name": "icon_root_list", + "font_class": "icon_root_list", + "unicode": "e775", + "unicode_decimal": 59253 + }, + { + "icon_id": "43001795", + "name": "icon_menu-application", + "font_class": "icon_menu-application1", + "unicode": "e75f", + "unicode_decimal": 59231 + }, + { + "icon_id": "43001794", + "name": "icon_mobile_vibrate", + "font_class": "icon_mobile_vibrate", + "unicode": "e760", + "unicode_decimal": 59232 + }, + { + "icon_id": "43001792", + "name": "icon_mirror", + "font_class": "icon_mirror", + "unicode": "e761", + "unicode_decimal": 59233 + }, + { + "icon_id": "43001793", + "name": "icon_move", + "font_class": "icon_move", + "unicode": "e762", + "unicode_decimal": 59234 + }, + { + "icon_id": "43001790", + "name": "icon_page_first", + "font_class": "icon_page_first", + "unicode": "e763", + "unicode_decimal": 59235 + }, + { + "icon_id": "43001787", + "name": "icon_notification_filled", + "font_class": "icon_notification_filled", + "unicode": "e764", + "unicode_decimal": 59236 + }, + { + "icon_id": "43001788", + "name": "icon_minus_rectangle", + "font_class": "icon_minus_rectangle1", + "unicode": "e765", + "unicode_decimal": 59237 + }, + { + "icon_id": "43001806", + "name": "icon_menu_unfold", + "font_class": "icon_menu_unfold1", + "unicode": "e752", + "unicode_decimal": 59218 + }, + { + "icon_id": "43001799", + "name": "icon_page_last", + "font_class": "icon_page_last", + "unicode": "e753", + "unicode_decimal": 59219 + }, + { + "icon_id": "43001804", + "name": "icon_pause_circle_filled", + "font_class": "icon_pause_circle_filled", + "unicode": "e754", + "unicode_decimal": 59220 + }, + { + "icon_id": "43001805", + "name": "icon_money_circle", + "font_class": "icon_money_circle", + "unicode": "e755", + "unicode_decimal": 59221 + }, + { + "icon_id": "43001803", + "name": "icon_mail", + "font_class": "icon_mail1", + "unicode": "e756", + "unicode_decimal": 59222 + }, + { + "icon_id": "43001800", + "name": "icon_minus_circle_filled", + "font_class": "icon_minus_circle_filled1", + "unicode": "e757", + "unicode_decimal": 59223 + }, + { + "icon_id": "43001801", + "name": "icon_more", + "font_class": "icon_more", + "unicode": "e758", + "unicode_decimal": 59224 + }, + { + "icon_id": "43001796", + "name": "icon_menu_fold", + "font_class": "icon_menu_fold1", + "unicode": "e759", + "unicode_decimal": 59225 + }, + { + "icon_id": "43001789", + "name": "icon_next", + "font_class": "icon_next", + "unicode": "e75a", + "unicode_decimal": 59226 + }, + { + "icon_id": "43001797", + "name": "icon_notification", + "font_class": "icon_notification", + "unicode": "e75b", + "unicode_decimal": 59227 + }, + { + "icon_id": "43001791", + "name": "icon_minus_circle", + "font_class": "icon_minus_circle1", + "unicode": "e75c", + "unicode_decimal": 59228 + }, + { + "icon_id": "43001802", + "name": "icon_mobile", + "font_class": "icon_mobile", + "unicode": "e75d", + "unicode_decimal": 59229 + }, + { + "icon_id": "43001798", + "name": "icon_order_adjustment_column", + "font_class": "icon_order_adjustment_column", + "unicode": "e75e", + "unicode_decimal": 59230 + }, + { + "icon_id": "42996149", + "name": "icon_jump", + "font_class": "icon_jump", + "unicode": "e740", + "unicode_decimal": 59200 + }, + { + "icon_id": "42996150", + "name": "icon_lock_off", + "font_class": "icon_lock_off", + "unicode": "e741", + "unicode_decimal": 59201 + }, + { + "icon_id": "42996153", + "name": "icon_loading", + "font_class": "icon_loading", + "unicode": "e744", + "unicode_decimal": 59204 + }, + { + "icon_id": "42996154", + "name": "icon_laptop", + "font_class": "icon_laptop", + "unicode": "e745", + "unicode_decimal": 59205 + }, + { + "icon_id": "42996156", + "name": "icon_mail", + "font_class": "icon_mail", + "unicode": "e747", + "unicode_decimal": 59207 + }, + { + "icon_id": "42996157", + "name": "icon_minus_rectangle", + "font_class": "icon_minus_rectangle", + "unicode": "e748", + "unicode_decimal": 59208 + }, + { + "icon_id": "42996158", + "name": "icon_location", + "font_class": "icon_location", + "unicode": "e749", + "unicode_decimal": 59209 + }, + { + "icon_id": "42996159", + "name": "icon_lock_on", + "font_class": "icon_lock_on", + "unicode": "e74a", + "unicode_decimal": 59210 + }, + { + "icon_id": "42996160", + "name": "icon_link_unlink", + "font_class": "icon_link_unlink", + "unicode": "e74b", + "unicode_decimal": 59211 + }, + { + "icon_id": "42996161", + "name": "icon_menu-application", + "font_class": "icon_menu-application", + "unicode": "e74c", + "unicode_decimal": 59212 + }, + { + "icon_id": "42996162", + "name": "icon_minus_circle", + "font_class": "icon_minus_circle", + "unicode": "e74d", + "unicode_decimal": 59213 + }, + { + "icon_id": "42996163", + "name": "icon_logout", + "font_class": "icon_logout", + "unicode": "e74e", + "unicode_decimal": 59214 + }, + { + "icon_id": "42996164", + "name": "icon_link", + "font_class": "icon_link", + "unicode": "e74f", + "unicode_decimal": 59215 + }, + { + "icon_id": "42996165", + "name": "icon_layers", + "font_class": "icon_layers", + "unicode": "e750", + "unicode_decimal": 59216 + }, + { + "icon_id": "42996166", + "name": "icon_login", + "font_class": "icon_login", + "unicode": "e751", + "unicode_decimal": 59217 + }, + { + "icon_id": "42996016", + "name": "icon_file_paste", + "font_class": "icon_file_paste", + "unicode": "e718", + "unicode_decimal": 59160 + }, + { + "icon_id": "42996017", + "name": "icon_error_circle", + "font_class": "icon_error_circle", + "unicode": "e719", + "unicode_decimal": 59161 + }, + { + "icon_id": "42996018", + "name": "icon_file_word", + "font_class": "icon_file_word", + "unicode": "e71a", + "unicode_decimal": 59162 + }, + { + "icon_id": "42996019", + "name": "icon_enter", + "font_class": "icon_enter", + "unicode": "e71b", + "unicode_decimal": 59163 + }, + { + "icon_id": "42996020", + "name": "icon_file", + "font_class": "icon_file", + "unicode": "e71c", + "unicode_decimal": 59164 + }, + { + "icon_id": "42996022", + "name": "icon_fork", + "font_class": "icon_fork", + "unicode": "e71e", + "unicode_decimal": 59166 + }, + { + "icon_id": "42996023", + "name": "icon_file_copy", + "font_class": "icon_file_copy", + "unicode": "e71f", + "unicode_decimal": 59167 + }, + { + "icon_id": "42996024", + "name": "icon_flag", + "font_class": "icon_flag", + "unicode": "e720", + "unicode_decimal": 59168 + }, + { + "icon_id": "42996025", + "name": "icon_folder", + "font_class": "icon_folder", + "unicode": "e721", + "unicode_decimal": 59169 + }, + { + "icon_id": "42996026", + "name": "icon_file_image", + "font_class": "icon_file_image", + "unicode": "e722", + "unicode_decimal": 59170 + }, + { + "icon_id": "42996027", + "name": "icon_file_powerpoint", + "font_class": "icon_file_powerpoint", + "unicode": "e723", + "unicode_decimal": 59171 + }, + { + "icon_id": "42996028", + "name": "icon_file_add", + "font_class": "icon_file_add", + "unicode": "e724", + "unicode_decimal": 59172 + }, + { + "icon_id": "42996029", + "name": "icon_file_unknown", + "font_class": "icon_file_unknown", + "unicode": "e725", + "unicode_decimal": 59173 + }, + { + "icon_id": "42996030", + "name": "icon_error_circle_filled", + "font_class": "icon_error_circle_filled", + "unicode": "e726", + "unicode_decimal": 59174 + }, + { + "icon_id": "42996031", + "name": "icon_help", + "font_class": "icon_help", + "unicode": "e727", + "unicode_decimal": 59175 + }, + { + "icon_id": "42996032", + "name": "icon_fullscreen_exit", + "font_class": "icon_fullscreen_exit", + "unicode": "e728", + "unicode_decimal": 59176 + }, + { + "icon_id": "42996033", + "name": "icon_file_pdf", + "font_class": "icon_file_pdf", + "unicode": "e729", + "unicode_decimal": 59177 + }, + { + "icon_id": "42996034", + "name": "icon_gift", + "font_class": "icon_gift", + "unicode": "e72a", + "unicode_decimal": 59178 + }, + { + "icon_id": "42996035", + "name": "icon_gender_female", + "font_class": "icon_gender_female", + "unicode": "e72b", + "unicode_decimal": 59179 + }, + { + "icon_id": "42996036", + "name": "icon_home", + "font_class": "icon_home1", + "unicode": "e72c", + "unicode_decimal": 59180 + }, + { + "icon_id": "42996037", + "name": "icon_heart_filled", + "font_class": "icon_heart_filled", + "unicode": "e72d", + "unicode_decimal": 59181 + }, + { + "icon_id": "42996038", + "name": "icon_info_circle", + "font_class": "icon_info_circle", + "unicode": "e72e", + "unicode_decimal": 59182 + }, + { + "icon_id": "42996039", + "name": "icon_file_icon", + "font_class": "icon_file_icon", + "unicode": "e72f", + "unicode_decimal": 59183 + }, + { + "icon_id": "42996040", + "name": "icon_heart", + "font_class": "icon_heart", + "unicode": "e730", + "unicode_decimal": 59184 + }, + { + "icon_id": "42996041", + "name": "icon_history", + "font_class": "icon_history", + "unicode": "e731", + "unicode_decimal": 59185 + }, + { + "icon_id": "42996043", + "name": "icon_hourglass", + "font_class": "icon_hourglass", + "unicode": "e733", + "unicode_decimal": 59187 + }, + { + "icon_id": "42996044", + "name": "icon_info_circle_filled", + "font_class": "icon_info_circle_filled", + "unicode": "e734", + "unicode_decimal": 59188 + }, + { + "icon_id": "42996045", + "name": "icon_folder_open", + "font_class": "icon_folder_open", + "unicode": "e735", + "unicode_decimal": 59189 + }, + { + "icon_id": "42996046", + "name": "icon_help_circle_filled", + "font_class": "icon_help_circle_filled", + "unicode": "e736", + "unicode_decimal": 59190 + }, + { + "icon_id": "42996047", + "name": "icon_help_circle", + "font_class": "icon_help_circle", + "unicode": "e737", + "unicode_decimal": 59191 + }, + { + "icon_id": "42996048", + "name": "icon_image", + "font_class": "icon_image", + "unicode": "e738", + "unicode_decimal": 59192 + }, + { + "icon_id": "42996049", + "name": "icon_gender_male", + "font_class": "icon_gender_male", + "unicode": "e739", + "unicode_decimal": 59193 + }, + { + "icon_id": "42996050", + "name": "icon_fullsreen", + "font_class": "icon_fullsreen", + "unicode": "e73a", + "unicode_decimal": 59194 + }, + { + "icon_id": "42996051", + "name": "icon_image_error", + "font_class": "icon_image_error", + "unicode": "e73b", + "unicode_decimal": 59195 + }, + { + "icon_id": "42996052", + "name": "icon_folder_add", + "font_class": "icon_folder_add", + "unicode": "e73c", + "unicode_decimal": 59196 + }, + { + "icon_id": "42996053", + "name": "icon_file_excel", + "font_class": "icon_file_excel", + "unicode": "e73d", + "unicode_decimal": 59197 + }, + { + "icon_id": "42996054", + "name": "icon_forward", + "font_class": "icon_forward", + "unicode": "e73e", + "unicode_decimal": 59198 + }, + { + "icon_id": "42996055", + "name": "icon_internet", + "font_class": "icon_internet", + "unicode": "e73f", + "unicode_decimal": 59199 + }, + { + "icon_id": "42995831", + "name": "icon_check_circle_filled", + "font_class": "icon_check_circle_filled", + "unicode": "e6f1", + "unicode_decimal": 59121 + }, + { + "icon_id": "42995832", + "name": "icon_chevron_right_circle", + "font_class": "icon_chevron_right_circle", + "unicode": "e6f2", + "unicode_decimal": 59122 + }, + { + "icon_id": "42995833", + "name": "icon_creditcard", + "font_class": "icon_creditcard", + "unicode": "e6f3", + "unicode_decimal": 59123 + }, + { + "icon_id": "42995834", + "name": "icon_close_circle_filled", + "font_class": "icon_close_circle_filled", + "unicode": "e6f4", + "unicode_decimal": 59124 + }, + { + "icon_id": "42995835", + "name": "icon_circle", + "font_class": "icon_circle", + "unicode": "e6f5", + "unicode_decimal": 59125 + }, + { + "icon_id": "42995836", + "name": "icon_chevron_left_circle", + "font_class": "icon_chevron_left_circle", + "unicode": "e6f6", + "unicode_decimal": 59126 + }, + { + "icon_id": "42995837", + "name": "icon_chevron_left_double", + "font_class": "icon_chevron_left_double", + "unicode": "e6f7", + "unicode_decimal": 59127 + }, + { + "icon_id": "42995838", + "name": "icon_chevron_right", + "font_class": "icon_chevron_right", + "unicode": "e6f8", + "unicode_decimal": 59128 + }, + { + "icon_id": "42995839", + "name": "icon_control_platform", + "font_class": "icon_control_platform", + "unicode": "e6f9", + "unicode_decimal": 59129 + }, + { + "icon_id": "42995840", + "name": "icon_close", + "font_class": "icon_close", + "unicode": "e6fa", + "unicode_decimal": 59130 + }, + { + "icon_id": "42995841", + "name": "icon_check", + "font_class": "icon_check", + "unicode": "e6fb", + "unicode_decimal": 59131 + }, + { + "icon_id": "42995842", + "name": "icon_chevron_right_double", + "font_class": "icon_chevron_right_double", + "unicode": "e6fc", + "unicode_decimal": 59132 + }, + { + "icon_id": "42995843", + "name": "icon_check_rectangle_filled", + "font_class": "icon_check_rectangle_filled", + "unicode": "e6fd", + "unicode_decimal": 59133 + }, + { + "icon_id": "42995844", + "name": "icon_cloud_upload", + "font_class": "icon_cloud_upload", + "unicode": "e6fe", + "unicode_decimal": 59134 + }, + { + "icon_id": "42995845", + "name": "icon_chevron_up_circle", + "font_class": "icon_chevron_up_circle", + "unicode": "e6ff", + "unicode_decimal": 59135 + }, + { + "icon_id": "42995846", + "name": "icon_chevron_down_circle", + "font_class": "icon_chevron_down_circle", + "unicode": "e700", + "unicode_decimal": 59136 + }, + { + "icon_id": "42995847", + "name": "icon_chevron_up_rectangle", + "font_class": "icon_chevron_up_rectangle", + "unicode": "e701", + "unicode_decimal": 59137 + }, + { + "icon_id": "42995848", + "name": "icon_close_rectangle", + "font_class": "icon_close_rectangle", + "unicode": "e702", + "unicode_decimal": 59138 + }, + { + "icon_id": "42995849", + "name": "icon_chevron_down_rectangle", + "font_class": "icon_chevron_down_rectangle", + "unicode": "e703", + "unicode_decimal": 59139 + }, + { + "icon_id": "42995850", + "name": "icon_chevron_left", + "font_class": "icon_chevron_left", + "unicode": "e704", + "unicode_decimal": 59140 + }, + { + "icon_id": "42995851", + "name": "icon_check_circle", + "font_class": "icon_check_circle", + "unicode": "e705", + "unicode_decimal": 59141 + }, + { + "icon_id": "42995852", + "name": "icon_chevron_right_rectangle", + "font_class": "icon_chevron_right_rectangle", + "unicode": "e706", + "unicode_decimal": 59142 + }, + { + "icon_id": "42995853", + "name": "icon_check_rectangle", + "font_class": "icon_check_rectangle", + "unicode": "e707", + "unicode_decimal": 59143 + }, + { + "icon_id": "42995854", + "name": "icon_close_circle", + "font_class": "icon_close_circle", + "unicode": "e708", + "unicode_decimal": 59144 + }, + { + "icon_id": "42995855", + "name": "icon_cloud", + "font_class": "icon_cloud", + "unicode": "e709", + "unicode_decimal": 59145 + }, + { + "icon_id": "42995856", + "name": "icon_chevron_down", + "font_class": "icon_chevron_down", + "unicode": "e70a", + "unicode_decimal": 59146 + }, + { + "icon_id": "42995857", + "name": "icon_delete", + "font_class": "icon_delete", + "unicode": "e70b", + "unicode_decimal": 59147 + }, + { + "icon_id": "42995858", + "name": "icon_chevron_up", + "font_class": "icon_chevron_up", + "unicode": "e70c", + "unicode_decimal": 59148 + }, + { + "icon_id": "42995859", + "name": "icon_code", + "font_class": "icon_code", + "unicode": "e70d", + "unicode_decimal": 59149 + }, + { + "icon_id": "42995860", + "name": "icon_dashboard", + "font_class": "icon_dashboard", + "unicode": "e70e", + "unicode_decimal": 59150 + }, + { + "icon_id": "42995861", + "name": "icon_clear", + "font_class": "icon_clear", + "unicode": "e70f", + "unicode_decimal": 59151 + }, + { + "icon_id": "42995862", + "name": "icon_chevron_left_rectangle", + "font_class": "icon_chevron_left_rectangle", + "unicode": "e710", + "unicode_decimal": 59152 + }, + { + "icon_id": "42995863", + "name": "icon_cloud_download", + "font_class": "icon_cloud_download", + "unicode": "e711", + "unicode_decimal": 59153 + }, + { + "icon_id": "42995864", + "name": "icon_desktop", + "font_class": "icon_desktop", + "unicode": "e712", + "unicode_decimal": 59154 + }, + { + "icon_id": "42995866", + "name": "icon_ellipsis", + "font_class": "icon_ellipsis", + "unicode": "e713", + "unicode_decimal": 59155 + }, + { + "icon_id": "42995868", + "name": "icon_edit", + "font_class": "icon_edit", + "unicode": "e715", + "unicode_decimal": 59157 + }, + { + "icon_id": "42995869", + "name": "icon_download", + "font_class": "icon_download", + "unicode": "e716", + "unicode_decimal": 59158 + }, + { + "icon_id": "42995870", + "name": "icon_discount_filled", + "font_class": "icon_discount_filled", + "unicode": "e717", + "unicode_decimal": 59159 + }, + { + "icon_id": "42995750", + "name": "icon_caret_down", + "font_class": "icon_caret_down", + "unicode": "e6c9", + "unicode_decimal": 59081 + }, + { + "icon_id": "42995751", + "name": "icon_backtop", + "font_class": "icon_backtop", + "unicode": "e6ca", + "unicode_decimal": 59082 + }, + { + "icon_id": "42995752", + "name": "icon_caret_right", + "font_class": "icon_caret_right", + "unicode": "e6cb", + "unicode_decimal": 59083 + }, + { + "icon_id": "42995753", + "name": "icon_bulletpoint", + "font_class": "icon_bulletpoint", + "unicode": "e6cc", + "unicode_decimal": 59084 + }, + { + "icon_id": "42995754", + "name": "icon_caret_right_small", + "font_class": "icon_caret_right_small", + "unicode": "e6cd", + "unicode_decimal": 59085 + }, + { + "icon_id": "42995755", + "name": "icon_add", + "font_class": "icon_add", + "unicode": "e6ce", + "unicode_decimal": 59086 + }, + { + "icon_id": "42995756", + "name": "icon_caret_left", + "font_class": "icon_caret_left", + "unicode": "e6cf", + "unicode_decimal": 59087 + }, + { + "icon_id": "42995757", + "name": "icon_caret_down_small", + "font_class": "icon_caret_down_small", + "unicode": "e6d0", + "unicode_decimal": 59088 + }, + { + "icon_id": "42995758", + "name": "icon_caret_left_small", + "font_class": "icon_caret_left_small", + "unicode": "e6d1", + "unicode_decimal": 59089 + }, + { + "icon_id": "42995759", + "name": "chart-bar", + "font_class": "chart-bar", + "unicode": "e6d2", + "unicode_decimal": 59090 + }, + { + "icon_id": "42995760", + "name": "icon_arrow_left", + "font_class": "icon_arrow_left", + "unicode": "e6d3", + "unicode_decimal": 59091 + }, + { + "icon_id": "42995761", + "name": "icon_calendar", + "font_class": "icon_calendar", + "unicode": "e6d4", + "unicode_decimal": 59092 + }, + { + "icon_id": "42995762", + "name": "icon_chart_pie", + "font_class": "icon_chart_pie", + "unicode": "e6d5", + "unicode_decimal": 59093 + }, + { + "icon_id": "42995763", + "name": "icon_arrow_right", + "font_class": "icon_arrow_right", + "unicode": "e6d6", + "unicode_decimal": 59094 + }, + { + "icon_id": "42995764", + "name": "icon_caret_up_small", + "font_class": "icon_caret_up_small", + "unicode": "e6d7", + "unicode_decimal": 59095 + }, + { + "icon_id": "42995766", + "name": "icon_browse", + "font_class": "icon_browse", + "unicode": "e6d9", + "unicode_decimal": 59097 + }, + { + "icon_id": "42995768", + "name": "icon_books", + "font_class": "icon_books", + "unicode": "e6db", + "unicode_decimal": 59099 + }, + { + "icon_id": "42995769", + "name": "icon_app", + "font_class": "icon_app", + "unicode": "e6dc", + "unicode_decimal": 59100 + }, + { + "icon_id": "42995770", + "name": "icon_arrow_up", + "font_class": "icon_arrow_up", + "unicode": "e6dd", + "unicode_decimal": 59101 + }, + { + "icon_id": "42995772", + "name": "icon_backward", + "font_class": "icon_backward", + "unicode": "e6df", + "unicode_decimal": 59103 + }, + { + "icon_id": "42995773", + "name": "icon_cart", + "font_class": "icon_cart", + "unicode": "e6e0", + "unicode_decimal": 59104 + }, + { + "icon_id": "42995774", + "name": "icon_attach", + "font_class": "icon_attach", + "unicode": "e6e1", + "unicode_decimal": 59105 + }, + { + "icon_id": "42995775", + "name": "chart", + "font_class": "chart", + "unicode": "e6e2", + "unicode_decimal": 59106 + }, + { + "icon_id": "42995776", + "name": "icon_chat", + "font_class": "icon_chat", + "unicode": "e6e3", + "unicode_decimal": 59107 + }, + { + "icon_id": "42995777", + "name": "icon_add_rectangle", + "font_class": "icon_add_rectangle", + "unicode": "e6e4", + "unicode_decimal": 59108 + }, + { + "icon_id": "42995778", + "name": "icon_add_circle", + "font_class": "icon_add_circle", + "unicode": "e6e5", + "unicode_decimal": 59109 + }, + { + "icon_id": "42995779", + "name": "icon_barcode", + "font_class": "icon_barcode", + "unicode": "e6e6", + "unicode_decimal": 59110 + }, + { + "icon_id": "42995780", + "name": "icon_arrow_down_rectangle", + "font_class": "icon_arrow_down_rectangle", + "unicode": "e6e7", + "unicode_decimal": 59111 + }, + { + "icon_id": "42995781", + "name": "icon_browse_off", + "font_class": "icon_browse_off", + "unicode": "e6e8", + "unicode_decimal": 59112 + }, + { + "icon_id": "42995782", + "name": "icon_backtop_rectangle", + "font_class": "icon_backtop_rectangle", + "unicode": "e6e9", + "unicode_decimal": 59113 + }, + { + "icon_id": "42995784", + "name": "icon_arrow_down", + "font_class": "icon_arrow_down", + "unicode": "e6eb", + "unicode_decimal": 59115 + }, + { + "icon_id": "42995786", + "name": "icon_call", + "font_class": "icon_call", + "unicode": "e6ed", + "unicode_decimal": 59117 + }, + { + "icon_id": "42995787", + "name": "icon_caret_up", + "font_class": "icon_caret_up", + "unicode": "e6ee", + "unicode_decimal": 59118 + }, + { + "icon_id": "42995789", + "name": "icon_chart_bubble", + "font_class": "icon_chart_bubble", + "unicode": "e6f0", + "unicode_decimal": 59120 + }, + { + "icon_id": "42977221", + "name": "icon_data", + "font_class": "icon_data", + "unicode": "e6ae", + "unicode_decimal": 59054 + }, + { + "icon_id": "42977220", + "name": "icon_clue", + "font_class": "icon_clue", + "unicode": "e6af", + "unicode_decimal": 59055 + }, + { + "icon_id": "42977219", + "name": "icon_business_opportunity", + "font_class": "icon_business_opportunity", + "unicode": "e6b0", + "unicode_decimal": 59056 + }, + { + "icon_id": "42977227", + "name": "icon_set_up", + "font_class": "icon_set_up", + "unicode": "e6a8", + "unicode_decimal": 59048 + }, + { + "icon_id": "42977225", + "name": "icon_not_bookmarked", + "font_class": "icon_not_bookmarked", + "unicode": "e6a9", + "unicode_decimal": 59049 + }, + { + "icon_id": "42977226", + "name": "icon_product", + "font_class": "icon_product", + "unicode": "e6aa", + "unicode_decimal": 59050 + }, + { + "icon_id": "42977224", + "name": "icon_customer", + "font_class": "icon_customer", + "unicode": "e6ab", + "unicode_decimal": 59051 + }, + { + "icon_id": "42977223", + "name": "icon_home", + "font_class": "icon_home", + "unicode": "e6ac", + "unicode_decimal": 59052 + }, + { + "icon_id": "42977222", + "name": "icon_collect", + "font_class": "icon_collect", + "unicode": "e6ad", + "unicode_decimal": 59053 + }, + { + "icon_id": "32849880", + "name": "icon-kfckfc", + "font_class": "icon_search-outline_outlined", + "unicode": "e6c2", + "unicode_decimal": 59074 + }, + { + "icon_id": "32849886", + "name": "icon-alarmclock", + "font_class": "icon-alarmclock", + "unicode": "e6c8", + "unicode_decimal": 59080 + } + ] +} diff --git a/frontend/packages/mobile/src/assets/icon-font/iconfont.svg b/frontend/packages/mobile/src/assets/icon-font/iconfont.svg new file mode 100644 index 0000000..3aa8fc2 --- /dev/null +++ b/frontend/packages/mobile/src/assets/icon-font/iconfont.svg @@ -0,0 +1,679 @@ + + + + Created by iconfont + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/mobile/src/assets/icon-font/iconfont.ttf b/frontend/packages/mobile/src/assets/icon-font/iconfont.ttf new file mode 100644 index 0000000..ac1fbbb Binary files /dev/null and b/frontend/packages/mobile/src/assets/icon-font/iconfont.ttf differ diff --git a/frontend/packages/mobile/src/assets/icon-font/iconfont.woff2 b/frontend/packages/mobile/src/assets/icon-font/iconfont.woff2 new file mode 100644 index 0000000..b261dc1 Binary files /dev/null and b/frontend/packages/mobile/src/assets/icon-font/iconfont.woff2 differ diff --git a/frontend/packages/mobile/src/assets/style/global.less b/frontend/packages/mobile/src/assets/style/global.less new file mode 100644 index 0000000..8dc71e1 --- /dev/null +++ b/frontend/packages/mobile/src/assets/style/global.less @@ -0,0 +1,68 @@ +@import url('./var.less'); +html, +body { + padding-bottom: env(safe-area-inset-bottom); + /* stylelint-disable-next-line function-no-unknown */ + padding-bottom: constant(safe-area-inset-bottom); + height: 100vh; + #app { + height: 100%; + } +} + +/* 修改进度条的背景颜色 */ +#nprogress { + .bar { + background: var(--primary-8) !important; + } +} + +/* 单行文本缩略 */ +.one-line-text { + @apply overflow-hidden overflow-ellipsis whitespace-nowrap; +} +.top-bar { + @apply flex items-center; + + gap: 12px; + padding: 8px 16px; + background-color: var(--text-n10); + .half-px-border-bottom(); +} +.crm-form { + .van-cell-group { + margin: 0; + .van-cell { + position: relative; + &:last-child::before { + position: absolute; + box-sizing: border-box; + content: ' '; + pointer-events: none; + right: 0; + bottom: 0; + left: 0; + border-bottom: 1px solid var(--van-cell-border-color); + transform: scaleY(0.5); + } + } + } +} +// 设置列表下label溢出省略 +.van-list { + .van-radio { + .van-radio__label { + .one-line-text(); + } + } + .van-checkbox { + .van-checkbox__label { + .one-line-text(); + } + } +} + +// search (在search组件上写class,会影响到van-search__field的padding-right,导致清除按钮样式异常) +.crm-search.van-search { + @apply flex-1 !p-0; +} diff --git a/frontend/packages/mobile/src/assets/style/index.less b/frontend/packages/mobile/src/assets/style/index.less new file mode 100644 index 0000000..173d7ee --- /dev/null +++ b/frontend/packages/mobile/src/assets/style/index.less @@ -0,0 +1,5 @@ +@import url('@lib/shared/assets/style/themeVariables.less'); +@import url('./global.less'); +@import url('./tailwind.less'); +@import url('./var.less'); +@import url('./theme.less'); diff --git a/frontend/packages/mobile/src/assets/style/tailwind.less b/frontend/packages/mobile/src/assets/style/tailwind.less new file mode 100644 index 0000000..20a5e14 --- /dev/null +++ b/frontend/packages/mobile/src/assets/style/tailwind.less @@ -0,0 +1,6 @@ +/* stylelint-disable at-rule-no-unknown */ + +/* tailWindCss.less */ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/frontend/packages/mobile/src/assets/style/theme.less b/frontend/packages/mobile/src/assets/style/theme.less new file mode 100644 index 0000000..2137cbd --- /dev/null +++ b/frontend/packages/mobile/src/assets/style/theme.less @@ -0,0 +1,18 @@ +:root:root { + // Component Colors + --van-primary-color: @primary-color; + --van-success-color: var(--success-green); + --van-danger-color: var(--error-red); + --van-warning-color: var(--warning-yellow); + --van-text-color: var(--text-n1); + --van-text-color-2: var(--text-n2); + --van-text-color-3: var(--text-n4); + --van-tabbar-height: 40px; + --van-radius-sm: @border-radius-mini; + --van-radius-md: @border-radius-small; + --van-radius-lg: @border-radius-medium; + --van-tag-radius: @border-radius-mini; +} +.van-dialog__message { + @apply text-left; +} diff --git a/frontend/packages/mobile/src/assets/style/var.less b/frontend/packages/mobile/src/assets/style/var.less new file mode 100644 index 0000000..c0b6354 --- /dev/null +++ b/frontend/packages/mobile/src/assets/style/var.less @@ -0,0 +1,81 @@ +/** 圆角 **/ +@border-radius-mini: 3px; +@border-radius-small: 6px; +@border-radius-medium: 9px; +@border-radius-large: 12px; + +@color-white: #fff; +@color-text-5: #aeaeb2; + +/** 投影 **/ +@box-base-shadow: 0 4px 10px -1px rgba(100 103 103 / 15%); +@box-middle-shadow: 0 4px 15px 2px rgba(100 103 103 / 10%); +@box-large-shadow: 0 6px 35px 6px rgba(100 103 103 / 10%); + +/** 滚动条 **/ +.crm-scroll-bar() { + &::-webkit-scrollbar { + @apply bg-transparent; + + width: 6px; + height: 6px; + } + &::-webkit-scrollbar-thumb { + @apply hidden; + + border-radius: var(--border-radius-medium); + background-color: var(--color-text-input-border); + } + &::-webkit-scrollbar-track { + @apply bg-transparent; + } + &:hover { + &::-webkit-scrollbar-thumb { + @apply block; + } + } +} + +/** 0.5px边框 **/ +.half-px-border-top() { + position: relative; + &::before { + position: absolute; + top: -50%; + right: -50%; + bottom: -50%; + left: -50%; + border-top: solid 1px var(--van-border-color); + content: ''; + transform: scale(0.5); + box-sizing: border-box; + pointer-events: none; + } +} +.half-px-border-bottom() { + position: relative; + &::after { + position: absolute; + top: -50%; + right: -50%; + bottom: -50%; + left: -50%; + border-bottom: solid 1px var(--van-border-color); + content: ''; + transform: scale(0.5); + box-sizing: border-box; + pointer-events: none; + } +} + +/** 按钮 **/ +.crm-button-primary--secondary { + border: none !important; + color: var(--primary-8) !important; + background-color: var(--primary-7) !important; +} + +// 溢出省略 +.one-line-text() { + @apply overflow-hidden overflow-ellipsis whitespace-nowrap; +} diff --git a/frontend/packages/mobile/src/assets/svg/bot.svg b/frontend/packages/mobile/src/assets/svg/bot.svg new file mode 100644 index 0000000..e78f664 --- /dev/null +++ b/frontend/packages/mobile/src/assets/svg/bot.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/packages/mobile/src/assets/svg/newClue.svg b/frontend/packages/mobile/src/assets/svg/newClue.svg new file mode 100644 index 0000000..ab92c2a --- /dev/null +++ b/frontend/packages/mobile/src/assets/svg/newClue.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/mobile/src/assets/svg/newContact.svg b/frontend/packages/mobile/src/assets/svg/newContact.svg new file mode 100644 index 0000000..990cdad --- /dev/null +++ b/frontend/packages/mobile/src/assets/svg/newContact.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/mobile/src/assets/svg/newCustomer.svg b/frontend/packages/mobile/src/assets/svg/newCustomer.svg new file mode 100644 index 0000000..f778d21 --- /dev/null +++ b/frontend/packages/mobile/src/assets/svg/newCustomer.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/mobile/src/assets/svg/newOpportunity.svg b/frontend/packages/mobile/src/assets/svg/newOpportunity.svg new file mode 100644 index 0000000..14b5f13 --- /dev/null +++ b/frontend/packages/mobile/src/assets/svg/newOpportunity.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/mobile/src/assets/svg/newPlan.svg b/frontend/packages/mobile/src/assets/svg/newPlan.svg new file mode 100644 index 0000000..672f59d --- /dev/null +++ b/frontend/packages/mobile/src/assets/svg/newPlan.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/mobile/src/assets/svg/newRecord.svg b/frontend/packages/mobile/src/assets/svg/newRecord.svg new file mode 100644 index 0000000..5420c55 --- /dev/null +++ b/frontend/packages/mobile/src/assets/svg/newRecord.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/mobile/src/auto-import.d.ts b/frontend/packages/mobile/src/auto-import.d.ts new file mode 100644 index 0000000..9b894de --- /dev/null +++ b/frontend/packages/mobile/src/auto-import.d.ts @@ -0,0 +1,70 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +export {} +declare global { + const EffectScope: typeof import('vue')['EffectScope'] + const computed: typeof import('vue')['computed'] + const createApp: typeof import('vue')['createApp'] + const customRef: typeof import('vue')['customRef'] + const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] + const defineComponent: typeof import('vue')['defineComponent'] + const effectScope: typeof import('vue')['effectScope'] + const getCurrentInstance: typeof import('vue')['getCurrentInstance'] + const getCurrentScope: typeof import('vue')['getCurrentScope'] + const h: typeof import('vue')['h'] + const inject: typeof import('vue')['inject'] + const isProxy: typeof import('vue')['isProxy'] + const isReactive: typeof import('vue')['isReactive'] + const isReadonly: typeof import('vue')['isReadonly'] + const isRef: typeof import('vue')['isRef'] + const markRaw: typeof import('vue')['markRaw'] + const nextTick: typeof import('vue')['nextTick'] + const onActivated: typeof import('vue')['onActivated'] + const onBeforeMount: typeof import('vue')['onBeforeMount'] + const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] + const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] + const onDeactivated: typeof import('vue')['onDeactivated'] + const onErrorCaptured: typeof import('vue')['onErrorCaptured'] + const onMounted: typeof import('vue')['onMounted'] + const onRenderTracked: typeof import('vue')['onRenderTracked'] + const onRenderTriggered: typeof import('vue')['onRenderTriggered'] + const onScopeDispose: typeof import('vue')['onScopeDispose'] + const onServerPrefetch: typeof import('vue')['onServerPrefetch'] + const onUnmounted: typeof import('vue')['onUnmounted'] + const onUpdated: typeof import('vue')['onUpdated'] + const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] + const provide: typeof import('vue')['provide'] + const reactive: typeof import('vue')['reactive'] + const readonly: typeof import('vue')['readonly'] + const ref: typeof import('vue')['ref'] + const resolveComponent: typeof import('vue')['resolveComponent'] + const shallowReactive: typeof import('vue')['shallowReactive'] + const shallowReadonly: typeof import('vue')['shallowReadonly'] + const shallowRef: typeof import('vue')['shallowRef'] + const toRaw: typeof import('vue')['toRaw'] + const toRef: typeof import('vue')['toRef'] + const toRefs: typeof import('vue')['toRefs'] + const toValue: typeof import('vue')['toValue'] + const triggerRef: typeof import('vue')['triggerRef'] + const unref: typeof import('vue')['unref'] + const useAttrs: typeof import('vue')['useAttrs'] + const useCssModule: typeof import('vue')['useCssModule'] + const useCssVars: typeof import('vue')['useCssVars'] + const useId: typeof import('vue')['useId'] + const useModel: typeof import('vue')['useModel'] + const useSlots: typeof import('vue')['useSlots'] + const useTemplateRef: typeof import('vue')['useTemplateRef'] + const watch: typeof import('vue')['watch'] + const watchEffect: typeof import('vue')['watchEffect'] + const watchPostEffect: typeof import('vue')['watchPostEffect'] + const watchSyncEffect: typeof import('vue')['watchSyncEffect'] +} +// for type re-export +declare global { + // @ts-ignore + export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + import('vue') +} diff --git a/frontend/packages/mobile/src/components/business/crm-action-buttons/index.vue b/frontend/packages/mobile/src/components/business/crm-action-buttons/index.vue new file mode 100644 index 0000000..3abbe6b --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-action-buttons/index.vue @@ -0,0 +1,75 @@ + + + diff --git a/frontend/packages/mobile/src/components/business/crm-avatar/index.vue b/frontend/packages/mobile/src/components/business/crm-avatar/index.vue new file mode 100644 index 0000000..33f6896 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-avatar/index.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-contact-list/detail.vue b/frontend/packages/mobile/src/components/business/crm-contact-list/detail.vue new file mode 100644 index 0000000..f10ee5b --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-contact-list/detail.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-contact-list/index.vue b/frontend/packages/mobile/src/components/business/crm-contact-list/index.vue new file mode 100644 index 0000000..11228fd --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-contact-list/index.vue @@ -0,0 +1,282 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-contact-list/locale/en-US.ts b/frontend/packages/mobile/src/components/business/crm-contact-list/locale/en-US.ts new file mode 100644 index 0000000..ccb0ec6 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-contact-list/locale/en-US.ts @@ -0,0 +1,7 @@ +export default { + 'contact.deleteTitle': 'Delete this contact?', + 'contact.deleteTip': 'Once deleted, this contact will not be recoverable. Please proceed with caution!', + 'contact.all': 'All contacts', + 'contact.mine': 'My contacts', + 'contact.department': 'Department contacts', +}; diff --git a/frontend/packages/mobile/src/components/business/crm-contact-list/locale/zh-CN.ts b/frontend/packages/mobile/src/components/business/crm-contact-list/locale/zh-CN.ts new file mode 100644 index 0000000..65153c7 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-contact-list/locale/zh-CN.ts @@ -0,0 +1,7 @@ +export default { + 'contact.deleteTitle': '确认删除联系人吗?', + 'contact.deleteTip': '删除后,将无法查询到该联系人且不可恢复,请谨慎操作!', + 'contact.all': '全部联系人', + 'contact.mine': '我的联系人', + 'contact.department': '部门联系人', +}; diff --git a/frontend/packages/mobile/src/components/business/crm-datasource/index.vue b/frontend/packages/mobile/src/components/business/crm-datasource/index.vue new file mode 100644 index 0000000..c5b02f8 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-datasource/index.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-datasource/locale/en-US.ts b/frontend/packages/mobile/src/components/business/crm-datasource/locale/en-US.ts new file mode 100644 index 0000000..f7ce9ca --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-datasource/locale/en-US.ts @@ -0,0 +1,4 @@ +export default { + 'datasource.pickResource': 'Pick {name}', + 'datasource.searchPlaceholder': 'Please enter {name} name', +}; diff --git a/frontend/packages/mobile/src/components/business/crm-datasource/locale/zh-CN.ts b/frontend/packages/mobile/src/components/business/crm-datasource/locale/zh-CN.ts new file mode 100644 index 0000000..a5cbf93 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-datasource/locale/zh-CN.ts @@ -0,0 +1,4 @@ +export default { + 'datasource.pickResource': '选择{name}', + 'datasource.searchPlaceholder': '请输入{name}名称', +}; diff --git a/frontend/packages/mobile/src/components/business/crm-file-list-pop/index.vue b/frontend/packages/mobile/src/components/business/crm-file-list-pop/index.vue new file mode 100644 index 0000000..4af9840 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-file-list-pop/index.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-file-list-pop/locale/en-US.ts b/frontend/packages/mobile/src/components/business/crm-file-list-pop/locale/en-US.ts new file mode 100644 index 0000000..14e5ea0 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-file-list-pop/locale/en-US.ts @@ -0,0 +1,7 @@ +export default { + 'crm.fileListPop.title': 'Attachments', + 'crm.fileListPop.deleteTipTitle': 'Are you sure you want to delete this file?', + 'crm.fileListPop.deleteTipContent': 'This action cannot be undone, please proceed with caution!', + 'crm.fileListPop.uploadAt': '{name} uploaded at {time}', + 'crm.fileListPop.wxworkDownloadTip': 'Please open the webpage in a browser to download', +}; diff --git a/frontend/packages/mobile/src/components/business/crm-file-list-pop/locale/zh-CN.ts b/frontend/packages/mobile/src/components/business/crm-file-list-pop/locale/zh-CN.ts new file mode 100644 index 0000000..ccff7d7 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-file-list-pop/locale/zh-CN.ts @@ -0,0 +1,7 @@ +export default { + 'crm.fileListPop.title': '附件', + 'crm.fileListPop.deleteTipTitle': '确认删除该文件吗?', + 'crm.fileListPop.deleteTipContent': '删除后无法恢复,请谨慎操作!', + 'crm.fileListPop.uploadAt': '{name} 上传于 {time}', + 'crm.fileListPop.wxworkDownloadTip': '请使用浏览器打开网页后进行下载', +}; diff --git a/frontend/packages/mobile/src/components/business/crm-follow-list/components/listItem.vue b/frontend/packages/mobile/src/components/business/crm-follow-list/components/listItem.vue new file mode 100644 index 0000000..6acd4ca --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-follow-list/components/listItem.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-follow-list/followDetail.vue b/frontend/packages/mobile/src/components/business/crm-follow-list/followDetail.vue new file mode 100644 index 0000000..51abff5 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-follow-list/followDetail.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-follow-list/followPlan.vue b/frontend/packages/mobile/src/components/business/crm-follow-list/followPlan.vue new file mode 100644 index 0000000..cef980f --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-follow-list/followPlan.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-follow-list/followRecord.vue b/frontend/packages/mobile/src/components/business/crm-follow-list/followRecord.vue new file mode 100644 index 0000000..7e99941 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-follow-list/followRecord.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-follow-list/useFollowApi.ts b/frontend/packages/mobile/src/components/business/crm-follow-list/useFollowApi.ts new file mode 100644 index 0000000..c5e9d32 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-follow-list/useFollowApi.ts @@ -0,0 +1,89 @@ +import { useRouter } from 'vue-router'; + +import type { CustomerFollowPlanListItem, FollowDetailItem } from '@lib/shared/models/customer'; + +import { PlanEnumType, RecordEnumType } from '@/config/follow'; +import useUserStore from '@/store/modules/user'; + +import { CommonRouteEnum } from '@/enums/routeEnum'; + +export default function useFollowApi(followProps: { + type: 'followRecord' | 'followPlan'; + formKey: PlanEnumType | RecordEnumType; + sourceId: string; + initialSourceName?: string; + readonly?: boolean; +}) { + const router = useRouter(); + + function transformField(item: FollowDetailItem, optionMap?: Record) { + const methodKey = followProps.type === 'followPlan' ? 'method' : 'followMethod'; + let followMethod; + if (optionMap) { + followMethod = + optionMap[methodKey]?.find((e: any) => e.id === item[methodKey as keyof FollowDetailItem])?.name || '-'; + } + return { + ...item, + [methodKey]: followMethod, + }; + } + + function goCreate() { + router.push({ + name: CommonRouteEnum.FORM_CREATE, + query: { + formKey: followProps.formKey, + id: followProps.sourceId, + initialSourceName: followProps.initialSourceName, + }, + ...(followProps.type === 'followPlan' + ? { + state: { + params: JSON.stringify({ converted: false }), + }, + } + : {}), + }); + } + + function handleEdit(item: FollowDetailItem) { + router.push({ + name: CommonRouteEnum.FORM_CREATE, + query: { + formKey: followProps.formKey, + id: item.id, + needInitDetail: 'Y', + }, + ...(followProps.type === 'followPlan' + ? { + state: { + params: JSON.stringify({ converted: (item as CustomerFollowPlanListItem).converted }), + }, + } + : {}), + }); + } + + function goDetail(item: FollowDetailItem) { + const userStore = useUserStore(); + const isOwner = userStore.userInfo.id === item.owner; + const readonlyString = followProps.readonly || !isOwner; + router.push({ + name: CommonRouteEnum.FOLLOW_DETAIL, + query: { + formKey: followProps.formKey, + id: item.id, + needInitDetail: 'Y', + readonly: String(readonlyString), + }, + }); + } + + return { + transformField, + goCreate, + handleEdit, + goDetail, + }; +} diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/dataSource.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/dataSource.vue new file mode 100644 index 0000000..3d2ca7e --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/dataSource.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/file.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/file.vue new file mode 100644 index 0000000..5e1669b --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/file.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/index.ts b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/index.ts new file mode 100644 index 0000000..29b2240 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/index.ts @@ -0,0 +1,19 @@ +import dataSource from './dataSource.vue'; +import file from './file.vue'; +import industry from './industry.vue'; +import link from './link.vue'; +import location from './location.vue'; +import phone from './phone.vue'; +import serialNumber from './serialNumber.vue'; +import upload from './upload.vue'; + +export default { + phone, + serialNumber, + location, + dataSource, + upload, + link, + file, + industry, +}; diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/industry.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/industry.vue new file mode 100644 index 0000000..7bc89ee --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/industry.vue @@ -0,0 +1,71 @@ + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/link.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/link.vue new file mode 100644 index 0000000..3a53764 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/link.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/location.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/location.vue new file mode 100644 index 0000000..34f8964 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/location.vue @@ -0,0 +1,101 @@ + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/phone.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/phone.vue new file mode 100644 index 0000000..a26c9c5 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/phone.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/serialNumber.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/serialNumber.vue new file mode 100644 index 0000000..cdd27bd --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/serialNumber.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/upload.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/upload.vue new file mode 100644 index 0000000..4d59157 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/advanced/upload.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/checkbox.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/checkbox.vue new file mode 100644 index 0000000..de95b9b --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/checkbox.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/datePicker.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/datePicker.vue new file mode 100644 index 0000000..801a6fc --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/datePicker.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/divider.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/divider.vue new file mode 100644 index 0000000..ccef644 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/divider.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/index.ts b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/index.ts new file mode 100644 index 0000000..bc0a21e --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/index.ts @@ -0,0 +1,23 @@ +import checkbox from './checkbox.vue'; +import datePicker from './datePicker.vue'; +import divider from './divider.vue'; +import inputNumber from './inputNumber.vue'; +import memberSelect from './memberSelect.vue'; +import multiplePick from './multiplePick.vue'; +import pick from './pick.vue'; +import radio from './radio.vue'; +import singleText from './singleText.vue'; +import textarea from './textarea.vue'; + +export default { + checkbox, + radio, + inputNumber, + textarea, + divider, + datePicker, + singleText, + pick, + multiplePick, + memberSelect, +}; diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/inputNumber.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/inputNumber.vue new file mode 100644 index 0000000..c788793 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/inputNumber.vue @@ -0,0 +1,122 @@ + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/memberSelect.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/memberSelect.vue new file mode 100644 index 0000000..67a9bfe --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/memberSelect.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/multiplePick.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/multiplePick.vue new file mode 100644 index 0000000..c6c97b0 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/multiplePick.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/pick.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/pick.vue new file mode 100644 index 0000000..62b48ce --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/pick.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/radio.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/radio.vue new file mode 100644 index 0000000..d1a50a0 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/radio.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/singleText.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/singleText.vue new file mode 100644 index 0000000..8112003 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/singleText.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/textarea.vue b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/textarea.vue new file mode 100644 index 0000000..fcad953 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/basic/textarea.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/components/index.ts b/frontend/packages/mobile/src/components/business/crm-form-create/components/index.ts new file mode 100644 index 0000000..3ee3221 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/components/index.ts @@ -0,0 +1,7 @@ +import advancedComponents from './advanced'; +import basicComponents from './basic'; + +export default { + basicComponents, + advancedComponents, +}; diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/index.vue b/frontend/packages/mobile/src/components/business/crm-form-create/index.vue new file mode 100644 index 0000000..99ff153 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/index.vue @@ -0,0 +1,307 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/locale/en-US.ts b/frontend/packages/mobile/src/components/business/crm-form-create/locale/en-US.ts new file mode 100644 index 0000000..d63a7a1 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/locale/en-US.ts @@ -0,0 +1,35 @@ +export default { + 'formCreate.createTitle': 'Create {name}', + 'formCreate.editTitle': 'Update {name}', + 'formCreate.customer': 'Account', + 'formCreate.contract': 'Contact', + 'formCreate.business': 'Opportunity', + 'formCreate.product': 'Product', + 'formCreate.clue': 'Lead', + 'formCreate.member': 'Member', + 'formCreate.department': 'Department', + 'formCreate.price': 'Price Table', + 'formCreate.quotation': 'Quotation Table', + 'formCreate.order': 'Order', + 'formCreate.invoice': 'Invoice', + 'formCreate.pickDate': 'Pick Date', + 'formCreate.pickTime': 'Pick Time', + 'formCreate.next': 'Next', + 'formCreate.phone.area.china': 'China', + 'formCreate.phone.area.hongKong': 'Hong Kong, China', + 'formCreate.phone.area.macau': 'Macau, China', + 'formCreate.phone.area.taiwan': 'Taiwan, China', + 'formCreate.phone.lengthValidator': 'Please enter the {count} digit phone number', + 'formCreate.phone.area.other': 'Other', + 'formCreate.phone.formatValidator': 'Please enter the phone number in the correct format', + 'formCreate.repeatTip': 'Duplicate with {name}', + 'formCreate.repeatTipWithoutName': 'There are duplicate values in the system', + 'formCreate.upload.repeatFileTip': 'File is duplicated', + 'formCreate.advanced.overSize': 'File size limit is {size} MB, please re-select the file', + 'formCreate.advanced.selectLocation': 'Please select an address', + 'formCreate.advanced.inputLocationDetail': 'Please enter detailed address', + 'formCreate.advanced.anyType': 'any format', + 'formCreate.advanced.uploadFileTip': 'Supports {type}, single file size within {size}', + 'formCreate.advanced.limitCount': ', maximum limit is {count}', + 'formCreate.advanced.selectIndustry': 'Please select an industry', +}; diff --git a/frontend/packages/mobile/src/components/business/crm-form-create/locale/zh-CN.ts b/frontend/packages/mobile/src/components/business/crm-form-create/locale/zh-CN.ts new file mode 100644 index 0000000..d59b3b3 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-form-create/locale/zh-CN.ts @@ -0,0 +1,35 @@ +export default { + 'formCreate.createTitle': '新建{name}', + 'formCreate.editTitle': '更新{name}', + 'formCreate.customer': '客户', + 'formCreate.contract': '联系人', + 'formCreate.business': '商机', + 'formCreate.product': '产品', + 'formCreate.clue': '线索', + 'formCreate.member': '成员', + 'formCreate.department': '部门', + 'formCreate.price': '价格表', + 'formCreate.quotation': '报价', + 'formCreate.order': '订单', + 'formCreate.invoice': '发票', + 'formCreate.pickDate': '选择日期', + 'formCreate.pickTime': '选择时间', + 'formCreate.next': '下一步', + 'formCreate.phone.area.china': '中国', + 'formCreate.phone.area.hongKong': '中国香港', + 'formCreate.phone.area.macau': '中国澳门', + 'formCreate.phone.area.taiwan': '中国台湾', + 'formCreate.phone.lengthValidator': '请输入 {count} 位手机号', + 'formCreate.phone.area.other': '其它', + 'formCreate.phone.formatValidator': '请输入正确格式的手机号', + 'formCreate.repeatTip': '与 {name} 重复', + 'formCreate.repeatTipWithoutName': '系统中存在重复值', + 'formCreate.upload.repeatFileTip': '文件重复', + 'formCreate.advanced.overSize': '文件大小限制为 {size} MB,请重新选择文件', + 'formCreate.advanced.selectLocation': '请选择地址', + 'formCreate.advanced.inputLocationDetail': '请输入详细地址', + 'formCreate.advanced.anyType': '任意格式', + 'formCreate.advanced.uploadFileTip': '支持 {type},单个大小 {size} 以内', + 'formCreate.advanced.limitCount': ',最大限制 {count} 个', + 'formCreate.advanced.selectIndustry': '请选择行业', +}; diff --git a/frontend/packages/mobile/src/components/business/crm-header-list/index.vue b/frontend/packages/mobile/src/components/business/crm-header-list/index.vue new file mode 100644 index 0000000..4c83b5e --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-header-list/index.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-header-list/locale/en-US.ts b/frontend/packages/mobile/src/components/business/crm-header-list/locale/en-US.ts new file mode 100644 index 0000000..66cec2a --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-header-list/locale/en-US.ts @@ -0,0 +1,3 @@ +export default { + 'header.attributionPeriod': 'Attribution period', +}; diff --git a/frontend/packages/mobile/src/components/business/crm-header-list/locale/zh-CN.ts b/frontend/packages/mobile/src/components/business/crm-header-list/locale/zh-CN.ts new file mode 100644 index 0000000..0a4daba --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-header-list/locale/zh-CN.ts @@ -0,0 +1,3 @@ +export default { + 'header.attributionPeriod': '归属周期', +}; diff --git a/frontend/packages/mobile/src/components/business/crm-message-item/index.vue b/frontend/packages/mobile/src/components/business/crm-message-item/index.vue new file mode 100644 index 0000000..6d8b327 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-message-item/index.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-select-list/index.vue b/frontend/packages/mobile/src/components/business/crm-select-list/index.vue new file mode 100644 index 0000000..6af4d94 --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-select-list/index.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-workflow-card/index.vue b/frontend/packages/mobile/src/components/business/crm-workflow-card/index.vue new file mode 100644 index 0000000..864cd9f --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-workflow-card/index.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/business/crm-workflow-card/workflowStage.vue b/frontend/packages/mobile/src/components/business/crm-workflow-card/workflowStage.vue new file mode 100644 index 0000000..02c48bb --- /dev/null +++ b/frontend/packages/mobile/src/components/business/crm-workflow-card/workflowStage.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/pure/crm-description/index.vue b/frontend/packages/mobile/src/components/pure/crm-description/index.vue new file mode 100644 index 0000000..1678803 --- /dev/null +++ b/frontend/packages/mobile/src/components/pure/crm-description/index.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/pure/crm-description/locale/en-US.ts b/frontend/packages/mobile/src/components/pure/crm-description/locale/en-US.ts new file mode 100644 index 0000000..bf4fe54 --- /dev/null +++ b/frontend/packages/mobile/src/components/pure/crm-description/locale/en-US.ts @@ -0,0 +1,3 @@ +export default { + 'crm.description.attachment': '{count} attachments', +}; diff --git a/frontend/packages/mobile/src/components/pure/crm-description/locale/zh-CN.ts b/frontend/packages/mobile/src/components/pure/crm-description/locale/zh-CN.ts new file mode 100644 index 0000000..1993193 --- /dev/null +++ b/frontend/packages/mobile/src/components/pure/crm-description/locale/zh-CN.ts @@ -0,0 +1,3 @@ +export default { + 'crm.description.attachment': '{count}个附件', +}; diff --git a/frontend/packages/mobile/src/components/pure/crm-file-icon/index.vue b/frontend/packages/mobile/src/components/pure/crm-file-icon/index.vue new file mode 100644 index 0000000..7351642 --- /dev/null +++ b/frontend/packages/mobile/src/components/pure/crm-file-icon/index.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/pure/crm-icon-font/index.vue b/frontend/packages/mobile/src/components/pure/crm-icon-font/index.vue new file mode 100644 index 0000000..17a2a40 --- /dev/null +++ b/frontend/packages/mobile/src/components/pure/crm-icon-font/index.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/pure/crm-list-common-item/index.vue b/frontend/packages/mobile/src/components/pure/crm-list-common-item/index.vue new file mode 100644 index 0000000..d8ea8ba --- /dev/null +++ b/frontend/packages/mobile/src/components/pure/crm-list-common-item/index.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/pure/crm-list/index.vue b/frontend/packages/mobile/src/components/pure/crm-list/index.vue new file mode 100644 index 0000000..c103d00 --- /dev/null +++ b/frontend/packages/mobile/src/components/pure/crm-list/index.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/pure/crm-page-header/index.vue b/frontend/packages/mobile/src/components/pure/crm-page-header/index.vue new file mode 100644 index 0000000..2aa22bd --- /dev/null +++ b/frontend/packages/mobile/src/components/pure/crm-page-header/index.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/pure/crm-page-wrapper/index.vue b/frontend/packages/mobile/src/components/pure/crm-page-wrapper/index.vue new file mode 100644 index 0000000..47fbc98 --- /dev/null +++ b/frontend/packages/mobile/src/components/pure/crm-page-wrapper/index.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/pure/crm-password-input/index.vue b/frontend/packages/mobile/src/components/pure/crm-password-input/index.vue new file mode 100644 index 0000000..73c5466 --- /dev/null +++ b/frontend/packages/mobile/src/components/pure/crm-password-input/index.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/pure/crm-tag/index.vue b/frontend/packages/mobile/src/components/pure/crm-tag/index.vue new file mode 100644 index 0000000..7ebb618 --- /dev/null +++ b/frontend/packages/mobile/src/components/pure/crm-tag/index.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/frontend/packages/mobile/src/components/pure/crm-text-button/index.vue b/frontend/packages/mobile/src/components/pure/crm-text-button/index.vue new file mode 100644 index 0000000..71c6a8d --- /dev/null +++ b/frontend/packages/mobile/src/components/pure/crm-text-button/index.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/frontend/packages/mobile/src/config/follow.ts b/frontend/packages/mobile/src/config/follow.ts new file mode 100644 index 0000000..319c8cb --- /dev/null +++ b/frontend/packages/mobile/src/config/follow.ts @@ -0,0 +1,110 @@ +import { CustomerFollowPlanStatusEnum } from '@lib/shared/enums/customerEnum'; +import { FormDesignKeyEnum } from '@lib/shared/enums/formDesignEnum'; +import type { CommonList } from '@lib/shared/models/common'; +import type { FollowDetailItem, StatusTagKey } from '@lib/shared/models/customer'; + +import { + deleteClueFollowPlan, + deleteClueFollowRecord, + deleteCustomerFollowPlan, + deleteCustomerFollowRecord, + deleteOptFollowPlan, + deleteOptFollowRecord, + getClueFollowPlanList, + getClueFollowRecordList, + getCluePoolFollowRecordList, + getCustomerFollowPlanList, + getCustomerFollowRecordList, + getCustomerOpenSeaFollowRecordList, + getOptFollowPlanList, + getOptFollowRecordList, + updateClueFollowPlanStatus, + updateCustomerFollowPlanStatus, + updateOptFollowPlanStatus, +} from '@/api/modules'; + +export type StatusTag = { + label: string; + value: CustomerFollowPlanStatusEnum; + icon: string; + color: string; + iconColor?: string; + bgColor?: string; +}; + +export const statusMap: Record = { + [CustomerFollowPlanStatusEnum.PREPARED]: { + label: 'common.notStarted', + value: CustomerFollowPlanStatusEnum.PREPARED, + icon: 'iconicon_block_filled', + color: 'var(--text-n1)', + iconColor: 'var(--text-n4)', + bgColor: 'var(--text-n6)', + }, + [CustomerFollowPlanStatusEnum.UNDERWAY]: { + label: 'common.inProgress', + value: CustomerFollowPlanStatusEnum.UNDERWAY, + icon: 'iconicon_testing', + color: 'var(--info-blue)', + }, + [CustomerFollowPlanStatusEnum.COMPLETED]: { + label: 'common.completed', + value: CustomerFollowPlanStatusEnum.COMPLETED, + icon: 'iconicon_succeed_filled', + color: 'var(--success-green)', + }, + [CustomerFollowPlanStatusEnum.CANCELLED]: { + label: 'common.canceled', + value: CustomerFollowPlanStatusEnum.CANCELLED, + icon: 'iconicon_block_filled', + color: 'var(--text-n6)', + bgColor: 'var(--text-n4)', + }, +}; + +export type RecordEnumType = + | FormDesignKeyEnum.FOLLOW_RECORD_CUSTOMER + | FormDesignKeyEnum.FOLLOW_RECORD_CLUE + | FormDesignKeyEnum.CLUE_POOL + | FormDesignKeyEnum.CUSTOMER_OPEN_SEA + | FormDesignKeyEnum.FOLLOW_RECORD_BUSINESS; + +export const followRecordApiMap: { + list: Record Promise>>; + delete: Partial Promise>>; +} = { + list: { + [FormDesignKeyEnum.FOLLOW_RECORD_CUSTOMER]: getCustomerFollowRecordList, + [FormDesignKeyEnum.FOLLOW_RECORD_CLUE]: getClueFollowRecordList, + [FormDesignKeyEnum.CLUE_POOL]: getCluePoolFollowRecordList, + [FormDesignKeyEnum.FOLLOW_RECORD_BUSINESS]: getOptFollowRecordList, + [FormDesignKeyEnum.CUSTOMER_OPEN_SEA]: getCustomerOpenSeaFollowRecordList, + }, + delete: { + [FormDesignKeyEnum.FOLLOW_RECORD_CUSTOMER]: deleteCustomerFollowRecord, + [FormDesignKeyEnum.FOLLOW_RECORD_CLUE]: deleteClueFollowRecord, + [FormDesignKeyEnum.FOLLOW_RECORD_BUSINESS]: deleteOptFollowRecord, + }, +}; + +export type PlanEnumType = + | FormDesignKeyEnum.FOLLOW_PLAN_CUSTOMER + | FormDesignKeyEnum.FOLLOW_PLAN_CLUE + | FormDesignKeyEnum.FOLLOW_PLAN_BUSINESS; +export const followPlanApiMap = { + list: { + [FormDesignKeyEnum.FOLLOW_PLAN_CUSTOMER]: getCustomerFollowPlanList, + [FormDesignKeyEnum.FOLLOW_PLAN_CLUE]: getClueFollowPlanList, + [FormDesignKeyEnum.FOLLOW_PLAN_BUSINESS]: getOptFollowPlanList, + }, + delete: { + [FormDesignKeyEnum.FOLLOW_PLAN_CUSTOMER]: deleteCustomerFollowPlan, + [FormDesignKeyEnum.FOLLOW_PLAN_CLUE]: deleteClueFollowPlan, + [FormDesignKeyEnum.FOLLOW_PLAN_BUSINESS]: deleteOptFollowPlan, + }, + changeStatus: { + [FormDesignKeyEnum.FOLLOW_PLAN_CUSTOMER]: updateCustomerFollowPlanStatus, + [FormDesignKeyEnum.FOLLOW_PLAN_CLUE]: updateClueFollowPlanStatus, + [FormDesignKeyEnum.FOLLOW_PLAN_BUSINESS]: updateOptFollowPlanStatus, + }, +}; diff --git a/frontend/packages/mobile/src/config/mine.ts b/frontend/packages/mobile/src/config/mine.ts new file mode 100644 index 0000000..200451d --- /dev/null +++ b/frontend/packages/mobile/src/config/mine.ts @@ -0,0 +1,31 @@ +import { OrgUserInfo } from '@lib/shared/models/system/org'; +// TODO +export const defaultUserInfo: OrgUserInfo = { + id: '', + name: '', + email: '', + password: '', + enable: true, + createTime: 0, + updateTime: 0, + language: '', + lastOrganizationId: '', + phone: '', + source: '', + createUser: '', + updateUser: '', + platformInfo: '', + avatar: '', + permissionIds: [''], + organizationIds: [''], + csrfToken: '', + sessionId: '', + roles: [], + userId: '', + userName: '', + departmentName: '', + departmentId: '', + defaultPwd: true, +}; + +export default {}; diff --git a/frontend/packages/mobile/src/directive/index.ts b/frontend/packages/mobile/src/directive/index.ts new file mode 100644 index 0000000..42b11a4 --- /dev/null +++ b/frontend/packages/mobile/src/directive/index.ts @@ -0,0 +1,9 @@ +import { App } from 'vue'; + +import permission from './permission'; + +export default { + install(Vue: App) { + Vue.directive('permission', permission); + }, +}; diff --git a/frontend/packages/mobile/src/directive/permission/index.ts b/frontend/packages/mobile/src/directive/permission/index.ts new file mode 100644 index 0000000..c556acd --- /dev/null +++ b/frontend/packages/mobile/src/directive/permission/index.ts @@ -0,0 +1,32 @@ +import { DirectiveBinding } from 'vue'; + +import { hasAllPermission, hasAnyPermission } from '@/utils/permission'; + +/** + * 权限指令 + * @param el dom 节点 + * @param binding vue 绑定的数据 + */ +function checkPermission(el: HTMLElement, binding: DirectiveBinding) { + const { value, modifiers } = binding; + if (Array.isArray(value)) { + if (value.length > 0) { + // 如果有 all 修饰符,表示需要全部权限;否则只需要其中一个权限 + const hasPermission = modifiers.all ? hasAllPermission(value) : hasAnyPermission(value); + if (!hasPermission && el.parentNode) { + el.parentNode.removeChild(el); + } + } + } else { + throw new Error(`need roles! Like v-permission="['admin','user']"`); + } +} + +export default { + mounted(el: HTMLElement, binding: DirectiveBinding) { + checkPermission(el, binding); + }, + updated(el: HTMLElement, binding: DirectiveBinding) { + checkPermission(el, binding); + }, +}; diff --git a/frontend/packages/mobile/src/enums/routeEnum.ts b/frontend/packages/mobile/src/enums/routeEnum.ts new file mode 100644 index 0000000..05693b5 --- /dev/null +++ b/frontend/packages/mobile/src/enums/routeEnum.ts @@ -0,0 +1,63 @@ +export enum OpportunityRouteEnum { + OPPORTUNITY = 'opportunity', + OPPORTUNITY_INDEX = 'opportunityIndex', + OPPORTUNITY_DETAIL = 'opportunityDetail', +} + +export enum ClueRouteEnum { + CLUE = 'lead', + CLUE_INDEX = 'leadIndex', + CLUE_DETAIL = 'leadDetail', + CLUE_POOL_DETAIL = 'leadPoolDetail', + CONVERT = 'convert', + MOVE_TO_POOL = 'moveToPool', +} + +export enum CustomerRouteEnum { + CUSTOMER = 'account', + CUSTOMER_INDEX = 'accountIndex', + CUSTOMER_DETAIL = 'accountDetail', + CUSTOMER_TRANSFER = 'accountTransfer', + CUSTOMER_DISTRIBUTE = 'accountDistribute', + CUSTOMER_OPENSEA_DETAIL = 'accountOpenSeaDetail', + CUSTOMER_RELATION = 'accountRelation', + CUSTOMER_COLLABORATOR = 'accountCollaborator', +} + +export enum CommonRouteEnum { + COMMON = 'common', + FORM_CREATE = 'formCreate', + CONTACT_DETAIL = 'contactDetail', + FOLLOW_DETAIL = 'followDetail', + WORKFLOW_STAGE = 'workflowStage', +} + +export enum ProductRouteEnum { + PRODUCT = 'product', + PRODUCT_PRO = 'productPro', +} + +export enum MineRouteEnum { + MINE = 'mine', + MINE_INDEX = 'mineIndex', + MINE_MESSAGE = 'mineMessage', + MINE_DETAIL = 'mineDetail', +} + +export enum WorkbenchRouteEnum { + WORKBENCH = 'workbench', + WORKBENCH_INDEX = 'workbenchIndex', + WORKBENCH_AGENT = 'workbenchAgent', + WORKBENCH_DUPLICATE_CHECK = 'workbenchDuplicateCheck', + WORKBENCH_DUPLICATE_CHECK_DETAIL = 'workbenchDuplicateCheckDetail', +} + +export const AppRouteEnum = { + ...OpportunityRouteEnum, + ...ClueRouteEnum, + ...CustomerRouteEnum, + ...CommonRouteEnum, + ...ProductRouteEnum, + ...MineRouteEnum, + ...WorkbenchRouteEnum, +}; diff --git a/frontend/packages/mobile/src/hooks/useFormCreateApi.ts b/frontend/packages/mobile/src/hooks/useFormCreateApi.ts new file mode 100644 index 0000000..9b460cf --- /dev/null +++ b/frontend/packages/mobile/src/hooks/useFormCreateApi.ts @@ -0,0 +1,683 @@ +import { showSuccessToast } from 'vant'; +import { cloneDeep } from 'lodash-es'; +import dayjs from 'dayjs'; + +import { FieldTypeEnum, FormDesignKeyEnum, type FormLinkScenarioEnum } from '@lib/shared/enums/formDesignEnum'; +import { useI18n } from '@lib/shared/hooks/useI18n'; +import { formatTimeValue, getCityPath, getIndustryPath, safeFractionConvert, sleep } from '@lib/shared/method'; +import { + dataSourceTypes, + departmentTypes, + formatNumberValue, + getNormalFieldValue, + linkAllAcceptTypes, + memberTypes, + multipleTypes, + singleTypes, +} from '@lib/shared/method/formCreate'; +import type { ModuleField } from '@lib/shared/models/common'; +import type { CollaborationType } from '@lib/shared/models/customer'; +import type { FormConfig } from '@lib/shared/models/system/module'; + +import type { CrmDescriptionItem } from '@/components/pure/crm-description/index.vue'; + +import { + createFormApi, + getFormConfigApiMap, + getFormDetailApiMap, + updateFormApi, +} from '@cordys/web/src/components/business/crm-form-create/config'; +import type { FormCreateField } from '@cordys/web/src/components/business/crm-form-create/types'; + +export interface FormCreateApiProps { + sourceId?: Ref; + formKey: FormDesignKeyEnum; + needInitDetail?: boolean; + initialSourceName?: string; // 特殊字段初始化需要的资源名称 + otherSaveParams?: Record; + linkFormInfo?: Ref | undefined>; // 关联表单信息 + linkFormKey?: Ref; // 关联表单key + linkScenario?: Ref; // 关联表单场景 +} + +export default function useFormCreateApi(props: FormCreateApiProps) { + const { t } = useI18n(); + + const sourceName = ref(props.initialSourceName); // 资源名称 + const collaborationType = ref(); // 协作类型-客户独有 + const specialInitialOptions = ref[]>([]); // 特殊字段的初始化选项列表 + const descriptions = ref([]); // 表单详情描述列表 + const fieldList = ref([]); // 表单字段列表 + const fieldShowControlMap = ref>({}); // 表单字段显示控制映射 + const formConfig = ref({ + layout: 1, + labelPos: 'top', + inputWidth: 'custom', + optBtnContent: [ + { + text: t('common.save'), + enable: true, + }, + { + text: t('common.saveAndContinue'), + enable: false, + }, + { + text: t('common.cancel'), + enable: true, + }, + ], + optBtnPos: 'flex-row', + }); // 表单属性配置 + const loading = ref(false); + const unsaved = ref(false); + const formDetail = ref>({}); + const originFormDetail = ref>({}); + const detail = ref>({}); // 详情 + + function initFormShowControl() { + // 读取整个显隐控制映射 + Object.keys(fieldShowControlMap.value).forEach((fieldId) => { + // 取出当前字段的所有规则 + const ruleIds = Object.keys(fieldShowControlMap.value[fieldId]); + const field = fieldList.value.find((f) => f.id === fieldId); + if (field) { + // 当前字段存在,则遍历它的全部控制规则 + for (let i = 0; i < ruleIds.length; i++) { + const ruleId = ruleIds[i]; + const controlField = fieldList.value.find((f) => f.id === ruleId); + if (controlField) { + // 处理显示规则 + if (fieldShowControlMap.value[fieldId][ruleId].includes(formDetail.value[controlField?.id])) { + field.show = true; + break; // 满足显示规则就停止,因为只需要满足一个规则字段即显示 + } else { + field.show = false; + } + } + } + } + }); + } + + /** + * 表单描述显示规则处理 + * @param form 表单数据 + */ + function formDescriptionShowControlRulesSet(form: Record) { + // 读取整个显隐控制映射 + Object.keys(fieldShowControlMap.value).forEach((fieldId) => { + // 取出当前字段的所有规则 + const fieldRuleIds = Object.keys(fieldShowControlMap.value[fieldId]); + const field = fieldList.value.find((f) => f.id === fieldId); + if (field) { + // 当前字段存在,则遍历它的全部控制规则 + for (let i = 0; i < fieldRuleIds.length; i++) { + const ruleId = fieldRuleIds[i]; + let value = ''; + const controlField = fieldList.value.find((f) => f.id === ruleId); + if (controlField?.businessKey) { + value = form[controlField.businessKey]; + } else { + const formField = form.moduleFields?.find( + (moduleField: ModuleField) => moduleField.fieldId === controlField?.id + ); + value = formField?.fieldValue || ''; + } + // 处理显示规则 + if (fieldShowControlMap.value[fieldId][ruleId].includes(value)) { + field.show = true; + break; // 满足显示规则就停止,因为只需要满足一个规则字段即显示 + } else { + field.show = false; + } + } + } + }); + } + + async function initFormDescription() { + try { + const asyncApi = getFormDetailApiMap[props.formKey]; + if (!asyncApi || !props.sourceId?.value) return; + const form = await asyncApi(props.sourceId?.value); + descriptions.value = []; + detail.value = form; + collaborationType.value = form.collaborationType; + formDescriptionShowControlRulesSet(form); + fieldList.value.forEach((item) => { + if (item.show === false || !item.readable) return; + if (item.businessKey) { + const options = form.optionMap?.[item.businessKey]; + // 业务标准字段读取最外层,读取form[item.businessKey]取到 id 值,然后去 options 里取 name + let name: string | string[] = ''; + const value = form[item.businessKey]; + // 若字段值是选项值,则取选项值的name + if (options) { + if (Array.isArray(value)) { + name = value.map((e) => { + const option = options.find((opt) => opt.id === e); + if (option) { + return option.name || t('common.optionNotExist'); + } + return t('common.optionNotExist'); + }); + } else { + name = options.find((e) => e.id === value)?.name; + } + } + if (item.type === FieldTypeEnum.DATE_TIME) { + descriptions.value.push({ + label: item.name, + value: formatTimeValue(name || form[item.businessKey], item.dateType), + }); + } else if (item.type === FieldTypeEnum.INPUT_NUMBER) { + descriptions.value.push({ + label: item.name, + value: formatNumberValue(name || form[item.businessKey], item), + }); + } else { + descriptions.value.push({ + label: item.name, + value: name || form[item.businessKey], + }); + } + if (item.businessKey === 'name') { + sourceName.value = name || form[item.businessKey]; + } + } else { + const options = form.optionMap?.[item.id]; + // 其他的字段读取moduleFields + const field = form.moduleFields?.find((moduleField: ModuleField) => moduleField.fieldId === item.id); + if (item.type === FieldTypeEnum.DIVIDER) { + descriptions.value.push({ + label: item.name, + value: field?.fieldValue || [], + isTitle: true, + fieldInfo: item, + }); + } else if (item.type === FieldTypeEnum.PICTURE) { + descriptions.value.push({ + label: item.name, + value: field?.fieldValue || [], + isImage: true, + }); + } else if (item.type === FieldTypeEnum.LINK) { + descriptions.value.push({ + label: item.name, + value: field?.fieldValue || '', + isLink: true, + fieldInfo: item, + }); + } else if (item.type === FieldTypeEnum.ATTACHMENT) { + descriptions.value.push({ + label: item.name, + value: form.attachmentMap?.[item.id] || [], + isAttachment: true, + }); + } else { + let value = field?.fieldValue || ''; + if (field && options) { + // 若字段值是选项值,则取选项值的name + if (Array.isArray(field.fieldValue)) { + value = field.fieldValue.map((e) => { + const option = options.find((opt) => opt.id === e); + if (option) { + return option.name || t('common.optionNotExist'); + } + return t('common.optionNotExist'); + }); + } else { + value = options.find((e) => e.id === field.fieldValue)?.name; + } + } else if (item.type === FieldTypeEnum.LOCATION) { + const addressArr = (field?.fieldValue as string)?.split('-') || []; + value = addressArr.length + ? `${getCityPath(addressArr[0], item.scope)}-${addressArr.filter((e, i) => i > 0).join('-')}` + : '-'; + } else if (item.type === FieldTypeEnum.INDUSTRY) { + value = field?.fieldValue ? getIndustryPath(field.fieldValue as string) : '-'; + } else if (item.type === FieldTypeEnum.INPUT_NUMBER) { + value = formatNumberValue(field?.fieldValue as string, item); + } else if (item.type === FieldTypeEnum.DATE_TIME) { + value = formatTimeValue(field?.fieldValue as string, item.dateType); + } + descriptions.value.push({ + label: item.name, + isTag: [FieldTypeEnum.INPUT_MULTIPLE, FieldTypeEnum.DATA_SOURCE_MULTIPLE].includes(item.type), + value, + }); + } + } + }); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } finally { + loading.value = false; + } + } + + function initFieldValue(field: FormCreateField, value: string | number | (string | number)[]) { + if ( + [FieldTypeEnum.DATA_SOURCE, FieldTypeEnum.DATA_SOURCE_MULTIPLE].includes(field.type) && + typeof value === 'string' + ) { + return value ? [value] : []; + } + return value; + } + + async function initFormDetail() { + try { + const asyncApi = getFormDetailApiMap[props.formKey]; + if (!asyncApi || !props.sourceId?.value) return; + const res = await asyncApi(props.sourceId?.value); + collaborationType.value = res.collaborationType; + sourceName.value = res.name; + fieldList.value = fieldList.value.map((item) => { + if (item.businessKey) { + // 业务标准字段读取最外层 + formDetail.value[item.id] = initFieldValue(item, res[item.businessKey]); + const options = res.optionMap?.[item.businessKey]; + if ( + [ + FieldTypeEnum.MEMBER, + FieldTypeEnum.MEMBER_MULTIPLE, + FieldTypeEnum.DEPARTMENT, + FieldTypeEnum.DEPARTMENT_MULTIPLE, + FieldTypeEnum.DATA_SOURCE, + FieldTypeEnum.DATA_SOURCE_MULTIPLE, + ].includes(item.type) + ) { + // 处理成员和数据源类型的字段 + item.initialOptions = options + ?.filter((e) => formDetail.value[item.id]?.includes(e.id)) + .map((e) => ({ + ...e, + name: e.name || t('common.optionNotExist'), + })); + } + } else { + // 其他的字段读取moduleFields + const field = res.moduleFields?.find((moduleField: ModuleField) => moduleField.fieldId === item.id); + if (field) { + formDetail.value[item.id] = initFieldValue(item, field.fieldValue); + } + const options = res.optionMap?.[item.id]; + if ( + [ + FieldTypeEnum.MEMBER, + FieldTypeEnum.MEMBER_MULTIPLE, + FieldTypeEnum.DEPARTMENT, + FieldTypeEnum.DEPARTMENT_MULTIPLE, + FieldTypeEnum.DATA_SOURCE, + FieldTypeEnum.DATA_SOURCE_MULTIPLE, + ].includes(item.type) + ) { + // 处理成员和数据源类型的字段 + item.initialOptions = options + ?.filter((e) => formDetail.value[item.id]?.includes(e.id)) + .map((e) => ({ + ...e, + name: e.name || t('common.optionNotExist'), + })); + } + } + if (item.type === FieldTypeEnum.DATE_TIME) { + // 处理时间类型的字段 + formDetail.value[item.id] = formDetail.value[item.id] ? Number(formDetail.value[item.id]) : ''; + } else if (item.type === FieldTypeEnum.ATTACHMENT) { + item.initialOptions = res.attachmentMap?.[item.id]; + } + return item; + }); + originFormDetail.value = cloneDeep(formDetail.value); + nextTick(() => { + unsaved.value = false; + }); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } finally { + loading.value = false; + } + } + + /** + * 处理业务表单的特殊字段在特定场景下的初始化默认值 + */ + function specialFormFieldInit(field: FormCreateField) { + if ( + [FormDesignKeyEnum.FOLLOW_PLAN_CUSTOMER, FormDesignKeyEnum.FOLLOW_RECORD_CUSTOMER].includes(props.formKey) && + props.sourceId?.value + ) { + // 客户跟进计划和记录,需要赋予类型字段默认为客户,客户字段默认值为当前客户 + if (field.businessKey === 'type') { + return { + defaultValue: 'CUSTOMER', + initialOptions: field.initialOptions, + }; + } + if (field.businessKey === 'customerId') { + specialInitialOptions.value = [ + { + id: props.sourceId?.value, + name: sourceName.value || props.initialSourceName, + }, + ]; + return { + defaultValue: initFieldValue(field, props.sourceId?.value || ''), + initialOptions: specialInitialOptions.value, + }; + } + } + if ( + [FormDesignKeyEnum.FOLLOW_PLAN_CLUE, FormDesignKeyEnum.FOLLOW_RECORD_CLUE].includes(props.formKey) && + props.sourceId?.value + ) { + // 线索跟进计划和记录,需要赋予类型字段默认为客户,线索字段默认值为当前线索 + if (field.businessKey === 'type') { + return { + defaultValue: 'CLUE', + initialOptions: field.initialOptions, + }; + } + if (field.businessKey === 'clueId') { + specialInitialOptions.value = [ + { + id: props.sourceId?.value, + name: sourceName.value || props.initialSourceName, + }, + ]; + return { + defaultValue: initFieldValue(field, props.sourceId?.value || ''), + initialOptions: specialInitialOptions.value, + }; + } + } + + if ( + [FormDesignKeyEnum.FOLLOW_PLAN_BUSINESS, FormDesignKeyEnum.FOLLOW_RECORD_BUSINESS].includes(props.formKey) && + props.sourceId?.value + ) { + // 商机跟进计划和记录,需要赋予默认跟进类型、商机、商机对应客户 + if (field.businessKey === 'type') { + return { + defaultValue: 'CUSTOMER', + initialOptions: field.initialOptions, + }; + } + + const defaultParsedSource = props.initialSourceName ? JSON.parse(props.initialSourceName) : {}; + if (Object.keys(defaultParsedSource).length) { + if (field.businessKey === 'opportunityId') { + specialInitialOptions.value = [ + { + id: props.sourceId?.value, + name: defaultParsedSource?.name ?? '', + }, + ]; + return { + defaultValue: initFieldValue(field, props.sourceId?.value || ''), + initialOptions: specialInitialOptions.value, + }; + } + + if (field.businessKey === 'customerId') { + const defaultCustomerId = defaultParsedSource?.[field.businessKey] ?? ''; + specialInitialOptions.value = [ + { + id: defaultCustomerId, + name: defaultParsedSource?.customerName ?? '', + }, + ]; + + return { + defaultValue: initFieldValue(field, defaultCustomerId || ''), + initialOptions: specialInitialOptions.value, + }; + } + } + } + if (props.formKey === FormDesignKeyEnum.CUSTOMER_CONTACT && props.sourceId?.value) { + // 联系人表单,赋予客户字段默认值为当前客户 + if (field.businessKey === 'customerId') { + specialInitialOptions.value = [ + { + id: props.sourceId?.value, + name: sourceName.value || props.initialSourceName, + }, + ]; + return { + defaultValue: initFieldValue(field, props.sourceId?.value || ''), + initialOptions: specialInitialOptions.value, + }; + } + } + if ([FormDesignKeyEnum.CLUE_TRANSITION_CUSTOMER].includes(props.formKey)) { + // 线索转客户带入名称 + if (field.businessKey === 'name') { + return { + defaultValue: props.initialSourceName, + initialOptions: field.initialOptions, + }; + } + } + if ([FieldTypeEnum.DATA_SOURCE, FieldTypeEnum.DATA_SOURCE_MULTIPLE].includes(field.type)) { + // 数据源类型的字段,默认值需要转为数组 + return { + defaultValue: typeof field.defaultValue === 'string' ? [field.defaultValue] : field.defaultValue, + initialOptions: field.initialOptions, + }; + } + return { + defaultValue: field.defaultValue, + initialOptions: field.initialOptions, + }; + } + + function fillLinkFormFieldValue(field: FormCreateField, scenario: FormLinkScenarioEnum) { + if (props.linkFormKey?.value) { + const linkFieldId = formConfig.value.linkProp?.[props.linkFormKey.value] + ?.find((e) => e.key === scenario) + ?.linkFields?.find((e) => e.current === field.id && e.enable)?.link; + if (linkFieldId) { + const linkField = props.linkFormInfo?.value?.[linkFieldId]; + if (linkField) { + switch (true) { + case dataSourceTypes.includes(field.type): + // 数据源填充,且替换initialOptions + field.initialOptions = linkField.initialOptions || []; + formDetail.value[field.id] = linkField.value.map((e: Record) => e.id); + break; + case multipleTypes.includes(field.type): + // 多选填充 + if (field.type === FieldTypeEnum.INPUT_MULTIPLE) { + // 标签直接填充 + formDetail.value[field.id] = Array.isArray(linkField.value) + ? linkField.value.slice(0, 10) + : [linkField.value]; + } else { + // 其他多选类型需匹配名称相等的选项值 + formDetail.value[field.id] = + field.options?.filter((e) => linkField.value.includes(e.label)).map((e) => e.value) || []; + } + break; + case singleTypes.includes(field.type): + // 单选填充需要匹配名称相同的选项值 + formDetail.value[field.id] = field.options?.find((e) => e.label === linkField.value)?.value || ''; + break; + case linkAllAcceptTypes.includes(field.type): + // 文本输入类型可填充任何字段类型值 + if (dataSourceTypes.includes(linkField.type)) { + // 联动的字段是数据源则填充选项名 + formDetail.value[field.id] = linkField.value + .map((e: Record) => e.name) + .join(',') + .slice(0, 255); + } else if (multipleTypes.includes(linkField.type)) { + // 联动的字段是多选则拼接选项名 + formDetail.value[field.id] = linkField.value.join(',').slice(0, 255); + } else if (linkField.type === FieldTypeEnum.DATE_TIME) { + // 联动的字段是日期时间则转换 + if (linkField.dateType === 'month') { + formDetail.value[field.id] = dayjs(linkField.value).format('YYYY-MM'); + } else if (linkField.dateType === 'date') { + formDetail.value[field.id] = dayjs(linkField.value).format('YYYY-MM-DD'); + } else { + formDetail.value[field.id] = dayjs(linkField.value).format('YYYY-MM-DD HH:mm:ss'); + } + } else if (linkField.type === FieldTypeEnum.LOCATION) { + // 联动的字段是省市区则填充城市路径 + const addressArr: string[] = linkField.value.split('-') || []; + formDetail.value[field.id] = addressArr.length + ? `${getCityPath(addressArr[0])}-${addressArr.filter((e, i) => i > 0).join('-')}` + : '-'; + } else if (linkField.type === FieldTypeEnum.TEXTAREA && field.type === FieldTypeEnum.INPUT) { + formDetail.value[field.id] = linkField.value.slice(0, 255); + } else if ([...memberTypes, ...departmentTypes].includes(linkField.type)) { + formDetail.value[field.id] = linkField.initialOptions + .map((e: any) => e.name) + .join(',') + .slice(0, 255); + } else if (linkField.type === FieldTypeEnum.INPUT_NUMBER) { + formDetail.value[field.id] = linkField.value?.toString(); + } else { + formDetail.value[field.id] = linkField.value; + } + break; + case [...memberTypes, ...departmentTypes].includes(field.type): + formDetail.value[field.id] = Array.isArray(linkField.value) ? linkField.value : [linkField.value]; + field.initialOptions = linkField.initialOptions || []; + break; + default: + formDetail.value[field.id] = linkField.value; + field.initialOptions = linkField.initialOptions || []; + break; + } + } + } + } + } + + async function initFormConfig() { + try { + loading.value = true; + const res = await getFormConfigApiMap[props.formKey](); + formConfig.value = res.formProp; + fieldList.value = res.fields.map((item) => { + const { defaultValue, initialOptions } = specialFormFieldInit(item); + if (item.showControlRules?.length) { + // 将字段的控制显隐规则存储到 fieldShowControlMap 中 + item.showControlRules?.forEach((rule) => { + rule.fieldIds.forEach((fieldId) => { + // 按字段 ID 存储规则,key 为字段 ID,value 为规则映射集合 + if (!fieldShowControlMap.value[fieldId]) { + fieldShowControlMap.value[fieldId] = {}; + } + // value 映射以控制显示隐藏的字段 id 为 key,字段值为 value 集合 + if (!fieldShowControlMap.value[fieldId][item.id]) { + fieldShowControlMap.value[fieldId][item.id] = []; + } + /** + * 最终结构为: + * fieldShowControlMap.value = { + * [fieldId]: { + * [item.id]: [rule.value] + * } + * } + * 这样最外层存储每个字段的 key,value 为该字段的所有的控制规则集合 + */ + fieldShowControlMap.value[fieldId][item.id].push(rule.value); + }); + }); + } + return { + ...item, + defaultValue, + initialOptions, + fieldWidth: safeFractionConvert(item.fieldWidth), + }; + }); + nextTick(() => { + unsaved.value = false; + }); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } finally { + loading.value = false; + } + } + + async function saveForm(form: Record, callback?: () => void) { + try { + loading.value = true; + const params: Record = { + ...props.otherSaveParams, + moduleFields: [], + id: props.sourceId?.value, + }; + fieldList.value.forEach((item) => { + if (item.businessKey) { + // 存在业务字段,则按照业务字段的key存储 + params[item.businessKey] = getNormalFieldValue(item, form[item.id]); + } else { + params.moduleFields.push({ + fieldId: item.id, + fieldValue: form[item.id], + }); + } + }); + if (props.sourceId?.value && props.needInitDetail) { + await updateFormApi[props.formKey](params); + showSuccessToast(t('common.updateSuccess')); + } else { + await createFormApi[props.formKey](params); + if (props.formKey === FormDesignKeyEnum.CLUE_TRANSITION_CUSTOMER) { + showSuccessToast(t('clue.transferredToCustomer')); + } else { + showSuccessToast(t('common.createSuccess')); + } + } + if (callback) { + await sleep(300); + callback(); + } + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } finally { + loading.value = false; + } + } + + const formCreateTitle = computed(() => { + if (props.formKey === FormDesignKeyEnum.CLUE_TRANSITION_CUSTOMER) { + return t('common.convertToCustomer'); + } + const prefix = props.sourceId?.value && props.needInitDetail ? t('common.edit') : t('common.create'); + return `${prefix}${t(`common.${props.formKey}`)}`; + }); + + return { + descriptions, + fieldList, + loading, + unsaved, + formConfig, + formDetail, + originFormDetail, + formCreateTitle, + collaborationType, + sourceName, + initFormDescription, + initFormConfig, + initFormDetail, + saveForm, + initFormShowControl, + fillLinkFormFieldValue, + detail, + }; +} diff --git a/frontend/packages/mobile/src/hooks/useFormCreateTransform.ts b/frontend/packages/mobile/src/hooks/useFormCreateTransform.ts new file mode 100644 index 0000000..798480c --- /dev/null +++ b/frontend/packages/mobile/src/hooks/useFormCreateTransform.ts @@ -0,0 +1,147 @@ +import { FieldTypeEnum, FormDesignKeyEnum } from '@lib/shared/enums/formDesignEnum'; +import { useI18n } from '@lib/shared/hooks/useI18n'; +import { getCityPath } from '@lib/shared/method'; +import type { ModuleField } from '@lib/shared/models/common'; + +import { getFormConfigApiMap } from '@cordys/web/src/components/business/crm-form-create/config'; +import type { FormCreateField } from '@cordys/web/src/components/business/crm-form-create/types'; + +export default async function useFormCreateTransform(formKey: FormDesignKeyEnum) { + const { t } = useI18n(); + + const fieldList = ref([]); + // 存储地址类型字段集合 + const addressFieldIds = ref([]); + // 业务字段集合 + const businessFieldIds = ref([]); + // 数据源字段集合 + const dataSourceFieldIds = ref([]); + + async function initFormConfig() { + try { + const res = await getFormConfigApiMap[formKey](); + fieldList.value = res.fields.map((field) => { + if (field.type === FieldTypeEnum.LOCATION) { + addressFieldIds.value.push(field.businessKey || field.id); + } else if ([FieldTypeEnum.DATA_SOURCE, FieldTypeEnum.DATA_SOURCE_MULTIPLE].includes(field.type)) { + dataSourceFieldIds.value.push(field.businessKey || field.id); + } + if (field.businessKey) { + businessFieldIds.value.push(field.businessKey); + } + return { + ...field, + }; + }); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } + } + + await initFormConfig(); + + const descriptionMap: Partial> = { + [FormDesignKeyEnum.CUSTOMER]: ['owner', 'reservedDays'], + [FormDesignKeyEnum.CLUE]: ['owner', 'reservedDays'], + }; + const descriptionKeys = descriptionMap[formKey] || []; + // 系统内置字段 + const systemFieldMap: Record = { + reservedDays: t('common.remainingBelong'), + }; + + /** + * 转换特殊字段值 + * @param key 字段 id 标识 + * @param value 字段值 + */ + function specialDescriptionValueTransform(key: string, value: string | number) { + if (key === 'reservedDays') { + // 处理剩余归属天数 + return Number.isNaN(Number(value)) || value === null ? '-' : `${value}${t('common.dayUnit')}`; + } + return value; + } + + function transformFormData(data: Record, optionMap?: Record) { + let formData: Record = { ...data, description: [] }; + const businessFieldAttr: Record = {}; + const customFieldAttr: Record = {}; + businessFieldIds.value.forEach((fieldId) => { + const options = optionMap?.[fieldId]; + let name: string | string[] = ''; + if (dataSourceFieldIds.value.includes(fieldId)) { + if (typeof data[fieldId] === 'string') { + name = [options?.find((e) => e.id === data[fieldId])?.name || t('common.optionNotExist')]; + } else { + name = + options?.filter((e) => data[fieldId]?.includes(e.id)).map((e) => e.name || t('common.optionNotExist')) || + []; + } + } else if (options?.find((e) => e.id === data[fieldId])) { + name = options?.find((e) => e.id === data[fieldId]).name || t('common.optionNotExist'); + } + if (name) { + businessFieldAttr[fieldId] = name; + } else if (addressFieldIds.value.includes(fieldId)) { + // 地址类型字段,解析代码替换成省市区 + const addressArr: string[] = data[fieldId]?.split('-') || []; + const value = addressArr.length + ? `${getCityPath(addressArr[0])}-${addressArr.filter((e, i) => i > 0).join('-')}` + : '-'; + businessFieldAttr[fieldId] = value; + } + }); + data.moduleFields?.forEach((field: ModuleField) => { + const options = optionMap?.[field.fieldId]; + if (options) { + // 若字段值是选项值,则取选项值的name + if (Array.isArray(field.fieldValue)) { + customFieldAttr[field.fieldId] = options.filter((e) => field.fieldValue.includes(e.id)).map((e) => e.name); + } else { + customFieldAttr[field.fieldId] = options.find((e) => e.id === field.fieldValue)?.name; + } + } else if (addressFieldIds.value.includes(field.fieldId)) { + // 地址类型字段,解析代码替换成省市区 + const addressArr: string[] = (field?.fieldValue as string)?.split('-') || []; + const value = addressArr.length + ? `${getCityPath(addressArr[0])}-${addressArr.filter((e, i) => i > 0).join('-')}` + : '-'; + customFieldAttr[field.fieldId] = value; + } else { + customFieldAttr[field.fieldId] = field.fieldValue; + } + // 数据源字段赋值 + if (dataSourceFieldIds.value.includes(field.fieldId)) { + customFieldAttr[field.fieldId] = Array.isArray(field.fieldValue) ? field.fieldValue : [field.fieldValue]; + } + }); + formData = { + ...formData, + ...businessFieldAttr, + ...customFieldAttr, + }; + descriptionKeys.forEach((key) => { + const item = fieldList.value.find((field) => field.businessKey === key || field.id === key); + if (item) { + formData.description.push({ + label: item.name, + value: formData[key] || '-', + }); + } else if (systemFieldMap[key]) { + formData.description.push({ + label: systemFieldMap[key], + value: specialDescriptionValueTransform(key, formData[key]), + }); + } + }); + + return formData; + } + + return { + fieldList, + transformFormData, + }; +} diff --git a/frontend/packages/mobile/src/hooks/useHiddenTab.ts b/frontend/packages/mobile/src/hooks/useHiddenTab.ts new file mode 100644 index 0000000..be5a648 --- /dev/null +++ b/frontend/packages/mobile/src/hooks/useHiddenTab.ts @@ -0,0 +1,56 @@ +import { FormDesignKeyEnum } from '@lib/shared/enums/formDesignEnum'; +import type { CustomerTabHidden } from '@lib/shared/models/customer'; + +import { getClueTab, getCustomerContactTab, getCustomerTab, getOptTab } from '@/api/modules'; + +export interface TabPaneProps { + name: string; + tab: string; +} + +export type TabType = + | FormDesignKeyEnum.CUSTOMER + | FormDesignKeyEnum.BUSINESS + | FormDesignKeyEnum.CLUE + | FormDesignKeyEnum.CONTACT; +export default function useHiddenTab(tabData: TabPaneProps[], type?: TabType) { + const activeFilter = ref(); + + const tabApiMap: Record Promise> = { + [FormDesignKeyEnum.CUSTOMER]: getCustomerTab, + [FormDesignKeyEnum.CONTACT]: getCustomerContactTab, + [FormDesignKeyEnum.BUSINESS]: getOptTab, + [FormDesignKeyEnum.CLUE]: getClueTab, + }; + + const tabList = ref([]); + + async function initTab() { + try { + if (!type) return; + const result = await tabApiMap[type](); + const { all, dept } = result; + tabList.value = tabData.filter((e) => { + if (e.name === 'ALL') return !!all; + if (e.name === 'DEPARTMENT') return !!dept; + return true; + }); + + nextTick(() => { + activeFilter.value = tabList.value[0]?.name; + }); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } + } + + onBeforeMount(() => { + initTab(); + }); + + return { + tabList, + activeFilter, + }; +} diff --git a/frontend/packages/mobile/src/hooks/useLogin.ts b/frontend/packages/mobile/src/hooks/useLogin.ts new file mode 100644 index 0000000..d0deb0b --- /dev/null +++ b/frontend/packages/mobile/src/hooks/useLogin.ts @@ -0,0 +1,158 @@ +import { useRouter } from 'vue-router'; +import { AxiosResponse } from 'axios'; + +import { CompanyTypeEnum } from '@lib/shared/enums/commonEnum'; +import { + getQueryVariable, + getUrlParameterWidthRegExp, + isDingTalkBrowser, + isLarkBrowser, + isWeComBrowser, +} from '@lib/shared/method'; +import { setLoginExpires, setLoginType } from '@lib/shared/method/auth'; +import { ThirdPartyResourceConfig } from '@lib/shared/models/system/business'; +import type { Result } from '@lib/shared/types/axios'; + +import { getThirdConfigByType, getThirdOauthCallback } from '@/api/modules'; +import { AUTH_DISABLED_ROUTE_NAME } from '@/router/constants'; +import useUserStore from '@/store/modules/user'; + +import { AppRouteEnum } from '@/enums/routeEnum'; + +const platformConfig = { + // 企业微信 + 'wecom': { + detect: isWeComBrowser, + platformConfigType: CompanyTypeEnum.WECOM, + authLoginType: CompanyTypeEnum.WE_COM_OAUTH2, + type: 'wecom', + codeKey: 'code', + codeKeysParams: ['code', 'state'], + authUrl: (config: Record) => { + const redirectUrl = `${window.location.origin}/mobile`; + return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${ + config.corpId + }&response_type=code&redirect_uri=${encodeURIComponent(redirectUrl)}&scope=snsapi_privateinfo&agentid=${ + config.agentId + }#wechat_redirect`; + }, + }, + // 钉钉 + 'ding-talk': { + detect: isDingTalkBrowser, + platformConfigType: CompanyTypeEnum.DINGTALK, + authLoginType: CompanyTypeEnum.DINGTALK_OAUTH2, + type: 'ding-talk', + codeKey: 'authCode', + codeKeysParams: ['code', 'authCode', 'state'], + authUrl: (config: Record) => { + const redirectUrl = `${window.location.origin}/mobile`; + return `https://login.dingtalk.com/oauth2/auth?redirect_uri=${encodeURIComponent( + redirectUrl + )}&response_type=code&client_id=${config.agentId}&scope=openid corpid&state=ding&prompt=consent&corpid=${ + config.corpId + }`; + }, + }, + 'lark': { + detect: isLarkBrowser, + platformConfigType: CompanyTypeEnum.LARK, + authLoginType: CompanyTypeEnum.LARK_OAUTH2, + type: 'lark-mobile', + codeKey: 'code', + codeKeysParams: ['code', 'state'], + authUrl: (config: Record) => { + const redirectUrl = `${window.location.origin}/mobile`; + return `https://open.feishu.cn/open-apis/authen/v1/authorize?app_id=${ + config.agentId + }&redirect_uri=${encodeURIComponent(redirectUrl)}&state=LARK`; + }, + }, +} as const; + +export default function useLogin() { + const userStore = useUserStore(); + const router = useRouter(); + + async function thirdAuthLogin(code: string, type: string, loginType: CompanyTypeEnum) { + try { + const res = await getThirdOauthCallback(code, type); + const success = userStore.setLoginInfo(res.data.data); + if (success) { + setLoginExpires(); + setLoginType(loginType); + const { redirect, ...othersQuery } = router.currentRoute.value.query; + await router.replace({ + name: (redirect as string) || AppRouteEnum.WORKBENCH, + query: { + ...othersQuery, + }, + }); + } + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + const err = error as Result; + if (err.code === 100500) { + router.replace({ name: 'login' }); + } else if (err.code === 401) { + router.replace(AUTH_DISABLED_ROUTE_NAME); + } + } + } + + async function handleThirdAuthLogin(platformKey: keyof typeof platformConfig) { + const platform = platformConfig[platformKey]; + const code = getQueryVariable(platform.codeKey) ?? ''; + if (code) { + await thirdAuthLogin(code, platform.type, platform.authLoginType); + + // 清理参数 + const url = new URL(window.location.href); + platform.codeKeysParams.forEach((param) => { + getUrlParameterWidthRegExp(param); + url.searchParams.delete(param); + }); + // 或者在不刷新页面的情况下更新URL(比如使用 History API) + window.history.replaceState({}, document.title, url.toString()); + return; + } + + const res = await getThirdConfigByType>>( + platform.platformConfigType, + true + ); + if (res) { + const { data } = res.data; + const redirectUrl = platform.authUrl(data.config); + window.location.replace(redirectUrl); + } + } + + async function oAuthLogin() { + try { + const platformKey = Object.keys(platformConfig).find((key) => + platformConfig[key as keyof typeof platformConfig].detect() + ) as keyof typeof platformConfig | undefined; + // 没有检测到三方平台企业微信、钉钉浏览器直接跳转到登录 + if (!platformKey) { + return router.replace({ name: 'login' }); + } + // 检测到任一平台走第三方认证登录 + await handleThirdAuthLogin(platformKey); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + const err = error as Result; + if (err.code === 100500) { + router.replace({ name: 'login' }); + } else if (err.code === 401) { + router.replace(AUTH_DISABLED_ROUTE_NAME); + } + } + } + + return { + oAuthLogin, + }; +} diff --git a/frontend/packages/mobile/src/hooks/useUser.ts b/frontend/packages/mobile/src/hooks/useUser.ts new file mode 100644 index 0000000..4a47eac --- /dev/null +++ b/frontend/packages/mobile/src/hooks/useUser.ts @@ -0,0 +1,42 @@ +import { showToast } from 'vant'; + +import { useI18n } from '@lib/shared/hooks/useI18n'; + +import router from '@/router'; +import { WHITE_LIST } from '@/router/constants'; +import useUserStore from '@/store/modules/user'; + +export default function useUser() { + const { t } = useI18n(); + + const logout = async (logoutTo?: string, _noRedirect?: boolean, silence = false) => { + try { + const userStore = useUserStore(); + await userStore.logout(); + if (!silence) { + showToast(t('message.logoutSuccess')); + router.replace({ + name: logoutTo && typeof logoutTo === 'string' ? logoutTo : 'login', + }); + } + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } + }; + + const isLoginPage = () => { + return window.location.hash.indexOf('login') > -1; + }; + + const isWhiteListPage = () => { + const currentRoute = router.currentRoute.value; + return WHITE_LIST.some((e) => e.path.includes(currentRoute.path)); + }; + + return { + logout, + isLoginPage, + isWhiteListPage, + }; +} diff --git a/frontend/packages/mobile/src/layout/default-layout.vue b/frontend/packages/mobile/src/layout/default-layout.vue new file mode 100644 index 0000000..0cfe079 --- /dev/null +++ b/frontend/packages/mobile/src/layout/default-layout.vue @@ -0,0 +1,228 @@ + + + + + + + diff --git a/frontend/packages/mobile/src/locale/en-US/common.ts b/frontend/packages/mobile/src/locale/en-US/common.ts new file mode 100644 index 0000000..1385dfe --- /dev/null +++ b/frontend/packages/mobile/src/locale/en-US/common.ts @@ -0,0 +1,101 @@ +export default { + 'common.newCustomer': 'New Account', + 'common.editCustomer': 'Update Account', + 'common.newContact': 'New Contact', + 'common.editContact': 'Update Contact', + 'common.newOpportunity': 'New Opt', + 'common.editOpportunity': 'Update Opportunity', + 'common.newClue': 'New Lead', + 'common.editClue': 'Update Lead', + 'common.record': 'Follow Record', + 'common.newFollowRecord': 'New Follow Record', + 'common.plan': 'Follow Plan', + 'common.newFollowPlan': 'New Follow Plan', + 'common.headerRecord': 'Header Record', + 'common.listLoadErrorTip': 'Request failed, click to reload', + 'common.listFinishedTip': 'Already at the bottom~', + 'common.message': 'Notification', + 'common.checkMore': 'Check More', + 'common.more': 'More', + 'common.system': 'System', + 'common.systemNotification': 'System Notification', + 'common.signRead': 'Sign Read', + 'common.noData': 'No Data', + 'common.edit': 'Edit', + 'common.writeRecord': 'Write Follow Record', + 'common.transfer': 'Transfer', + 'common.delete': 'Delete', + 'common.deleteSuccess': 'Delete Success', + 'common.pick': 'Pick', + 'common.picking': 'Picking...', + 'common.pickSuccess': 'Pick Success', + 'common.distribute': 'Distribute', + 'common.distributeSuccess': 'Distribute Success', + 'common.normal': 'Normal', + 'common.abnormal': 'Abnormal', + 'common.pleaseInput': 'Please Input', + 'common.pleaseSelect': 'Please Select', + 'common.create': 'Create', + 'common.createSuccess': 'Create Success', + 'common.add': 'Add', + 'common.addSuccess': 'Add Success', + 'common.update': 'Update', + 'common.updateSuccess': 'Update Success', + 'common.cancel': 'Cancel', + 'common.cancelPlan': 'Cancel Plan', + 'common.cancelSuccess': 'Cancel Success', + 'common.pleaseInputKeyword': 'Please Input Keyword', + 'common.customer': 'Account', + 'common.clue': 'Lead', + 'common.opportunity': 'Opportunity', + 'common.recordBusiness': 'Follow-up record', + 'common.planBusiness': 'Follow-up plan', + 'common.recordClue': 'Follow-up record', + 'common.planClue': 'Follow-up plan', + 'common.convertToCustomer': 'Convert to account', + 'common.confirm': 'Confirm', + 'common.notNull': '{value} cannot be empty', + 'common.required': 'Required', + 'common.onlyOne': 'Not allowed to repeat value', + 'common.contact': 'Contact', + 'common.customerContact': 'Contact', + 'common.confirmDelete': 'Confirm delete', + 'common.copySuccess': 'Copy success', + 'common.copyNotSupport': 'Current device does not support copy, please copy manually', + 'common.phoneNumber': 'Phone number', + 'common.announcementMessage': 'Announcement', + 'common.remainingBelong': 'Remaining belong', + 'common.refreshSuccess': 'Refresh success', + 'common.searching': 'Searching...', + 'common.transferring': 'Transferring...', + 'common.deleting': 'Deleting...', + 'common.loading': 'Loading...', + 'common.dayUnit': 'Days', + 'common.transferSuccess': 'Transfer Success', + 'common.transferFailed': 'Transfer failed', + 'common.confirmMoveIn': 'Confirm move in', + 'common.updateToCurrentProgress': 'Update to current progress', + 'common.success': 'Success', + 'common.fail': 'Error', + 'common.updating': 'Updating...', + 'common.operationSuccess': 'Operation success', + 'common.followFailed': 'Follow failed', + 'common.result': 'Result', + 'common.notStarted': 'Not started', + 'common.inProgress': 'In progress', + 'common.completed': 'Completed', + 'common.canceled': 'Canceled', + 'common.detail': 'Detail', + 'common.communicationContent': 'Communication content', + 'common.head': 'Head', + 'common.createTime': 'Create time', + 'common.login': 'Login...', + 'common.searchByName': 'Search by name', + 'common.optionNotExist': 'Option does not exist', + 'common.open': 'Open', + 'common.close': 'Closed', + 'common.convertPlanToRecord': 'Plan transfer record', + 'common.hasConvertToRecord': 'Have record', + 'common.moveInReason': 'Move-in reason', + 'common.noReason': 'Reason for migration not configured', +}; diff --git a/frontend/packages/mobile/src/locale/en-US/index.ts b/frontend/packages/mobile/src/locale/en-US/index.ts new file mode 100644 index 0000000..e162cbd --- /dev/null +++ b/frontend/packages/mobile/src/locale/en-US/index.ts @@ -0,0 +1,37 @@ +import common from './common'; +import sys from './sys'; +import dayjsLocale from 'dayjs/locale/en'; + +const _Cmodules: any = import.meta.glob('../../components/**/locale/en-US.ts', { eager: true }); +const _Vmodules: any = import.meta.glob('../../views/**/locale/en-US.ts', { eager: true }); +let result = {}; +Object.keys(_Cmodules).forEach((key) => { + const defaultModule = _Cmodules[key as any].default; + if (!defaultModule) return; + result = { ...result, ...defaultModule }; +}); +Object.keys(_Vmodules).forEach((key) => { + const defaultModule = _Vmodules[key as any].default; + if (!defaultModule) return; + result = { ...result, ...defaultModule }; +}); + +export default { + message: { + 'menu.workbench': 'Home', + 'menu.duplicateCheck': 'Duplicate Check', + 'menu.opportunity': 'Opportunity', + 'menu.clue': 'Lead', + 'menu.cluePool': 'Lead Pool', + 'menu.customer': 'Account', + 'menu.openSea': 'Open Sea', + 'menu.contact': 'Contact', + 'menu.mine': 'Mine', + 'navbar.action.locale': 'Switch to English', + ...sys, + ...result, + ...common, + }, + dayjsLocale, + dayjsLocaleName: 'en-US', +}; diff --git a/frontend/packages/mobile/src/locale/en-US/sys.ts b/frontend/packages/mobile/src/locale/en-US/sys.ts new file mode 100644 index 0000000..c2dcd40 --- /dev/null +++ b/frontend/packages/mobile/src/locale/en-US/sys.ts @@ -0,0 +1,43 @@ +export default { + // 消息提醒相关 + 'message.errorTip': 'Error Tip', + 'message.loggingOut': 'Logging out...', + 'message.loginExpired': 'Login expired, please login again!', + 'message.logoutSuccess': 'Logout successful', + 'message.loadingDefaultTip': 'Loading...', + 'message.menuLoading': 'Loading the menu...', + 'message.menuLoadSuccess': 'Menu loaded successfully', + 'message.menuLoadError': 'Menu load failed', + // API相关 + 'api.apiTimeoutMessage': 'The interface request timed out, please refresh the page and try again!', + 'api.apiRequestFailed': 'The interface request failed, please try again later!', + 'api.networkException': 'network anomaly', + 'api.networkExceptionMsg': 'Please check if your network connection is normal! The network is abnormal', + 'api.responseError': 'Interface returns an error, please try again', + 'api.requestError': 'Request Error', + 'api.errMsg401': 'The user does not have permission (token, user name, password error)!', + 'api.errMsg403': 'No access rights, please contact the administrator', + 'api.errMsg404': 'Network request error, the resource was not found!', + 'api.errMsg405': 'Network request error, request method not allowed!', + 'api.errMsg408': 'Network request timed out!', + 'api.errMsg500': 'Server error, please contact the administrator!', + 'api.errMsg501': 'The network is not implemented!', + 'api.errMsg502': 'Network Error!', + 'api.errMsg503': 'The service is unavailable, the server is temporarily overloaded or maintained!', + 'api.errMsg504': 'Network timeout!', + 'api.errMsg505': 'The http version does not support the request!', + 'api.authDisabled': 'The application is disabled!', + // 异步任务提示 + 'asyncTask.uploadFileProgress': 'File upload progress {percent}; {done} successful, {fail} failed', + 'asyncTask.uploadFileSuccess': 'File upload completed: {done} successfully, {fail} failed', + 'asyncTask.uploadFileSuccessTitle': 'Upload completed', + // 通用业务提示 + 'user.openSourceCreateUsersLimit': + 'The maximum number of system users has reached 30 (Community Edition). If you need to add/enable more users, you can apply', + 'user.businessTry': 'Enterprise Edition Trial', + 'user.businessCreateUsersLimitThirty': + 'The maximum number of system users has reached 30 (Community Edition). If you need to add/enable more users, you can apply', + 'user.businessCreateUsersLimitMax': + 'The number of system users has reached the maximum number of user subscriptions {count}. If you want to add/enable more users, you can apply', + 'user.businessScaling': 'Enterprise Edition Capacity Expansion', +}; diff --git a/frontend/packages/mobile/src/locale/zh-CN/common.ts b/frontend/packages/mobile/src/locale/zh-CN/common.ts new file mode 100644 index 0000000..f9f618a --- /dev/null +++ b/frontend/packages/mobile/src/locale/zh-CN/common.ts @@ -0,0 +1,102 @@ +export default { + 'common.newCustomer': '新建客户', + 'common.editCustomer': '更新客户', + 'common.newContact': '新建联系人', + 'common.editContact': '更新联系人', + 'common.newOpportunity': '新建商机', + 'common.editOpportunity': '更新商机', + 'common.newClue': '新建线索', + 'common.editClue': '更新线索', + 'common.record': '跟进记录', + 'common.newFollowRecord': '写跟进', + 'common.plan': '跟进计划', + 'common.newFollowPlan': '写计划', + 'common.headerRecord': '负责人记录', + 'common.listLoadErrorTip': '请求失败,点击重新加载', + 'common.listFinishedTip': '已经到底部啦~', + 'common.message': '消息通知', + 'common.checkMore': '查看更多', + 'common.more': '更多', + 'common.system': '系统', + 'common.systemNotification': '系统通知', + 'common.signRead': '标为已读', + 'common.noData': '暂无数据', + 'common.edit': '编辑', + 'common.writeRecord': '写跟进', + 'common.transfer': '转移', + 'common.delete': '删除', + 'common.deleteSuccess': '删除成功', + 'common.pick': '领取', + 'common.picking': '领取中...', + 'common.pickSuccess': '领取成功', + 'common.distribute': '分配', + 'common.distributeSuccess': '分配成功', + 'common.normal': '正常', + 'common.abnormal': '异常', + 'common.pleaseInput': '请输入', + 'common.pleaseSelect': '请选择', + 'common.create': '新建', + 'common.createSuccess': '新建成功', + 'common.add': '添加', + 'common.addSuccess': '添加成功', + 'common.update': '更新', + 'common.updateSuccess': '更新成功', + 'common.cancel': '取消', + 'common.cancelPlan': '取消计划', + 'common.cancelSuccess': '取消成功', + 'common.pleaseInputKeyword': '请输入关键词', + 'common.customer': '客户', + 'common.clue': '线索', + 'common.opportunity': '商机', + 'common.recordBusiness': '跟进记录', + 'common.planBusiness': '跟进计划', + 'common.recordClue': '跟进记录', + 'common.planClue': '跟进计划', + 'common.convertToCustomer': '转为客户', + 'common.confirm': '确认', + 'common.notNull': '{value}不能为空', + 'common.required': '必填', + 'common.onlyOne': '不允许重复值', + 'common.contact': '联系人', + 'common.customerContact': '联系人', + 'common.confirmDelete': '确认删除', + 'common.copySuccess': '复制成功', + 'common.copyNotSupport': '当前设备不支持复制,请手动复制', + 'common.phoneNumber': '手机号', + 'common.announcementMessage': '公告', + 'common.remainingBelong': '剩余归属', + 'common.refreshSuccess': '刷新成功', + 'common.searching': '搜索中...', + 'common.to': '至', + 'common.transferring': '转移中...', + 'common.deleting': '删除中...', + 'common.loading': '加载中...', + 'common.dayUnit': '天', + 'common.transferSuccess': '转移成功', + 'common.transferFailed': '转移失败', + 'common.confirmMoveIn': '确认移入', + 'common.updateToCurrentProgress': '更新为当前进度', + 'common.success': '成功', + 'common.fail': '失败', + 'common.updating': '更新中...', + 'common.operationSuccess': '操作成功', + 'common.followFailed': '跟进失败', + 'common.result': '结果', + 'common.notStarted': '未开始', + 'common.inProgress': '进行中', + 'common.completed': '已完成', + 'common.canceled': '已取消', + 'common.detail': '详情', + 'common.communicationContent': '沟通内容', + 'common.head': '负责人', + 'common.createTime': '创建时间', + 'common.login': '登录中...', + 'common.searchByName': '通过名称搜索', + 'common.optionNotExist': '选项不存在', + 'common.open': '开启', + 'common.close': '关闭', + 'common.convertPlanToRecord': '计划转记录', + 'common.hasConvertToRecord': '已转记录', + 'common.moveInReason': '移入原因', + 'common.noReason': '未配置移入原因', +}; diff --git a/frontend/packages/mobile/src/locale/zh-CN/index.ts b/frontend/packages/mobile/src/locale/zh-CN/index.ts new file mode 100644 index 0000000..c7707b9 --- /dev/null +++ b/frontend/packages/mobile/src/locale/zh-CN/index.ts @@ -0,0 +1,36 @@ +import common from './common'; +import sys from './sys'; +import dayjsLocale from 'dayjs/locale/zh-cn'; + +const _Cmodules: any = import.meta.glob('../../components/**/locale/zh-CN.ts', { eager: true }); +const _Vmodules: any = import.meta.glob('../../views/**/locale/zh-CN.ts', { eager: true }); +let result = {}; +Object.keys(_Cmodules).forEach((key) => { + const defaultModule = _Cmodules[key as any].default; + if (!defaultModule) return; + result = { ...result, ...defaultModule }; +}); +Object.keys(_Vmodules).forEach((key) => { + const defaultModule = _Vmodules[key as any].default; + if (!defaultModule) return; + result = { ...result, ...defaultModule }; +}); +export default { + message: { + 'menu.workbench': '首页', + 'menu.duplicateCheck': '查重', + 'menu.opportunity': '商机', + 'menu.clue': '线索', + 'menu.cluePool': '线索池', + 'menu.customer': '客户', + 'menu.contact': '联系人', + 'menu.openSea': '公海', + 'menu.mine': '我的', + 'navbar.action.locale': '切换为中文', + ...sys, + ...result, + ...common, + }, + dayjsLocale, + dayjsLocaleName: 'zh-CN', +}; diff --git a/frontend/packages/mobile/src/locale/zh-CN/sys.ts b/frontend/packages/mobile/src/locale/zh-CN/sys.ts new file mode 100644 index 0000000..5fe7f79 --- /dev/null +++ b/frontend/packages/mobile/src/locale/zh-CN/sys.ts @@ -0,0 +1,40 @@ +export default { + // 消息提醒相关 + 'message.errorTip': '错误提示', + 'message.loggingOut': '正在退出登录...', + 'message.loginExpired': '登录过期,请重新登录!', + 'message.logoutSuccess': '登出成功', + 'message.loadingDefaultTip': '加载中...', + 'message.menuLoading': '菜单加载中...', + 'message.menuLoadSuccess': '菜单加载成功', + 'message.menuLoadError': '菜单加载失败', + // API相关 + 'api.apiTimeoutMessage': '接口请求超时,请刷新页面重试!', + 'api.apiRequestFailed': '请求出错,请稍候重试', + 'api.networkException': '网络异常', + 'api.networkExceptionMsg': '网络异常,请检查您的网络连接是否正常!', + 'api.responseError': '接口返回错误,请重试', + 'api.requestError': '请求错误', + 'api.errMsg401': '用户没有权限(令牌、用户名、密码错误)!', + 'api.errMsg403': '无权访问,请联系管理员!', + 'api.errMsg404': '网络请求错误,未找到该资源!', + 'api.errMsg405': '网络请求错误,请求方法未允许!', + 'api.errMsg408': '网络请求超时!', + 'api.errMsg500': '服务器错误,请联系管理员!', + 'api.errMsg501': '网络未实现!', + 'api.errMsg502': '网络错误!', + 'api.errMsg503': '服务不可用,服务器暂时过载或维护!', + 'api.errMsg504': '网络超时!', + 'api.errMsg505': 'http版本不支持该请求!', + 'api.authDisabled': '应用被禁用!', + // 异步任务提示 + 'asyncTask.uploadFileProgress': '文件上传进度 {percent};成功 {done} 个,失败 {fail} 个', + 'asyncTask.uploadFileSuccess': '文件上传完成:成功 {done} 个,失败 {fail} 个', + 'asyncTask.uploadFileSuccessTitle': '上传完成', + // 通用业务提示 + 'user.openSourceCreateUsersLimit': '系统用户数已达到最大用户数限制30人(社区版),如需添加/启用更多用户,可申请', + 'user.businessTry': '企业版试用', + 'user.businessCreateUsersLimitThirty': '系统用户数已达到最大用户数限制30人(社区版),如需添加/启用更多用户,可申请', + 'user.businessCreateUsersLimitMax': '系统用户数已达到最大用户订阅数 {count} 人,如需添加/启用更多用户,可申请', + 'user.businessScaling': '企业版扩容', +}; diff --git a/frontend/packages/mobile/src/main.ts b/frontend/packages/mobile/src/main.ts new file mode 100644 index 0000000..7222100 --- /dev/null +++ b/frontend/packages/mobile/src/main.ts @@ -0,0 +1,43 @@ +// eslint-disable-next-line simple-import-sort/imports +import { createApp } from 'vue'; +import { showLoadingToast } from 'vant'; + +import 'vant/es/toast/style'; // 解决toast样式被popup覆盖的问题 +import 'vant/es/dialog/style'; // 解决dialog样式被popup覆盖的问题 +import 'vant/es/image-preview/style'; // 解决image-preview样式被popup覆盖的问题 +import '@/assets/icon-font/iconfont'; +import '@/assets/icon-font/iconfont.css'; +import '@/assets/style/index.less'; + +import { setupI18n } from '@lib/shared/locale'; +import useLocale from '@lib/shared/locale/useLocale'; + +import App from './App.vue'; +import directive from './directive/index'; + +// eslint-disable-next-line import/no-unresolved +import 'virtual:svg-icons-register'; +import router from './router'; +import store from './store'; + +async function setupApp() { + const app = createApp(App); + + app.use(store); + // 注册国际化,需要异步阻塞,确保语言包加载完毕 + await setupI18n(app); + + // 获取默认语言 + const localLocale = localStorage.getItem('CRM-locale'); + if (!localLocale) { + // TODO 国际化接口对接 + // const defaultLocale = await getDefaultLocale(); + const { changeLocale } = useLocale(showLoadingToast); + changeLocale('zh-CN'); + } + app.use(router); + app.use(directive); + app.mount('#app'); +} + +setupApp(); diff --git a/frontend/packages/mobile/src/router/constants.ts b/frontend/packages/mobile/src/router/constants.ts new file mode 100644 index 0000000..8e6bf70 --- /dev/null +++ b/frontend/packages/mobile/src/router/constants.ts @@ -0,0 +1,28 @@ +// 404 路由 +export const NOT_FOUND = { + name: 'notFound', + path: '/not-found', + meta: { + requiresAuth: false, + }, +}; + +// 路由白名单,无需校验权限与登录状态 +export const WHITE_LIST = [NOT_FOUND]; + +// 重定向中转站路由 +export const REDIRECT_ROUTE_NAME = 'Redirect'; + +export const WHITE_LIST_NAME = WHITE_LIST.map((el) => el.name); + +// 无资源/权限路由 +export const NO_RESOURCE_ROUTE_NAME = 'no-resource'; + +// 企业微信认证被禁用路由 +export const AUTH_DISABLED_ROUTE_NAME = 'auth-disabled'; + +// 首页路由 +export const DEFAULT_ROUTE_NAME = 'workbench'; + +// 登录加载路由 +export const LOGIN_LOADING = 'loading'; diff --git a/frontend/packages/mobile/src/router/guard/index.ts b/frontend/packages/mobile/src/router/guard/index.ts new file mode 100644 index 0000000..99f72b1 --- /dev/null +++ b/frontend/packages/mobile/src/router/guard/index.ts @@ -0,0 +1,28 @@ +import { AxiosCanceler } from '@lib/shared/api/http/axiosCancel'; +import { setRouteEmitter } from '@lib/shared/method/route-listener'; + +import setupPermissionGuard from './permission'; +import setupUserLoginInfoGuard from './userLoginInfo'; +import NProgress from 'nprogress'; +import type { Router } from 'vue-router'; + +function setupPageGuard(router: Router) { + const axiosCanceler = new AxiosCanceler(); + router.beforeEach((to, from, next) => { + NProgress.start(); + // 监听路由变化 + setRouteEmitter(to); + // 取消上个路由未完成的请求(不包含设置了ignoreCancelToken的请求) + axiosCanceler.removeAllPending(); + next(); + }); +} + +export default function createRouteGuard(router: Router) { + // 设置路由监听守卫 + setupPageGuard(router); + // 设置用户登录校验守卫 + setupUserLoginInfoGuard(router); + // 设置菜单权限守卫 + setupPermissionGuard(router); +} diff --git a/frontend/packages/mobile/src/router/guard/permission.ts b/frontend/packages/mobile/src/router/guard/permission.ts new file mode 100644 index 0000000..e3efcfe --- /dev/null +++ b/frontend/packages/mobile/src/router/guard/permission.ts @@ -0,0 +1,23 @@ +import { NO_RESOURCE_ROUTE_NAME, WHITE_LIST } from '../constants'; +import usePermission from '@cordys/web/src/hooks/usePermission'; +import NProgress from 'nprogress'; +import type { Router } from 'vue-router'; + +export default function setupPermissionGuard(router: Router) { + router.beforeEach(async (to, from, next) => { + const Permission = usePermission(); + const permissionsAllow = Permission.accessRouter(to); + + // TODO 如果是隐藏的模块,则跳转到无权限页面 + + const exist = WHITE_LIST.find((el) => el.name === to.name); + if (exist || permissionsAllow) { + next(); + } else { + next({ + name: NO_RESOURCE_ROUTE_NAME, + }); + } + NProgress.done(); + }); +} diff --git a/frontend/packages/mobile/src/router/guard/userLoginInfo.ts b/frontend/packages/mobile/src/router/guard/userLoginInfo.ts new file mode 100644 index 0000000..27f8336 --- /dev/null +++ b/frontend/packages/mobile/src/router/guard/userLoginInfo.ts @@ -0,0 +1,57 @@ +import { isDingTalkBrowser, isLarkBrowser, isWeComBrowser } from '@lib/shared/method'; +import { clearToken, hasToken, isLoginExpires } from '@lib/shared/method/auth'; + +import useUser from '@/hooks/useUser'; + +import { AppRouteEnum } from '@/enums/routeEnum'; + +import { LOGIN_LOADING } from '../constants'; +import NProgress from 'nprogress'; +import type { Router } from 'vue-router'; + +export default function setupUserLoginInfoGuard(router: Router) { + router.beforeEach(async (to, from, next) => { + NProgress.start(); + + const { isWhiteListPage } = useUser(); + // 登录过期清除token + if (isLoginExpires()) { + clearToken(); + } + + const tokenExists = hasToken(); + + // 未登录访问受限页面重定向登录页 + if (!tokenExists) { + if (to.name === 'login') { + // 允许主动退出后访问 login + next(); + NProgress.done(); + return; + } + // 企业微信进入,未登录则进去loading页面 + if (isWeComBrowser() || isDingTalkBrowser() || isLarkBrowser()) { + if (to.name !== LOGIN_LOADING) { + next({ name: LOGIN_LOADING }); + NProgress.done(); + return; + } + // 其他浏览器进入则到登录页面 + } else if (to.name !== 'login' && !isWhiteListPage()) { + next({ name: 'login' }); + NProgress.done(); + return; + } + } + // 已登录访问登录页面和loading页面则都去首页 + if ((to.name === 'login' || to.name === LOGIN_LOADING) && tokenExists) { + next({ name: AppRouteEnum.WORKBENCH }); + NProgress.done(); + return; + } + + // 其他情况(放行:已登录访问正常页面\未登录访问白名单页面) + next(); + NProgress.done(); + }); +} diff --git a/frontend/packages/mobile/src/router/index.ts b/frontend/packages/mobile/src/router/index.ts new file mode 100644 index 0000000..d234800 --- /dev/null +++ b/frontend/packages/mobile/src/router/index.ts @@ -0,0 +1,35 @@ +import { createRouter, createWebHashHistory } from 'vue-router'; + +import 'nprogress/nprogress.css'; +import createRouteGuard from './guard/index'; +import appRoutes from './routes'; +import { AUTH_DISABLED_ROUTE, AUTH_LOGIN_LOADING_ROUTE, NO_RESOURCE_ROUTE } from './routes/base'; +import NProgress from 'nprogress'; + +NProgress.configure({ showSpinner: false }); + +const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { + path: '/', + redirect: '/loading', + }, + { + path: '/login', + name: 'login', + component: () => import('@/views/base/login/index.vue'), + meta: { + requiresAuth: false, + }, + }, + ...appRoutes, + NO_RESOURCE_ROUTE, + AUTH_DISABLED_ROUTE, + AUTH_LOGIN_LOADING_ROUTE, + ], +}); + +createRouteGuard(router); + +export default router; diff --git a/frontend/packages/mobile/src/router/routes/base.ts b/frontend/packages/mobile/src/router/routes/base.ts new file mode 100644 index 0000000..5c3666c --- /dev/null +++ b/frontend/packages/mobile/src/router/routes/base.ts @@ -0,0 +1,31 @@ +import { AUTH_DISABLED_ROUTE_NAME, LOGIN_LOADING, NO_RESOURCE_ROUTE_NAME } from '../constants'; +import type { RouteRecordRaw } from 'vue-router'; + +export const DEFAULT_LAYOUT = () => import('@/layout/default-layout.vue'); + +export const NO_RESOURCE_ROUTE: RouteRecordRaw = { + path: '/noResource', + name: NO_RESOURCE_ROUTE_NAME, + component: () => import('@/views/base/no-resource.vue'), + meta: { + requiresAuth: false, + }, +}; + +export const AUTH_DISABLED_ROUTE: RouteRecordRaw = { + path: '/authDisabled', + name: AUTH_DISABLED_ROUTE_NAME, + component: () => import('@/views/base/auth-disabled.vue'), + meta: { + requiresAuth: false, + }, +}; + +export const AUTH_LOGIN_LOADING_ROUTE: RouteRecordRaw = { + path: '/loading', + name: LOGIN_LOADING, + component: () => import('@/views/base/login/loading.vue'), + meta: { + requiresAuth: false, + }, +}; diff --git a/frontend/packages/mobile/src/router/routes/index.ts b/frontend/packages/mobile/src/router/routes/index.ts new file mode 100644 index 0000000..e89ade7 --- /dev/null +++ b/frontend/packages/mobile/src/router/routes/index.ts @@ -0,0 +1,17 @@ +import type { RouteRecordNormalized } from 'vue-router'; + +const modules = import.meta.glob('./modules/*.ts', { eager: true }); + +function formatModules(_modules: any, result: RouteRecordNormalized[]) { + Object.keys(_modules).forEach((key) => { + const defaultModule = _modules[key].default; + if (!defaultModule) return; + const moduleList = Array.isArray(defaultModule) ? [...defaultModule] : [defaultModule]; + result.push(...moduleList); + }); + return result; +} + +const appRoutes: RouteRecordNormalized[] = formatModules(modules, []); + +export default appRoutes; diff --git a/frontend/packages/mobile/src/router/routes/modules/clue.ts b/frontend/packages/mobile/src/router/routes/modules/clue.ts new file mode 100644 index 0000000..d0de4f9 --- /dev/null +++ b/frontend/packages/mobile/src/router/routes/modules/clue.ts @@ -0,0 +1,68 @@ +import { ClueRouteEnum } from '@/enums/routeEnum'; + +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; + +const lead: AppRouteRecordRaw = { + path: '/lead', + name: ClueRouteEnum.CLUE, + redirect: '/lead/index', + component: DEFAULT_LAYOUT, + meta: { + permissions: ['CLUE_MANAGEMENT:READ', 'CLUE_MANAGEMENT_POOL:READ'], + }, + children: [ + { + path: 'index', + name: ClueRouteEnum.CLUE_INDEX, + component: () => import('@/views/clue/index.vue'), + meta: { + locale: 'menu.clue', + permissions: ['CLUE_MANAGEMENT:READ'], + depth: 1, + isCache: true, + }, + }, + { + path: 'detail', + name: ClueRouteEnum.CLUE_DETAIL, + component: () => import('@/views/clue/clue/detail.vue'), + meta: { + permissions: ['CLUE_MANAGEMENT:READ'], + depth: 2, + isCache: true, + }, + }, + { + path: 'moveToPool', + name: ClueRouteEnum.MOVE_TO_POOL, + component: () => import('@/views/clue/clue/moveToPool.vue'), + meta: { + permissions: ['CLUE_MANAGEMENT:READ'], + depth: 2, + }, + }, + { + path: 'convert', + name: ClueRouteEnum.CONVERT, + component: () => import('@/views/clue/clue/convert.vue'), + meta: { + permissions: ['CLUE_MANAGEMENT:READ'], + isCache: true, + depth: 2, + }, + }, + { + path: 'poolDetail', + name: ClueRouteEnum.CLUE_POOL_DETAIL, + component: () => import('@/views/clue/pool/detail.vue'), + meta: { + permissions: ['CLUE_MANAGEMENT_POOL:READ'], + isCache: true, + depth: 2, + }, + }, + ], +}; + +export default lead; diff --git a/frontend/packages/mobile/src/router/routes/modules/common.ts b/frontend/packages/mobile/src/router/routes/modules/common.ts new file mode 100644 index 0000000..31158e4 --- /dev/null +++ b/frontend/packages/mobile/src/router/routes/modules/common.ts @@ -0,0 +1,71 @@ +import { CommonRouteEnum } from '@/enums/routeEnum'; + +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; + +const common: AppRouteRecordRaw = { + path: '/common', + name: CommonRouteEnum.COMMON, + component: DEFAULT_LAYOUT, + meta: { + permissions: [ + 'CLUE_MANAGEMENT:ADD', + 'CLUE_MANAGEMENT:UPDATE', + 'CUSTOMER_MANAGEMENT:ADD', + 'CUSTOMER_MANAGEMENT:UPDATE', + 'CUSTOMER_MANAGEMENT_CONTACT:ADD', + 'CUSTOMER_MANAGEMENT_CONTACT:UPDATE', + 'OPPORTUNITY_MANAGEMENT:ADD', + 'OPPORTUNITY_MANAGEMENT:UPDATE', + ], + }, + children: [ + { + path: 'formCreate', + name: CommonRouteEnum.FORM_CREATE, + component: () => import('@/components/business/crm-form-create/index.vue'), + meta: { + permissions: [ + 'CLUE_MANAGEMENT:ADD', + 'CLUE_MANAGEMENT:UPDATE', + 'CUSTOMER_MANAGEMENT:ADD', + 'CUSTOMER_MANAGEMENT:UPDATE', + 'CUSTOMER_MANAGEMENT_CONTACT:ADD', + 'CUSTOMER_MANAGEMENT_CONTACT:UPDATE', + 'OPPORTUNITY_MANAGEMENT:ADD', + 'OPPORTUNITY_MANAGEMENT:UPDATE', + ], + depth: 9, + }, + }, + { + path: 'contactDetail', + name: CommonRouteEnum.CONTACT_DETAIL, + component: () => import('@/components/business/crm-contact-list/detail.vue'), + meta: { + permissions: ['CUSTOMER_MANAGEMENT:READ'], + depth: 9, + }, + }, + { + path: 'followDetail', + name: CommonRouteEnum.FOLLOW_DETAIL, + component: () => import('@/components/business/crm-follow-list/followDetail.vue'), + meta: { + permissions: ['CUSTOMER_MANAGEMENT:READ', 'CLUE_MANAGEMENT:READ', 'OPPORTUNITY_MANAGEMENT:READ'], + depth: 9, + }, + }, + { + path: 'workflowStage', + name: CommonRouteEnum.WORKFLOW_STAGE, + component: () => import('@/components/business/crm-workflow-card/workflowStage.vue'), + meta: { + permissions: ['OPPORTUNITY_MANAGEMENT:READ', 'OPPORTUNITY_MANAGEMENT:UPDATE'], + depth: 9, + }, + }, + ], +}; + +export default common; diff --git a/frontend/packages/mobile/src/router/routes/modules/customer.ts b/frontend/packages/mobile/src/router/routes/modules/customer.ts new file mode 100644 index 0000000..e7947f3 --- /dev/null +++ b/frontend/packages/mobile/src/router/routes/modules/customer.ts @@ -0,0 +1,85 @@ +import { CustomerRouteEnum } from '@/enums/routeEnum'; + +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; + +const customer: AppRouteRecordRaw = { + path: '/account', + name: CustomerRouteEnum.CUSTOMER, + redirect: '/account/index', + component: DEFAULT_LAYOUT, + meta: { + permissions: ['CUSTOMER_MANAGEMENT:READ', 'CUSTOMER_MANAGEMENT_POOL:READ', 'CUSTOMER_MANAGEMENT_CONTACT:READ'], + }, + children: [ + { + path: 'index', + name: CustomerRouteEnum.CUSTOMER_INDEX, + component: () => import('@/views/customer/index.vue'), + meta: { + locale: 'menu.customer', + depth: 1, + isCache: true, + permissions: ['CUSTOMER_MANAGEMENT:READ'], + }, + }, + { + path: 'detail', + name: CustomerRouteEnum.CUSTOMER_DETAIL, + component: () => import('@/views/customer/detail.vue'), + meta: { + depth: 2, + isCache: true, + permissions: ['CUSTOMER_MANAGEMENT:READ'], + }, + }, + { + path: 'transfer', + name: CustomerRouteEnum.CUSTOMER_TRANSFER, + component: () => import('@/views/customer/transfer.vue'), + meta: { + depth: 3, + permissions: ['CUSTOMER_MANAGEMENT:UPDATE'], + }, + }, + { + path: 'distribute', + name: CustomerRouteEnum.CUSTOMER_DISTRIBUTE, + component: () => import('@/views/customer/openSea/distribute.vue'), + meta: { + depth: 3, + permissions: ['CUSTOMER_MANAGEMENT_POOL:ASSIGN'], + }, + }, + { + path: 'openSeaDetail', + name: CustomerRouteEnum.CUSTOMER_OPENSEA_DETAIL, + component: () => import('@/views/customer/openSea/detail.vue'), + meta: { + depth: 2, + isCache: true, + permissions: ['CUSTOMER_MANAGEMENT_POOL:READ'], + }, + }, + { + path: 'relation', + name: CustomerRouteEnum.CUSTOMER_RELATION, + component: () => import('@/views/customer/relation.vue'), + meta: { + depth: 3, + permissions: ['CUSTOMER_MANAGEMENT:UPDATE'], + }, + }, + { + path: 'collaborator', + name: CustomerRouteEnum.CUSTOMER_COLLABORATOR, + component: () => import('@/views/customer/collaborator.vue'), + meta: { + depth: 3, + permissions: ['CUSTOMER_MANAGEMENT:UPDATE'], + }, + }, + ], +}; + +export default customer; diff --git a/frontend/packages/mobile/src/router/routes/modules/mine.ts b/frontend/packages/mobile/src/router/routes/modules/mine.ts new file mode 100644 index 0000000..a1c5879 --- /dev/null +++ b/frontend/packages/mobile/src/router/routes/modules/mine.ts @@ -0,0 +1,46 @@ +import { MineRouteEnum } from '@/enums/routeEnum'; + +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; + +const mine: AppRouteRecordRaw = { + path: '/mine', + name: MineRouteEnum.MINE, + redirect: '/mine/index', + component: DEFAULT_LAYOUT, + meta: { + permissions: [], + }, + children: [ + { + path: 'index', + name: MineRouteEnum.MINE_INDEX, + component: () => import('@/views/mine/index.vue'), + meta: { + locale: 'menu.mine', + permissions: [], + depth: 1, + }, + }, + { + path: 'message', + name: MineRouteEnum.MINE_MESSAGE, + component: () => import('@/views/mine/message.vue'), + meta: { + locale: 'menu.message', + depth: 2, + }, + }, + { + path: 'detail', + name: MineRouteEnum.MINE_DETAIL, + component: () => import('@/views/mine/detail.vue'), + meta: { + permissions: [], + depth: 2, + }, + }, + ], +}; + +export default mine; diff --git a/frontend/packages/mobile/src/router/routes/modules/opportunity.ts b/frontend/packages/mobile/src/router/routes/modules/opportunity.ts new file mode 100644 index 0000000..935d5ce --- /dev/null +++ b/frontend/packages/mobile/src/router/routes/modules/opportunity.ts @@ -0,0 +1,39 @@ +import { OpportunityRouteEnum } from '@/enums/routeEnum'; + +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; + +const opportunity: AppRouteRecordRaw = { + path: '/opportunity', + name: OpportunityRouteEnum.OPPORTUNITY, + redirect: '/opportunity/index', + component: DEFAULT_LAYOUT, + meta: { + permissions: ['OPPORTUNITY_MANAGEMENT:READ'], + }, + children: [ + { + path: 'index', + name: OpportunityRouteEnum.OPPORTUNITY_INDEX, + component: () => import('@/views/opportunity/index.vue'), + meta: { + locale: 'menu.opportunity', + permissions: ['OPPORTUNITY_MANAGEMENT:READ'], + depth: 1, + isCache: true, + }, + }, + { + path: 'detail', + name: OpportunityRouteEnum.OPPORTUNITY_DETAIL, + component: () => import('@/views/opportunity/detail.vue'), + meta: { + permissions: ['OPPORTUNITY_MANAGEMENT:READ'], + depth: 2, + isCache: true, + }, + }, + ], +}; + +export default opportunity; diff --git a/frontend/packages/mobile/src/router/routes/modules/workbench.ts b/frontend/packages/mobile/src/router/routes/modules/workbench.ts new file mode 100644 index 0000000..72d58ce --- /dev/null +++ b/frontend/packages/mobile/src/router/routes/modules/workbench.ts @@ -0,0 +1,55 @@ +import { WorkbenchRouteEnum } from '@/enums/routeEnum'; + +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; + +const workbench: AppRouteRecordRaw = { + path: '/workbench', + name: WorkbenchRouteEnum.WORKBENCH, + redirect: '/workbench/index', + component: DEFAULT_LAYOUT, + meta: { + permissions: [], + }, + children: [ + { + path: 'index', + name: WorkbenchRouteEnum.WORKBENCH_INDEX, + component: () => import('@/views/workbench/index.vue'), + meta: { + locale: 'menu.workbench', + permissions: [], + depth: 1, + }, + }, + { + path: 'duplicateCheck', + name: WorkbenchRouteEnum.WORKBENCH_DUPLICATE_CHECK, + component: () => import('@/views/workbench/duplicateCheck/index.vue'), + meta: { + locale: 'menu.duplicateCheck', + permissions: [ + 'CUSTOMER_MANAGEMENT:READ', + 'CUSTOMER_MANAGEMENT_POOL:READ', + 'CLUE_MANAGEMENT:READ', + 'CLUE_MANAGEMENT_POOL:READ', + ], + depth: 1, + isCache: true, + }, + }, + { + path: 'agent', + name: WorkbenchRouteEnum.WORKBENCH_AGENT, + component: () => import('@/views/workbench/agent/index.vue'), + meta: { + locale: '', + permissions: [], + depth: 1, + isCache: true, + }, + }, + ], +}; + +export default workbench; diff --git a/frontend/packages/mobile/src/router/routes/types.ts b/frontend/packages/mobile/src/router/routes/types.ts new file mode 100644 index 0000000..e627bd9 --- /dev/null +++ b/frontend/packages/mobile/src/router/routes/types.ts @@ -0,0 +1,30 @@ +import { defineComponent } from 'vue'; + +import type { NavigationGuard } from 'vue-router'; + +export type Component = + | ReturnType + | (() => Promise) + | (() => Promise); + +export interface RouteMeta { + permissions?: string[]; // 权限数组 + requiresAuth?: boolean; // 是否需要权限,默认需要 + icon?: string; // 菜单icon + locale?: string; // 国际化语言单词 + order?: number; // 排序权重 + depth?: number; // 路由深度 + isCache?: boolean; // 缓存设置,true缓存页面状态 +} +export interface AppRouteRecordRaw { + path: string; + name?: string | symbol; + meta?: RouteMeta; + redirect?: string; + component: Component | string; + children?: AppRouteRecordRaw[]; + alias?: string | string[]; + props?: Record | boolean; + beforeEnter?: NavigationGuard | NavigationGuard[]; + fullPath?: string; +} diff --git a/frontend/packages/mobile/src/router/typing.d.ts b/frontend/packages/mobile/src/router/typing.d.ts new file mode 100644 index 0000000..a83a6bc --- /dev/null +++ b/frontend/packages/mobile/src/router/typing.d.ts @@ -0,0 +1,15 @@ +import 'vue-router'; + +declare module 'vue-router' { + interface RouteMeta { + permissions?: string[]; // 权限数组 + requiresAuth?: boolean; // 是否需要权限,默认需要 + icon?: string; // 菜单icon + locale?: string; // 国际化语言单词 + collapsedLocale?: string; // 收起时的国际化语言单词 + hideInMenu?: boolean; // 此路由不在菜单展示 + hideChildrenInMenu?: boolean; // 子路由不展示在菜单 + activeMenu?: string; // 激活状态 + order?: number; // 排序权重 + } +} diff --git a/frontend/packages/mobile/src/store/index.ts b/frontend/packages/mobile/src/store/index.ts new file mode 100644 index 0000000..22c2d46 --- /dev/null +++ b/frontend/packages/mobile/src/store/index.ts @@ -0,0 +1,8 @@ +import { createPinia } from 'pinia'; + +import { debouncePlugin } from './plugins'; +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; + +const pinia = createPinia().use(debouncePlugin).use(piniaPluginPersistedstate); + +export default pinia; diff --git a/frontend/packages/mobile/src/store/modules/app/index.ts b/frontend/packages/mobile/src/store/modules/app/index.ts new file mode 100644 index 0000000..4cb73d5 --- /dev/null +++ b/frontend/packages/mobile/src/store/modules/app/index.ts @@ -0,0 +1,209 @@ +import { defineStore } from 'pinia'; +import { cloneDeep } from 'lodash-es'; + +import { SubscribeMessageUrl } from '@lib/shared/api/requrls/system/message'; +import { CompanyTypeEnum } from '@lib/shared/enums/commonEnum'; +import { ModuleConfigEnum } from '@lib/shared/enums/moduleEnum'; +import { getSSE } from '@lib/shared/method/index'; +import { setLocalStorage } from '@lib/shared/method/local-storage'; +import { loadScript } from '@lib/shared/method/scriptLoader'; + +import { + closeMessageSubscribe, + getHomeMessageList, + getKey, + getModuleNavConfigList, + getOpportunityStageConfig, + getThirdConfigByType, + getUnReadAnnouncement, +} from '@/api/modules'; +import useUserStore from '@/store/modules/user'; + +import type { AppState } from './types'; + +const defaultModuleConfig = [ + { + moduleKey: ModuleConfigEnum.DASHBOARD, + enable: true, + }, + { + moduleKey: ModuleConfigEnum.HOME, + enable: true, + }, + { + moduleKey: ModuleConfigEnum.CUSTOMER_MANAGEMENT, + enable: true, + }, + { + moduleKey: ModuleConfigEnum.CLUE_MANAGEMENT, + enable: true, + }, + { + moduleKey: ModuleConfigEnum.BUSINESS_MANAGEMENT, + enable: true, + }, + { + moduleKey: ModuleConfigEnum.PRODUCT_MANAGEMENT, + enable: true, + }, +]; + +const useAppStore = defineStore('app', { + state: (): AppState => ({ + // 分页 + pageSize: 10, + showSizePicker: true, + showQuickJumper: true, + orgId: '', + moduleConfigList: cloneDeep(defaultModuleConfig), + messageInfo: { + read: true, + notificationDTOList: [], + announcementDTOList: [], + }, + eventSource: null, + cacheRoutes: new Set([]), + isManualBack: false, + originStageConfigList: [], + }), + getters: { + getOrgId(state: AppState) { + return state.orgId; + }, + getCacheRoutes(state: AppState) { + return state.cacheRoutes; + }, + getManualBack(state: AppState) { + return state.isManualBack; + }, + stageConfigList(state: AppState) { + return state.originStageConfigList.map((e) => ({ + value: e.id, + label: e.name, + })); + }, + }, + actions: { + setOrgId(id: string) { + this.orgId = id; + }, + addCacheRoute(route: string) { + this.cacheRoutes.add(route); + }, + removeCacheRoute(route: string) { + this.cacheRoutes.delete(route); + }, + setManualBack(val: boolean) { + this.isManualBack = val; + }, + /** + * 初始化模块配置 + */ + async initModuleConfig() { + try { + this.moduleConfigList = await getModuleNavConfigList({ organizationId: this.orgId }); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } + }, + + /** + * 连接SSE消息订阅流 + */ + async connectSystemMessageSSE() { + const userStore = useUserStore(); + + await this.disconnectSystemMessageSSE(); + this.eventSource = getSSE(SubscribeMessageUrl, { + clientId: userStore.clientIdRandomId, + userId: userStore.userInfo.id, + }); + if (this.eventSource) { + this.eventSource.onmessage = (event: MessageEvent) => { + try { + if (event.data && event.data.includes('HEARTBEAT')) { + return; + } + const data = JSON.parse(event.data); + + this.messageInfo = { ...data }; + } catch (error) { + // eslint-disable-next-line no-console + console.error('SSE Message parsing failure:', error); + } + }; + // 错误直接关闭,手动刷新 + this.eventSource.onerror = () => { + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + }; + } + }, + /** + * 客户端主动断开连接 + */ + async disconnectSystemMessageSSE() { + const userStore = useUserStore(); + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + try { + await closeMessageSubscribe({ clientId: userStore.clientIdRandomId, userId: userStore.userInfo.id }); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } + }, + /** + * 初始化首页消息 + */ + async initMessage() { + try { + const [notifications, announcements] = await Promise.all([getHomeMessageList(), getUnReadAnnouncement()]); + this.messageInfo.notificationDTOList = notifications; + this.messageInfo.announcementDTOList = announcements; + this.messageInfo.read = !(announcements?.length || notifications?.length); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } + }, + // 显示 SQLBot + async showSQLBot() { + // TODO license 先放开 + // const licenseStore = useLicenseStore(); + // if (!licenseStore.hasLicense()) return; + const res = await getThirdConfigByType(CompanyTypeEnum.SQLBot); + if (res.config?.sqlBotChatEnable) { + await loadScript(res.config?.appSecret as string, { identifier: CompanyTypeEnum.SQLBot }); + } + }, + async initPublicKey() { + try { + const res = await getKey(); + setLocalStorage('publicKey', res); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } + }, + async initStageConfig() { + try { + const stageConfig = await getOpportunityStageConfig(); + this.originStageConfigList = stageConfig.stageConfigList; + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } + }, + }, + persist: { + paths: ['moduleConfigList'], + }, +}); + +export default useAppStore; diff --git a/frontend/packages/mobile/src/store/modules/app/types.ts b/frontend/packages/mobile/src/store/modules/app/types.ts new file mode 100644 index 0000000..0db5958 --- /dev/null +++ b/frontend/packages/mobile/src/store/modules/app/types.ts @@ -0,0 +1,16 @@ +import { StageConfigItem } from '@lib/shared/models/opportunity'; +import type { ModuleNavBaseInfoItem } from '@lib/shared/models/system/module'; +import type { MessageInfo } from '@lib/shared/models/user'; + +export interface AppState { + pageSize: number; + showSizePicker: boolean; + showQuickJumper: boolean; + orgId: string; + moduleConfigList: ModuleNavBaseInfoItem[]; // 模块配置列表 + messageInfo: MessageInfo; // 消息通知和公告 + eventSource: null | EventSource; // 事件流资源 + cacheRoutes: Set; // 缓存路由列表 + isManualBack: boolean; // 是否手动触发的返回 + originStageConfigList: StageConfigItem[]; +} diff --git a/frontend/packages/mobile/src/store/modules/setting/license.ts b/frontend/packages/mobile/src/store/modules/setting/license.ts new file mode 100644 index 0000000..555e849 --- /dev/null +++ b/frontend/packages/mobile/src/store/modules/setting/license.ts @@ -0,0 +1,95 @@ +import { defineStore } from 'pinia'; +import dayjs from 'dayjs'; + +import { LicenseInfo } from '@lib/shared/models/system/authorizedManagement'; + +import { getLicense } from '@/api/modules'; + +const useLicenseStore = defineStore('license', { + persist: true, + state: (): { licenseInfo: LicenseInfo | null; expiredDuring: boolean; expiredDays: number } => ({ + licenseInfo: null, + expiredDuring: false, + expiredDays: 0, + }), + actions: { + setLicenseInfo(info: LicenseInfo) { + this.licenseInfo = info; + }, + removeLicenseStatus() { + if (this.licenseInfo) { + this.licenseInfo.status = null; + } + }, + hasLicense() { + return this.licenseInfo?.status === 'valid'; + }, + isEnterpriseVersion() { + return this.licenseInfo?.status !== 'not_found'; + }, + getExpirationTime(resTime: string) { + if (!this.isEnterpriseVersion()) { + this.expiredDuring = false; + this.expiredDays = 0; + return; + } + + if (!resTime && !this.hasLicense() && this.isEnterpriseVersion()) { + this.expiredDuring = true; + this.expiredDays = 0; + return; + } + + const today = dayjs().startOf('day'); // 今天的开始时间 + const expirationDate = dayjs(resTime).startOf('day'); // license到期日的开始时间 + + // 计算天数差(不包含当天) + const daysDifference = expirationDate.diff(today, 'day'); + if (daysDifference < 0) { + // 已过期情况 + const daysExpired = Math.abs(daysDifference); + this.expiredDays = -daysExpired; // 负数表示已过期天数 + this.expiredDuring = daysExpired <= 30; // 过期后只显示30天 + } else if (daysDifference === 0) { + // 今天到期(显示"剩余1天") + this.expiredDays = 1; + this.expiredDuring = true; + } else if (daysDifference <= 29) { + // 1-29天后到期(显示剩余天数+1) + this.expiredDays = daysDifference + 1; + this.expiredDuring = true; + } else if (daysDifference === 30) { + // 30天后到期(显示"剩余30天") + this.expiredDays = daysDifference + 1; + this.expiredDuring = false; + } else { + // 31+天后到期 + this.expiredDays = daysDifference + 1; + this.expiredDuring = false; + } + }, + // license校验 + async getValidateLicense() { + try { + const result = await getLicense(); + // 检查返回结果是否有效,不存在license自身值 + if (!result || !result.status) { + return; + } + /* if (!result || !result.status || !result.license || !result.license.count) { + return; + } */ + this.setLicenseInfo(result); + // 计算license时间 + if (result) { + this.getExpirationTime(result.expired); + } + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } + }, + }, +}); + +export default useLicenseStore; diff --git a/frontend/packages/mobile/src/store/modules/user/index.ts b/frontend/packages/mobile/src/store/modules/user/index.ts new file mode 100644 index 0000000..2df8c39 --- /dev/null +++ b/frontend/packages/mobile/src/store/modules/user/index.ts @@ -0,0 +1,169 @@ +import { defineStore } from 'pinia'; +import { showToast } from 'vant'; + +import { CompanyTypeEnum } from '@lib/shared/enums/commonEnum'; +import { useI18n } from '@lib/shared/hooks/useI18n'; +import { getGenerateId } from '@lib/shared/method'; +import { clearToken, setToken } from '@lib/shared/method/auth'; +import { removeRouteListener } from '@lib/shared/method/route-listener'; +import { removeScript } from '@lib/shared/method/scriptLoader'; +import { ApiKeyItem } from '@lib/shared/models/system/business'; +import type { LoginParams } from '@lib/shared/models/system/login'; +import type { UserInfo } from '@lib/shared/models/user'; + +import { getApiKeyList, isLogin, login, signout } from '@/api/modules'; +import useUser from '@/hooks/useUser'; +import router from '@/router'; +import { hasAnyPermission } from '@/utils/permission'; + +import { AppRouteEnum } from '@/enums/routeEnum'; + +import useAppStore from '../app'; + +export interface UserState { + loginType: string[]; + userInfo: UserInfo; + clientIdRandomId: string; + apiKeyList: ApiKeyItem[]; +} + +const useUserStore = defineStore('user', { + persist: true, + state: (): UserState => ({ + loginType: [], + userInfo: { + id: '', + name: '', + email: '', + password: '', + enable: true, + createTime: 0, + updateTime: 0, + language: '', + lastOrganizationId: '', + phone: '', + source: '', + createUser: '', + updateUser: '', + platformInfo: '', + avatar: '', + permissionIds: [], + organizationIds: [], + csrfToken: '', + sessionId: '', + roles: [], + departmentId: '', + departmentName: '', + defaultPwd: true, + }, + clientIdRandomId: '', + apiKeyList: [], + }), + + getters: { + isAdmin(state: UserState) { + return state.userInfo.id === 'admin'; + }, + }, + actions: { + // 设置用户信息 + setInfo(info: UserInfo) { + this.$patch({ userInfo: info }); + }, + setDefaultPwd(value: boolean) { + this.userInfo.defaultPwd = value; + }, + setLoginInfo(res: UserInfo) { + try { + if (!res) { + return false; + } + setToken(res.sessionId, res.csrfToken); + this.setInfo(res); + const appStore = useAppStore(); + const lastOrganizationId = res.lastOrganizationId ?? res.organizationIds?.[0] ?? ''; + appStore.setOrgId(lastOrganizationId); + this.clientIdRandomId = getGenerateId(); + return true; + } catch (err) { + // eslint-disable-next-line no-console + console.log(err); + clearToken(); + return false; + } + }, + async isLogin(isDisabledErrorTip = false) { + try { + const res = await isLogin(isDisabledErrorTip); + if (!res) { + return false; + } + this.setLoginInfo(res); + return true; + } catch (err) { + // eslint-disable-next-line no-console + console.log(err); + return false; + } + }, + async checkIsLogin() { + const { isLoginPage } = useUser(); + const isLoginStatus = await this.isLogin(); + if (isLoginStatus) { + if (isLoginPage()) { + router.replace({ name: AppRouteEnum.WORKBENCH }); + } + } else if (!isLoginPage()) { + router.replace({ name: 'login' }); + } + }, + async login(params: LoginParams) { + try { + const res = await login(params); + setToken(res.sessionId, res.csrfToken); + this.setInfo(res); + const appStore = useAppStore(); + const lastOrganizationId = res.lastOrganizationId ?? res.organizationIds[0] ?? ''; + this.clientIdRandomId = getGenerateId(); + appStore.setOrgId(lastOrganizationId); + } catch (error) { + clearToken(); + throw error; + } + }, + logoutCallBack() { + this.$reset(); + clearToken(); + removeRouteListener(); + removeScript(CompanyTypeEnum.SQLBot); + }, + async logout(silence = false) { + try { + const { t } = useI18n(); + if (!silence) { + showToast(t('message.loggingOut')); + } + await signout(); + } finally { + this.logoutCallBack(); + } + }, + async initApiKeyList() { + if (!hasAnyPermission(['PERSONAL_API_KEY:READ'])) return; + try { + const res = await getApiKeyList(); + this.apiKeyList = res.map((item) => ({ + ...item, + isExpire: item.forever ? false : item.expireTime < Date.now(), + desensitization: true, + showDescInput: false, + })); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } + }, + }, +}); + +export default useUserStore; diff --git a/frontend/packages/mobile/src/store/plugins.ts b/frontend/packages/mobile/src/store/plugins.ts new file mode 100644 index 0000000..04157b4 --- /dev/null +++ b/frontend/packages/mobile/src/store/plugins.ts @@ -0,0 +1,19 @@ +import { debounce } from 'lodash-es'; + +import type { PiniaPluginContext } from 'pinia'; +// 首先得声明插件使用到的额外属性,因为pinia的TS类型声明中,每个store只有三个原生属性state、getters、actions,若没有使用到额外属性则无需声明 +declare module 'pinia' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface DefineStoreOptionsBase { + debounce?: Partial, number>>; // 节流配置 + } +} +// 基于lodash的防抖函数封装,读取store中cache配置的属性,针对已配置的属性更改操作进行防抖处理 +export const debouncePlugin = ({ options, store }: PiniaPluginContext): void | Record => { + if (options.debounce) { + return Object.keys(options.debounce).reduce((debounceActions: Record, action) => { + debounceActions[action] = debounce(store[action], options.debounce![action]); + return debounceActions; + }, {}); + } +}; diff --git a/frontend/packages/mobile/src/utils/permission.ts b/frontend/packages/mobile/src/utils/permission.ts new file mode 100644 index 0000000..f91bf24 --- /dev/null +++ b/frontend/packages/mobile/src/utils/permission.ts @@ -0,0 +1,86 @@ +import { RouteLocationNormalized, RouteRecordNormalized, RouteRecordRaw } from 'vue-router'; + +import appRoutes from '@/router/routes/index'; +import useAppStore from '@/store/modules/app'; +import useUserStore from '@/store/modules/user'; + +export function hasPermission(permission: string) { + const userStore = useUserStore(); + if (userStore.isAdmin) { + return true; + } + + if (userStore.userInfo.permissionIds.length === 0) { + return false; + } + + if (userStore.userInfo.permissionIds.includes(permission)) { + return true; + } + return false; +} + +/** + * 判断是否有任一权限 + * @param permissions 权限列表 + */ +export function hasAnyPermission(permissions?: string[]) { + if (!permissions || permissions.length === 0) { + return true; + } + return permissions.some((permission) => hasPermission(permission)); +} + +/** + * 判断是否有所有权限 + * @param permissions 权限列表 + */ +export function hasAllPermission(permissions?: string[]) { + if (!permissions || permissions.length === 0) { + return true; + } + return permissions.every((permission) => hasPermission(permission)); +} + +// 判断当前一级菜单是否有权限 +export function topLevelMenuHasPermission(route: RouteLocationNormalized | RouteRecordRaw) { + const userStore = useUserStore(); + const appStore = useAppStore(); + const { moduleConfigList } = appStore; + + if ( + moduleConfigList.length && + !moduleConfigList.filter((e) => e.enable).some((e) => e.moduleKey === (route.name as string)) + ) { + // 没有配置的菜单不显示 + return false; + } + if (userStore.isAdmin) { + // 如果是系统管理员, 包含所有菜单权限 + return true; + } + return hasAnyPermission(route.meta?.permissions || []); +} + +// 判断当前路由名有没有权限 +export function routerNameHasPermission(routerName: string, routerList: RouteRecordNormalized[]) { + const currentRoute = routerList.find((item) => item.name === routerName); + return currentRoute ? hasAnyPermission(currentRoute.meta?.permissions || []) : false; +} + +export function findRouteByName(name: string) { + const queue: RouteRecordNormalized[] = [...appRoutes]; + while (queue.length > 0) { + const currentRoute = queue.shift(); + if (!currentRoute) { + return; + } + if (currentRoute.name === name) { + return currentRoute; + } + if (currentRoute.children) { + queue.push(...(currentRoute.children as RouteRecordNormalized[])); + } + } + return null; +} diff --git a/frontend/packages/mobile/src/utils/themeOverrides.ts b/frontend/packages/mobile/src/utils/themeOverrides.ts new file mode 100644 index 0000000..63aed8d --- /dev/null +++ b/frontend/packages/mobile/src/utils/themeOverrides.ts @@ -0,0 +1,7 @@ +import type { ConfigProviderThemeVars } from 'vant'; + +const themeVars: ConfigProviderThemeVars = { + sliderBarHeight: '4px', +}; + +export default themeVars; diff --git a/frontend/packages/mobile/src/views/base/auth-disabled.vue b/frontend/packages/mobile/src/views/base/auth-disabled.vue new file mode 100644 index 0000000..a725c23 --- /dev/null +++ b/frontend/packages/mobile/src/views/base/auth-disabled.vue @@ -0,0 +1,11 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/base/login/index.vue b/frontend/packages/mobile/src/views/base/login/index.vue new file mode 100644 index 0000000..61d0acb --- /dev/null +++ b/frontend/packages/mobile/src/views/base/login/index.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/base/login/loading.vue b/frontend/packages/mobile/src/views/base/login/loading.vue new file mode 100644 index 0000000..be69f01 --- /dev/null +++ b/frontend/packages/mobile/src/views/base/login/loading.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/base/login/locale/en-US.ts b/frontend/packages/mobile/src/views/base/login/locale/en-US.ts new file mode 100644 index 0000000..7ea81d8 --- /dev/null +++ b/frontend/packages/mobile/src/views/base/login/locale/en-US.ts @@ -0,0 +1,20 @@ +export default { + 'login.form.title': 'Welcome to Cordys CRM', + 'login.form.userName.errMsg': 'Username cannot be empty', + 'login.form.password.errMsg': 'Password cannot be empty', + 'login.form.login.errMsg': 'Login error, refresh and try again', + 'login.form.userName.placeholder': 'Please enter your phone number or email', + 'login.form.userName.placeholderOther': 'Please enter your account', + 'login.form.password.placeholder': 'Please enter your password', + 'login.form.rememberPassword': 'Remember password', + 'login.form.forgetPassword': 'Forgot password', + 'login.form.login': 'login', + 'login.form.username': 'Username', + 'login.form.password': 'Password', + 'login.form.loading': 'Logging in', + 'login.form.WECOM': 'WECOM', + 'login.form.DINGTALK': 'DINGTALK', + 'login.form.LARK': 'LARK', + 'login.form.logout': 'Log out', + 'login.form.loadingTip': 'Loading, please wait...', +}; diff --git a/frontend/packages/mobile/src/views/base/login/locale/zh-CN.ts b/frontend/packages/mobile/src/views/base/login/locale/zh-CN.ts new file mode 100644 index 0000000..af1687a --- /dev/null +++ b/frontend/packages/mobile/src/views/base/login/locale/zh-CN.ts @@ -0,0 +1,20 @@ +export default { + 'login.form.title': '欢迎登录 Cordys CRM', + 'login.form.userName.errMsg': '用户名不能为空', + 'login.form.password.errMsg': '密码不能为空', + 'login.form.account': '账号', + 'login.form.login.errMsg': '登录出错,请刷新重试', + 'login.form.userName.placeholderOther': '请输入用户名', + 'login.form.password.placeholder': '请输入密码', + 'login.form.rememberPassword': '记住密码', + 'login.form.forgetPassword': '忘记密码', + 'login.form.login': '登录', + 'login.form.username': '账号', + 'login.form.password': '密码', + 'login.form.loading': '登录中', + 'login.form.WECOM': '企业微信', + 'login.form.DINGTALK': '钉钉', + 'login.form.LARK': '飞书', + 'login.form.logout': '退出登录', + 'login.form.loadingTip': '正在加载中,请稍候...', +}; diff --git a/frontend/packages/mobile/src/views/base/no-resource.vue b/frontend/packages/mobile/src/views/base/no-resource.vue new file mode 100644 index 0000000..9598a92 --- /dev/null +++ b/frontend/packages/mobile/src/views/base/no-resource.vue @@ -0,0 +1,11 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/clue/clue/convert.vue b/frontend/packages/mobile/src/views/clue/clue/convert.vue new file mode 100644 index 0000000..7d49ad5 --- /dev/null +++ b/frontend/packages/mobile/src/views/clue/clue/convert.vue @@ -0,0 +1,322 @@ + + + + + + + diff --git a/frontend/packages/mobile/src/views/clue/clue/detail.vue b/frontend/packages/mobile/src/views/clue/clue/detail.vue new file mode 100644 index 0000000..232731d --- /dev/null +++ b/frontend/packages/mobile/src/views/clue/clue/detail.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/clue/clue/index.vue b/frontend/packages/mobile/src/views/clue/clue/index.vue new file mode 100644 index 0000000..7979b2b --- /dev/null +++ b/frontend/packages/mobile/src/views/clue/clue/index.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/clue/clue/moveToPool.vue b/frontend/packages/mobile/src/views/clue/clue/moveToPool.vue new file mode 100644 index 0000000..240c4c3 --- /dev/null +++ b/frontend/packages/mobile/src/views/clue/clue/moveToPool.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/clue/index.vue b/frontend/packages/mobile/src/views/clue/index.vue new file mode 100644 index 0000000..2c4062b --- /dev/null +++ b/frontend/packages/mobile/src/views/clue/index.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/clue/locale/en-US.ts b/frontend/packages/mobile/src/views/clue/locale/en-US.ts new file mode 100644 index 0000000..4de5714 --- /dev/null +++ b/frontend/packages/mobile/src/views/clue/locale/en-US.ts @@ -0,0 +1,36 @@ +export default { + 'clue.department': 'Department', + 'clue.info': 'Lead information', + 'clue.convertedToCustomer': 'Converted to customer', + 'clue.batchDeleteContentTip': 'After deletion, members cannot access this lead. Please proceed with caution!', + 'clue.deleteTitle': 'Confirm to delete this lead?', + 'clue.previousOwnerRecord': 'Previous owner record', + 'clue.followingUp': 'Following up', + 'clue.interested': 'Interested', + 'clue.clueProgress': 'Lead progress', + 'clue.transferredToCustomer': 'Transferred to customer', + 'clue.searchPlaceholder': 'Please enter the company name', + 'clue.transferClue': 'Transfer lead', + 'clue.convert': 'Convert', + 'clue.convertTo': 'Convert To', + 'clue.convertCluesTo': 'Convert Clues To', + 'clue.conversionSuccessful': 'Conversion Successful', + 'clue.remarks': 'Remarks', + 'clue.sameNameConvertTip': + '1. Contact name is required. Please complete the contact name linkage configuration before converting the contact.', + 'clue.notSameNameConvertTip': + '2. Please complete the linkage configuration of the customer form before converting the lead to a customer.', + 'clue.linkFormConfigTip': + '3. When converting a lead to a customer, if the customer already exists, the lead will be associated with the customer and the lead owner will be added as a collaborator; if the customer does not exist, it will be created automatically.', + 'clue.sameNameOppConvertTip': + '1. When a customer with the same name exists, the opportunity will be associated with that customer, and the clue owner will be added as a collaborator of the same-named customer.', + 'clue.notSameNameOppConvertTip': + '2. If no customer with the same name exists, the system will automatically create a customer and a contact, and associate them with the opportunity.', + 'clue.linkFormConfigOppTip': + '3. The field mapping between customers and opportunities is configured in the “Opportunity Form Settings”.', + 'clue.backClueList': 'Back to Clue List', + 'clue.afterConvertGoDetailText': '{name} Details', + 'clue.countDownTip': 'Returning to the clue list in {count} seconds. You can also go back manually.', + 'clue.moveToLeadPoolTip': 'Warning: Leads will become unassigned after transfer!', + 'clue.moveIntoCluePool': 'Move to lead pool', +}; diff --git a/frontend/packages/mobile/src/views/clue/locale/zh-CN.ts b/frontend/packages/mobile/src/views/clue/locale/zh-CN.ts new file mode 100644 index 0000000..83497d0 --- /dev/null +++ b/frontend/packages/mobile/src/views/clue/locale/zh-CN.ts @@ -0,0 +1,30 @@ +export default { + 'clue.department': '部门', + 'clue.info': '线索信息', + 'clue.convertedToCustomer': '已转客户', + 'clue.batchDeleteContentTip': '删除后,成员无法查到该线索,请谨慎操作!', + 'clue.deleteTitle': '确认删除线索吗?', + 'clue.previousOwnerRecord': '前负责人记录', + 'clue.followingUp': '跟进中', + 'clue.interested': '感兴趣', + 'clue.clueProgress': '线索进度', + 'clue.transferredToCustomer': '已转为客户', + 'clue.searchPlaceholder': '请输入公司名称', + 'clue.transferClue': '转移线索', + 'clue.convert': '转换', + 'clue.convertTo': '转换为', + 'clue.convertCluesTo': '转换线索为', + 'clue.conversionSuccessful': '转换成功', + 'clue.remarks': '备注', + 'clue.sameNameConvertTip': '1.存在同名客户时,线索将与同名客户关联,线索负责人添加为同名客户的协作人', + 'clue.notSameNameConvertTip': '2.如未存在同名客户,系统将会自动创建客户和联系人', + 'clue.linkFormConfigTip': '3.线索与客户的字段映射在“客户表单设置”配置表单联动', + 'clue.sameNameOppConvertTip': '1.存在同名客户时,商机将与同名客户关联,线索负责人添加为同名客户的协作人', + 'clue.notSameNameOppConvertTip': '2.如未存在同名客户,系统将会自动创建客户和联系人,并与商机关联', + 'clue.linkFormConfigOppTip': '3.客户与商机的字段映射在“商机表单设置”配置表单联动', + 'clue.backClueList': '返回线索列表', + 'clue.afterConvertGoDetailText': '{name}详情', + 'clue.countDownTip': '{count} 秒后回到线索列表,也可手动回到线索列表', + 'clue.moveToLeadPoolTip': '移入后,线索将无负责人,请谨慎操作!', + 'clue.moveIntoCluePool': '移入线索池', +}; diff --git a/frontend/packages/mobile/src/views/clue/pool/detail.vue b/frontend/packages/mobile/src/views/clue/pool/detail.vue new file mode 100644 index 0000000..dd9233e --- /dev/null +++ b/frontend/packages/mobile/src/views/clue/pool/detail.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/clue/pool/index.vue b/frontend/packages/mobile/src/views/clue/pool/index.vue new file mode 100644 index 0000000..268c6f6 --- /dev/null +++ b/frontend/packages/mobile/src/views/clue/pool/index.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/customer/collaborator.vue b/frontend/packages/mobile/src/views/customer/collaborator.vue new file mode 100644 index 0000000..86c1841 --- /dev/null +++ b/frontend/packages/mobile/src/views/customer/collaborator.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/customer/components/collaborator.vue b/frontend/packages/mobile/src/views/customer/components/collaborator.vue new file mode 100644 index 0000000..125b6fc --- /dev/null +++ b/frontend/packages/mobile/src/views/customer/components/collaborator.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/customer/components/customer.vue b/frontend/packages/mobile/src/views/customer/components/customer.vue new file mode 100644 index 0000000..0609f03 --- /dev/null +++ b/frontend/packages/mobile/src/views/customer/components/customer.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/customer/components/relation.vue b/frontend/packages/mobile/src/views/customer/components/relation.vue new file mode 100644 index 0000000..7c3b0e7 --- /dev/null +++ b/frontend/packages/mobile/src/views/customer/components/relation.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/customer/contact/index.vue b/frontend/packages/mobile/src/views/customer/contact/index.vue new file mode 100644 index 0000000..34fc8bf --- /dev/null +++ b/frontend/packages/mobile/src/views/customer/contact/index.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/customer/detail.vue b/frontend/packages/mobile/src/views/customer/detail.vue new file mode 100644 index 0000000..0cb2ea2 --- /dev/null +++ b/frontend/packages/mobile/src/views/customer/detail.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/customer/index.vue b/frontend/packages/mobile/src/views/customer/index.vue new file mode 100644 index 0000000..7da37d7 --- /dev/null +++ b/frontend/packages/mobile/src/views/customer/index.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/customer/locale/en-US.ts b/frontend/packages/mobile/src/views/customer/locale/en-US.ts new file mode 100644 index 0000000..7c1399d --- /dev/null +++ b/frontend/packages/mobile/src/views/customer/locale/en-US.ts @@ -0,0 +1,30 @@ +export default { + 'customer.searchPlaceholder': 'Please enter account name', + 'customer.searchContactPlaceholder': 'Please enter contact name or phone number', + 'customer.all': 'All', + 'customer.mine': 'My Accounts', + 'customer.deptCustomer': 'Department Accounts', + 'customer.cooperationCustomer': 'Cooperation Accounts', + 'customer.customerName': 'Account Name', + 'customer.info': 'Account Information', + 'customer.headerRecord': 'Header Record', + 'customer.relation': 'Account Relationship', + 'customer.addRelation': 'Add Account Relationship', + 'customer.updateRelation': 'Update Account Relationship', + 'customer.collaborator': 'Collaborator', + 'customer.addCollaborator': 'Add Collaborator', + 'customer.updateCollaborator': 'Update Collaborator', + 'customer.transferCustomer': 'Transfer Account', + 'customer.searchNamePlaceholder': 'Please enter name', + 'customer.transfer': 'Transfer', + 'customer.transferSuccess': 'Transfer Success', + 'customer.deleteTitle': 'Delete this account?', + 'customer.deleteTip': 'Once deleted, members will not be able to find this account. Please proceed with caution!', + 'customer.group': 'Group', + 'customer.subsidiary': 'Subsidiary', + 'customer.selectCustomer': 'Select account', + 'customer.readOnly': 'Read only', + 'customer.cooperation': 'Cooperation', + 'customer.moveToOpenSea': 'Move to Open Sea', + 'customer.batchMoveContentTip': 'After moving, the account will have no owner. Please proceed with caution!', +}; diff --git a/frontend/packages/mobile/src/views/customer/locale/zh-CN.ts b/frontend/packages/mobile/src/views/customer/locale/zh-CN.ts new file mode 100644 index 0000000..d6f7670 --- /dev/null +++ b/frontend/packages/mobile/src/views/customer/locale/zh-CN.ts @@ -0,0 +1,31 @@ +export default { + 'customer.searchPlaceholder': '请输入客户名称', + 'customer.searchContactPlaceholder': '请输入联系人名称或手机号', + 'customer.all': '全部', + 'customer.mine': '我的客户', + 'customer.deptCustomer': '部门客户', + 'customer.cooperationCustomer': '协作客户', + 'customer.customerName': '客户姓名', + 'customer.customerNameNotNull': '客户姓名不能为空', + 'customer.info': '客户信息', + 'customer.headerRecord': '负责人记录', + 'customer.relation': '客户关系', + 'customer.addRelation': '添加客户关系', + 'customer.updateRelation': '更新客户关系', + 'customer.collaborator': '协作成员', + 'customer.addCollaborator': '添加成员', + 'customer.updateCollaborator': '更新成员', + 'customer.transferCustomer': '转移客户', + 'customer.searchNamePlaceholder': '请输入姓名', + 'customer.transfer': '转移', + 'customer.transferSuccess': '转移成功', + 'customer.deleteTitle': '确认删除客户吗?', + 'customer.deleteTip': '删除后,成员无法查到该客户,请谨慎操作!', + 'customer.group': '集团', + 'customer.subsidiary': '子公司', + 'customer.selectCustomer': '选择客户', + 'customer.readOnly': '只读', + 'customer.cooperation': '协作', + 'customer.moveToOpenSea': '移入公海', + 'customer.batchMoveContentTip': '移入后,客户将无负责人,请谨慎操作!', +}; diff --git a/frontend/packages/mobile/src/views/customer/openSea/detail.vue b/frontend/packages/mobile/src/views/customer/openSea/detail.vue new file mode 100644 index 0000000..e1dd1d7 --- /dev/null +++ b/frontend/packages/mobile/src/views/customer/openSea/detail.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/customer/openSea/distribute.vue b/frontend/packages/mobile/src/views/customer/openSea/distribute.vue new file mode 100644 index 0000000..cc442b9 --- /dev/null +++ b/frontend/packages/mobile/src/views/customer/openSea/distribute.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/customer/openSea/index.vue b/frontend/packages/mobile/src/views/customer/openSea/index.vue new file mode 100644 index 0000000..946d545 --- /dev/null +++ b/frontend/packages/mobile/src/views/customer/openSea/index.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/customer/relation.vue b/frontend/packages/mobile/src/views/customer/relation.vue new file mode 100644 index 0000000..2677314 --- /dev/null +++ b/frontend/packages/mobile/src/views/customer/relation.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/customer/transfer.vue b/frontend/packages/mobile/src/views/customer/transfer.vue new file mode 100644 index 0000000..a7d1f15 --- /dev/null +++ b/frontend/packages/mobile/src/views/customer/transfer.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/mine/detail.vue b/frontend/packages/mobile/src/views/mine/detail.vue new file mode 100644 index 0000000..e861a57 --- /dev/null +++ b/frontend/packages/mobile/src/views/mine/detail.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/mine/index.vue b/frontend/packages/mobile/src/views/mine/index.vue new file mode 100644 index 0000000..a706650 --- /dev/null +++ b/frontend/packages/mobile/src/views/mine/index.vue @@ -0,0 +1,212 @@ + + + + + + + diff --git a/frontend/packages/mobile/src/views/mine/locale/en-US.ts b/frontend/packages/mobile/src/views/mine/locale/en-US.ts new file mode 100644 index 0000000..0de1b65 --- /dev/null +++ b/frontend/packages/mobile/src/views/mine/locale/en-US.ts @@ -0,0 +1,24 @@ +export default { + 'mine.email': 'Email ', + 'mine.resetPassWord': 'Reset password ', + 'mine.verificationCode': 'Verification code ', + 'mine.getCode': 'Get the verification code ', + 'mine.changePassword': 'Change password', + 'mine.currentPassword': 'Current password', + 'mine.changePasswordTip': + 'Detected that you are currently using the default initial password. For the security of your account, it is recommended to change your password immediately', + 'mime. PleaseInputVerificationCode': 'Please enter the authentication code', + 'mine.pleaseInputPassWord': 'Please enter your password ', + 'mime. PleaseInputConfirmPassWord': 'Please confirm password again', + 'mine.repeatPassWord': 'Repeat password ', + 'mine.newPassWord': 'New password ', + 'mine.passwordDiff': 'Two passwords do not match ', + 'mine.retryGetCode': '{count} s Retrieve', + 'mine.allMessage': 'All ', + 'mine.systemMessage': 'System message ', + 'mine.announcementMessage': 'Announcement ', + 'mine.opportunityMessage': 'Opportunity', + 'mine.userPhoneErrTip': 'Please enter 11-digit phone number', + 'mine.emailErrTip': 'Email format error, please re-enter', + 'mine.messageSearchPlaceholder': 'Please enter keyword', +}; diff --git a/frontend/packages/mobile/src/views/mine/locale/zh-CN.ts b/frontend/packages/mobile/src/views/mine/locale/zh-CN.ts new file mode 100644 index 0000000..b781920 --- /dev/null +++ b/frontend/packages/mobile/src/views/mine/locale/zh-CN.ts @@ -0,0 +1,23 @@ +export default { + 'mine.email': '邮箱', + 'mine.resetPassWord': '重置密码', + 'mine.verificationCode': '验证码', + 'mine.changePassword': '修改密码', + 'mine.currentPassword': '当前密码', + 'mine.changePasswordTip': '检测到您当前正在使用默认初始密码,为了您的账户安全,建议立即', + 'mine.getCode': '获取验证码', + 'mine.pleaseInputVerificationCode': '请输入验证码', + 'mine.pleaseInputPassWord': '请输入密码', + 'mine.pleaseInputConfirmPassWord': '请再次确认密码', + 'mine.repeatPassWord': '重复密码', + 'mine.newPassWord': '新密码', + 'mine.passwordDiff': '两次密码不一致', + 'mine.retryGetCode': '{count} 秒重新获取', + 'mine.allMessage': '全部', + 'mine.systemMessage': '系统消息', + 'mine.announcementMessage': '公告', + 'mine.opportunityMessage': '商机', + 'mine.userPhoneErrTip': '请输入 11 位手机号', + 'mine.emailErrTip': '邮箱格式错误,请重新输入', + 'mine.messageSearchPlaceholder': '请输入关键词', +}; diff --git a/frontend/packages/mobile/src/views/mine/message.vue b/frontend/packages/mobile/src/views/mine/message.vue new file mode 100644 index 0000000..5c8c72f --- /dev/null +++ b/frontend/packages/mobile/src/views/mine/message.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/opportunity/detail.vue b/frontend/packages/mobile/src/views/opportunity/detail.vue new file mode 100644 index 0000000..5e0d980 --- /dev/null +++ b/frontend/packages/mobile/src/views/opportunity/detail.vue @@ -0,0 +1,279 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/opportunity/index.vue b/frontend/packages/mobile/src/views/opportunity/index.vue new file mode 100644 index 0000000..6abbf9a --- /dev/null +++ b/frontend/packages/mobile/src/views/opportunity/index.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/opportunity/locale/en-US.ts b/frontend/packages/mobile/src/views/opportunity/locale/en-US.ts new file mode 100644 index 0000000..b6f255a --- /dev/null +++ b/frontend/packages/mobile/src/views/opportunity/locale/en-US.ts @@ -0,0 +1,30 @@ +export default { + 'opportunity.searchPlaceholder': 'Please enter opportunity name', + 'opportunity.all': 'All', + 'opportunity.mine': 'My opportunities', + 'opportunity.deptOpportunities': 'Department opportunities', + 'opportunity.convertedOpportunities': 'Converted opportunities', + 'opportunity.deleteTitle': 'Confirm delete opportunity?', + 'opportunity.deleteContentTip': 'Deleted, members cannot find this opportunity, please be careful!', + 'opportunity.info': 'Opportunity Information', + 'opportunity.newCreate': 'New create', + 'opportunity.clearRequirements': 'Clear requirements', + 'opportunity.schemeValidation': 'Solution validation', + 'opportunity.projectProposalReport': 'Project proposal report', + 'opportunity.businessProcurement': 'Opportunity procurement', + 'opportunity.end': 'End', + 'opportunity.progress': 'Opportunity progress', + 'opportunity.intendedProducts': 'Intended products', + 'opportunity.stage': 'Opportunity stage', + 'opportunity.name': 'Opportunity name', + 'opportunity.transferOpportunity': 'Transfer opportunity', + 'opportunity.endTime': 'End time', + 'opportunity.selectActualEndTime': 'Please select an end time', + 'opportunity.failureReason': 'Failure reason', + 'opportunity.selectFailureReason': 'Please select a failure reason', + 'opportunity.customerChooseCompetitor': 'Account choose competitor', + 'opportunity.projectFailed': 'Project failed', + 'opportunity.complexDecisionChain': 'Complex decision chain', + 'opportunity.budgetLimit': 'Budget limit', + 'opportunity.requirementChange': 'Requirement change', +}; diff --git a/frontend/packages/mobile/src/views/opportunity/locale/zh-CN.ts b/frontend/packages/mobile/src/views/opportunity/locale/zh-CN.ts new file mode 100644 index 0000000..eff5a87 --- /dev/null +++ b/frontend/packages/mobile/src/views/opportunity/locale/zh-CN.ts @@ -0,0 +1,30 @@ +export default { + 'opportunity.searchPlaceholder': '请输商机名称', + 'opportunity.all': '全部', + 'opportunity.mine': '我的', + 'opportunity.deptOpportunities': '部门', + 'opportunity.convertedOpportunities': '成交', + 'opportunity.deleteTitle': '确认删除商机吗?', + 'opportunity.deleteContentTip': '删除后,成员无法查到该商机,请谨慎操作!', + 'opportunity.info': '商机信息', + 'opportunity.newCreate': '新建', + 'opportunity.clearRequirements': '需求明确', + 'opportunity.schemeValidation': '方案验证', + 'opportunity.projectProposalReport': '立项汇报', + 'opportunity.businessProcurement': '商务采购', + 'opportunity.end': '完结', + 'opportunity.progress': '商机进度', + 'opportunity.intendedProducts': '意向产品', + 'opportunity.stage': '商机阶段', + 'opportunity.name': '商机名称', + 'opportunity.transferOpportunity': '转移商机', + 'opportunity.endTime': '结束时间', + 'opportunity.selectActualEndTime': '请选择结束时间', + 'opportunity.failureReason': '失败原因', + 'opportunity.selectFailureReason': '请选择失败原因', + 'opportunity.customerChooseCompetitor': '客户选择竞品', + 'opportunity.projectFailed': '立项失败', + 'opportunity.complexDecisionChain': '决策链复杂', + 'opportunity.budgetLimit': '预算限制', + 'opportunity.requirementChange': '需求变化', +}; diff --git a/frontend/packages/mobile/src/views/workbench/agent/index.vue b/frontend/packages/mobile/src/views/workbench/agent/index.vue new file mode 100644 index 0000000..880532f --- /dev/null +++ b/frontend/packages/mobile/src/views/workbench/agent/index.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/workbench/duplicateCheck/components/relatedList.vue b/frontend/packages/mobile/src/views/workbench/duplicateCheck/components/relatedList.vue new file mode 100644 index 0000000..20e1c31 --- /dev/null +++ b/frontend/packages/mobile/src/views/workbench/duplicateCheck/components/relatedList.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/workbench/duplicateCheck/config.ts b/frontend/packages/mobile/src/views/workbench/duplicateCheck/config.ts new file mode 100644 index 0000000..110cbde --- /dev/null +++ b/frontend/packages/mobile/src/views/workbench/duplicateCheck/config.ts @@ -0,0 +1,77 @@ +import { FormDesignKeyEnum } from '@lib/shared/enums/formDesignEnum'; +import { ModuleConfigEnum } from '@lib/shared/enums/moduleEnum'; +import { useI18n } from '@lib/shared/hooks/useI18n'; +import { DefaultSearchSetFormModel } from '@lib/shared/models/system/module'; + +import useAppStore from '@/store/modules/app'; +import { hasAnyPermission } from '@/utils/permission'; + +const { t } = useI18n(); +const appStore = useAppStore(); + +export type SearchTableKey = + | FormDesignKeyEnum.SEARCH_ADVANCED_CLUE + | FormDesignKeyEnum.SEARCH_ADVANCED_CUSTOMER + | FormDesignKeyEnum.SEARCH_ADVANCED_CONTACT + | FormDesignKeyEnum.SEARCH_ADVANCED_PUBLIC + | FormDesignKeyEnum.SEARCH_ADVANCED_CLUE_POOL + | FormDesignKeyEnum.SEARCH_ADVANCED_OPPORTUNITY; + +export interface ScopedOptions { + label: string; + value: SearchTableKey; + moduleKey: ModuleConfigEnum; + permission: string[]; +} + +export const scopedOptions = [ + { + label: t('common.clue'), + value: FormDesignKeyEnum.SEARCH_ADVANCED_CLUE, + moduleKey: ModuleConfigEnum.CLUE_MANAGEMENT, + permission: ['CLUE_MANAGEMENT:READ'], + }, + { + label: t('menu.cluePool'), + value: FormDesignKeyEnum.SEARCH_ADVANCED_CLUE_POOL, + moduleKey: ModuleConfigEnum.CLUE_MANAGEMENT, + permission: ['CLUE_MANAGEMENT_POOL:READ'], + }, + { + label: t('common.customer'), + value: FormDesignKeyEnum.SEARCH_ADVANCED_CUSTOMER, + moduleKey: ModuleConfigEnum.CUSTOMER_MANAGEMENT, + permission: ['CUSTOMER_MANAGEMENT:READ'], + }, + { + label: t('menu.openSea'), + value: FormDesignKeyEnum.SEARCH_ADVANCED_PUBLIC, + moduleKey: ModuleConfigEnum.CUSTOMER_MANAGEMENT, + permission: ['CUSTOMER_MANAGEMENT_POOL:READ'], + }, + { + label: t('common.opportunity'), + value: FormDesignKeyEnum.SEARCH_ADVANCED_OPPORTUNITY, + moduleKey: ModuleConfigEnum.BUSINESS_MANAGEMENT, + permission: ['OPPORTUNITY_MANAGEMENT:READ'], + }, + + { + label: t('common.contact'), + value: FormDesignKeyEnum.SEARCH_ADVANCED_CONTACT, + moduleKey: ModuleConfigEnum.CUSTOMER_MANAGEMENT, + permission: ['CUSTOMER_MANAGEMENT_CONTACT:READ'], + }, +]; + +export const lastScopedOptions = computed(() => + scopedOptions.filter((e) => + appStore.moduleConfigList?.some((m) => m.moduleKey === e.moduleKey && m.enable && hasAnyPermission(e.permission)) + ) +); + +export const defaultSearchSetFormModel: DefaultSearchSetFormModel = { + searchFields: {}, // 字段map + resultDisplay: false, // 是否开启 + sortSetting: [], // 排序列表 +}; diff --git a/frontend/packages/mobile/src/views/workbench/duplicateCheck/index.vue b/frontend/packages/mobile/src/views/workbench/duplicateCheck/index.vue new file mode 100644 index 0000000..a8daa09 --- /dev/null +++ b/frontend/packages/mobile/src/views/workbench/duplicateCheck/index.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/workbench/duplicateCheck/useSearchFormConfig.ts b/frontend/packages/mobile/src/views/workbench/duplicateCheck/useSearchFormConfig.ts new file mode 100644 index 0000000..8d5fea1 --- /dev/null +++ b/frontend/packages/mobile/src/views/workbench/duplicateCheck/useSearchFormConfig.ts @@ -0,0 +1,362 @@ +import { closeToast, showLoadingToast } from 'vant'; +import { cloneDeep } from 'lodash-es'; +import dayjs from 'dayjs'; + +import { FieldTypeEnum, FormDesignKeyEnum } from '@lib/shared/enums/formDesignEnum'; +import { useI18n } from '@lib/shared/hooks/useI18n'; +import { CommonList } from '@lib/shared/models/common'; +import { DefaultSearchSetFormModel } from '@lib/shared/models/system/module'; + +import { + getGlobalCluePoolList, + getGlobalCustomerContactList, + getGlobalCustomerList, + getGlobalModuleCount, + getGlobalOpenSeaCustomerList, + getGlobalSearchClueList, + getSearchConfig, + globalSearchOptPage, +} from '@/api/modules'; +import { hasAnyPermission } from '@/utils/permission'; + +import { defaultSearchSetFormModel, lastScopedOptions, ScopedOptions, scopedOptions, SearchTableKey } from './config'; +import { getFormConfigApiMap } from '@cordys/web/src/components/business/crm-form-create/config'; + +export const getSearchListApiMap: Record Promise>> = { + [FormDesignKeyEnum.SEARCH_ADVANCED_CLUE]: getGlobalSearchClueList, + [FormDesignKeyEnum.SEARCH_ADVANCED_CUSTOMER]: getGlobalCustomerList, + [FormDesignKeyEnum.SEARCH_ADVANCED_CONTACT]: getGlobalCustomerContactList, + [FormDesignKeyEnum.SEARCH_ADVANCED_PUBLIC]: getGlobalOpenSeaCustomerList, + [FormDesignKeyEnum.SEARCH_ADVANCED_CLUE_POOL]: getGlobalCluePoolList, + [FormDesignKeyEnum.SEARCH_ADVANCED_OPPORTUNITY]: globalSearchOptPage, +}; + +// 固定展示字段 +export const fixedFieldKeyListMap: Record = { + [FormDesignKeyEnum.SEARCH_ADVANCED_CLUE]: ['name', 'owner', 'departmentId', 'products'], // 公司名称 、负责人、部门、意向产品 + [FormDesignKeyEnum.SEARCH_ADVANCED_CLUE_POOL]: ['name', 'products', 'poolName'], // 公司名称 、意向产品、线索池名称 + [FormDesignKeyEnum.SEARCH_ADVANCED_CUSTOMER]: ['name', 'owner', 'departmentId'], // 客户名称、负责人、部门 + [FormDesignKeyEnum.SEARCH_ADVANCED_CONTACT]: ['customerId', 'name', 'phone', 'owner', 'departmentId'], // 客户名称、姓名、手机号、负责人、部门 + [FormDesignKeyEnum.SEARCH_ADVANCED_PUBLIC]: ['name', 'poolName'], // 客户名称、公海名称 + [FormDesignKeyEnum.SEARCH_ADVANCED_OPPORTUNITY]: ['name', 'customerId', 'owner', 'departmentId', 'products', 'stage'], // 商机名称、客户名称、负责人、部门、意向产品、商机阶段 +}; + +export default function useSearchFormConfig() { + const { t } = useI18n(); + + // 客户公海共用表单 + const customerConfig = [FormDesignKeyEnum.SEARCH_ADVANCED_CUSTOMER, FormDesignKeyEnum.SEARCH_ADVANCED_PUBLIC]; + // 联系人表单 + const customerContactConfig = [FormDesignKeyEnum.SEARCH_ADVANCED_CONTACT]; + // 线索线索池共用表单 + const clueConfig = [FormDesignKeyEnum.SEARCH_ADVANCED_CLUE_POOL, FormDesignKeyEnum.SEARCH_ADVANCED_CLUE]; + // 商机表单 + const opportunityConfig = [FormDesignKeyEnum.SEARCH_ADVANCED_OPPORTUNITY]; + + const formScopedConfig = [ + FormDesignKeyEnum.SEARCH_ADVANCED_OPPORTUNITY, + FormDesignKeyEnum.SEARCH_ADVANCED_CUSTOMER, + FormDesignKeyEnum.SEARCH_ADVANCED_CONTACT, + FormDesignKeyEnum.SEARCH_ADVANCED_CLUE, + ]; + + const searchFieldConfigType = [ + FieldTypeEnum.INPUT, + FieldTypeEnum.PHONE, + FieldTypeEnum.DATA_SOURCE_MULTIPLE, + FieldTypeEnum.DATA_SOURCE, + FieldTypeEnum.SERIAL_NUMBER, + ]; + + // 表单类型配置映射表 + const configMap: Partial> = { + [FormDesignKeyEnum.CUSTOMER]: customerConfig, + [FormDesignKeyEnum.CUSTOMER_CONTACT]: customerContactConfig, + [FormDesignKeyEnum.CLUE]: clueConfig, + [FormDesignKeyEnum.BUSINESS]: opportunityConfig, + }; + + const configPermissionMap: Partial> = { + [FormDesignKeyEnum.SEARCH_ADVANCED_OPPORTUNITY]: ['OPPORTUNITY_MANAGEMENT:READ'], + [FormDesignKeyEnum.SEARCH_ADVANCED_CUSTOMER]: ['CUSTOMER_MANAGEMENT:READ', 'CUSTOMER_MANAGEMENT_POOL:READ'], + [FormDesignKeyEnum.SEARCH_ADVANCED_CONTACT]: ['CUSTOMER_MANAGEMENT_CONTACT:READ'], + [FormDesignKeyEnum.SEARCH_ADVANCED_CLUE]: ['CLUE_MANAGEMENT:READ', 'CLUE_MANAGEMENT_POOL:READ'], + }; + + // 最终自定义字段对应的类型Map + const searchFieldMap = ref>({}); + const allFieldMap = ref>({}); + const configList = ref([]); + const formModel = ref(cloneDeep(defaultSearchSetFormModel)); + const searchResultMap = ref>>({ + [FormDesignKeyEnum.SEARCH_ADVANCED_CLUE]: { + describe: [], + list: [], + }, + [FormDesignKeyEnum.SEARCH_ADVANCED_CUSTOMER]: { + describe: [], + list: [], + }, + [FormDesignKeyEnum.SEARCH_ADVANCED_CONTACT]: { + describe: [], + list: [], + }, + [FormDesignKeyEnum.SEARCH_ADVANCED_PUBLIC]: { + describe: [], + list: [], + }, + [FormDesignKeyEnum.SEARCH_ADVANCED_CLUE_POOL]: { + describe: [], + list: [], + }, + [FormDesignKeyEnum.SEARCH_ADVANCED_OPPORTUNITY]: { + describe: [], + list: [], + }, + }); + + async function initSearchFormConfig() { + try { + const uniqueConfigs = [...new Set(formScopedConfig)]; + + const promises = uniqueConfigs.map(async (value) => { + const configKey = Object.keys(configMap).find((key) => + (configMap[key as FormDesignKeyEnum] as FormDesignKeyEnum[]).includes(value) + ); + + if (!configKey) return; + if (!hasAnyPermission(configPermissionMap[value as FormDesignKeyEnum])) return; + + const res = await getFormConfigApiMap[configKey as FormDesignKeyEnum](); + const customFieldsFilterConfig = res.fields.filter((e) => searchFieldConfigType.includes(e.type)); + + configMap[configKey as FormDesignKeyEnum]?.forEach((configValue) => { + searchFieldMap.value[configValue] = customFieldsFilterConfig; + allFieldMap.value[configValue] = res.fields.map((field) => { + return { + id: field.id, + key: field.businessKey ?? field.id, + label: field.name, + }; + }); + }); + }); + + await Promise.all(promises); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } + } + + const loading = ref(false); + const moduleCount = ref>(); + async function getCountList(val: string) { + if (!val) return; + try { + showLoadingToast(t('common.loading')); + loading.value = true; + const res = await getGlobalModuleCount(val); + moduleCount.value = res.reduce>((acc, item) => { + acc[item.key] = item.count; + return acc; + }, {}); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } finally { + loading.value = false; + closeToast(); + } + } + + async function initSearchDetail() { + try { + const res = await getSearchConfig(); + const { sortSetting } = res; + + const optionsMap = new Map(scopedOptions.map((item) => [item.value, item])); + + configList.value = sortSetting + .map((val: any) => optionsMap.get(val)) + .filter((option) => option && lastScopedOptions.value.includes(option)) as ScopedOptions[]; + formModel.value = cloneDeep(res); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } + } + + const systemDesc = computed(() => { + return (searchTableKey: SearchTableKey) => { + return [ + { + label: t('workbench.duplicateCheck.department'), + key: 'departmentId', + type: FieldTypeEnum.INPUT, + }, + { + label: t('workbench.duplicateCheck.opportunityStage'), + key: 'stage', + type: FieldTypeEnum.INPUT, + }, + { + label: + searchTableKey === FormDesignKeyEnum.SEARCH_ADVANCED_PUBLIC + ? t('workbench.duplicateCheck.openSeaName') + : t('workbench.duplicateCheck.leadPoolName'), + key: 'poolName', + type: FieldTypeEnum.INPUT, + }, + ]; + }; + }); + + const displayedDescList = computed(() => { + return (searchTableKey: SearchTableKey): any[] => { + if (!searchTableKey) return []; + // 配置选择的id + const selectedFieldIdList = formModel.value.searchFields[searchTableKey]; + + // 全量fields列表 + const fieldList = allFieldMap.value[searchTableKey]; + + // 转换成同属性后查重 + const selectedFieldKeyList = + selectedFieldIdList?.map((id: string) => fieldList.find((i) => i.id === id)?.key) || []; + + const displayedColumnKeyList = [...new Set([...fixedFieldKeyListMap[searchTableKey], ...selectedFieldKeyList])]; + return displayedColumnKeyList + .map((item) => [...fieldList, ...systemDesc.value(searchTableKey)].find((i) => i.key === item)) + .filter(Boolean); + }; + }); + + function transformData(item: any, searchTableKey: SearchTableKey) { + const fieldMap = new Map(item.moduleFields?.map((field: any) => [field.fieldId, field.fieldValue])); + const fieldAttr: Record = Object.fromEntries( + displayedDescList + .value(searchTableKey) + ?.filter((i: any) => i.id && fieldMap.has(i.id)) + .map((i: any) => [i.key, fieldMap.get(i.id)!]) + ); + return { + ...item, + ...fieldAttr, + }; + } + + const createTimeDesc = { + label: t('common.createTime'), + key: 'createTime', + valueSlotName: 'render', + render: (row: any) => { + return dayjs(row.createTime).format('YYYY-MM-DD'); + }, + }; + + const lastFollowTimeDesc = { + label: t('workbench.duplicateCheck.lastFollowUpDate'), + key: 'followTime', + valueSlotName: 'render', + render: (row: any) => { + return row.followTime ? dayjs(row.followTime).format('YYYY-MM-DD') : '-'; + }, + }; + + function initSearchListConfig() { + configList.value.forEach((e) => { + const resultDesc = displayedDescList.value(e.value as SearchTableKey).map((field) => { + if (field.key === 'name' && e.value !== FormDesignKeyEnum.SEARCH_ADVANCED_CONTACT) { + return { + label: field.label, + key: field.key, + valueSlotName: 'name', + }; + } + + if (field.key === 'customerId') { + return { + label: field.label, + key: field.key, + valueSlotName: 'customerName', + }; + } + + // 部门 + if (field.key === 'departmentId') { + return { + label: field.label, + key: 'departmentName', + }; + } + + // 负责人 + if (field.key === 'owner') { + return { + label: field.label, + key: 'ownerName', + }; + } + + if (field.key === 'stage') { + return { + label: field.label, + key: field.key, + valueSlotName: 'render', + render: (row: any) => row.stageName ?? '-', + }; + } + + if (field.type === FieldTypeEnum.DATA_SOURCE_MULTIPLE) { + return { + label: field.label, + key: field.key, + valueSlotName: 'render', + render: (row: any) => { + return row[field.key]?.join(';') ?? '-'; + }, + }; + } + + return { + label: field.label, + key: field.key, + }; + }); + + const hasCreateTimeDesc = [ + FormDesignKeyEnum.SEARCH_ADVANCED_CLUE_POOL, + FormDesignKeyEnum.SEARCH_ADVANCED_PUBLIC, + ].includes(e.value) + ? [] + : [createTimeDesc]; + + const hasLastFollowDesc = [FormDesignKeyEnum.SEARCH_ADVANCED_CONTACT].includes(e.value) + ? [] + : [lastFollowTimeDesc]; + + searchResultMap.value[e.value as SearchTableKey].describe = [ + ...resultDesc, + ...hasLastFollowDesc, + ...hasCreateTimeDesc, + ]; + }); + } + + return { + initSearchFormConfig, + getCountList, + initSearchDetail, + moduleCount, + loading, + searchFieldMap, + allFieldMap, + configList, + formModel, + searchResultMap, + getSearchListApiMap, + transformData, + initSearchListConfig, + }; +} diff --git a/frontend/packages/mobile/src/views/workbench/index.vue b/frontend/packages/mobile/src/views/workbench/index.vue new file mode 100644 index 0000000..f99b50b --- /dev/null +++ b/frontend/packages/mobile/src/views/workbench/index.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/frontend/packages/mobile/src/views/workbench/locale/en-US.ts b/frontend/packages/mobile/src/views/workbench/locale/en-US.ts new file mode 100644 index 0000000..0282156 --- /dev/null +++ b/frontend/packages/mobile/src/views/workbench/locale/en-US.ts @@ -0,0 +1,28 @@ +export default { + 'workbench.searchPlaceholder': 'Please input the account name or phone number', + 'workbench.quickEntry': 'Quick Entry', + 'workbench.duplicateCheck': 'Search', + 'workbench.duplicateCheck.result': 'Account related', + 'workbench.duplicateCheck.contactResult': 'Contact result', + 'workbench.duplicateCheck.status': 'Duplicate status', + 'workbench.duplicateCheck.relatedOpportunity': 'Related opportunity', + 'workbench.duplicateCheck.relatedClue': 'Related lead', + 'workbench.duplicateCheck.relatedClues': 'Lead relations', + 'workbench.duplicateCheck.clueStage': 'Lead stage', + 'workbench.duplicateCheck.noSearchData': 'No search results found', + 'workbench.duplicateCheck.duplicate': 'Duplicate', + 'workbench.duplicateCheck.similar': 'Similar', + 'workbench.duplicateCheck.contactCompany': 'Company', + 'workbench.duplicateCheck.contactorName': 'Contact name', + 'workbench.duplicateCheck.contactorPhoneNumber': 'The contact phone', + 'workbench.duplicateCheck.accountName': 'Account name', + 'workbench.duplicateCheck.contactName': 'name', + 'workbench.duplicateCheck.contactStatus': 'status', + 'workbench.duplicateCheck.searchTypeTitle': '{title} related', + 'workbench.duplicateCheck.department': 'Department name', + 'workbench.duplicateCheck.opportunityStage': 'Opportunity stage', + 'workbench.duplicateCheck.openSeaName': 'OpenSea name', + 'workbench.duplicateCheck.leadPoolName': 'Lead pool name', + 'workbench.duplicateCheck.lastFollowUpDate': 'Last follow-up time', + 'workbench.agent.select': 'Select', +}; diff --git a/frontend/packages/mobile/src/views/workbench/locale/zh-CN.ts b/frontend/packages/mobile/src/views/workbench/locale/zh-CN.ts new file mode 100644 index 0000000..e8187a9 --- /dev/null +++ b/frontend/packages/mobile/src/views/workbench/locale/zh-CN.ts @@ -0,0 +1,28 @@ +export default { + 'workbench.searchPlaceholder': '请输入客户名或手机号', + 'workbench.quickEntry': '快速入口', + 'workbench.duplicateCheck': '搜索', + 'workbench.duplicateCheck.result': '客户相关', + 'workbench.duplicateCheck.contactResult': '联系人相关', + 'workbench.duplicateCheck.status': '重复状态', + 'workbench.duplicateCheck.relatedOpportunity': '对应商机', + 'workbench.duplicateCheck.relatedClue': '对应线索', + 'workbench.duplicateCheck.relatedClues': '线索相关', + 'workbench.duplicateCheck.clueStage': '线索阶段', + 'workbench.duplicateCheck.noSearchData': '无搜索结果', + 'workbench.duplicateCheck.duplicate': '重复', + 'workbench.duplicateCheck.similar': '相似', + 'workbench.duplicateCheck.contactCompany': '公司', + 'workbench.duplicateCheck.contactorName': '联系人姓名', + 'workbench.duplicateCheck.contactorPhoneNumber': '联系人电话', + 'workbench.duplicateCheck.customerName': '客户名称', + 'workbench.duplicateCheck.contactName': '姓名', + 'workbench.duplicateCheck.contactStatus': '状态', + 'workbench.duplicateCheck.searchTypeTitle': '{title}相关', + 'workbench.duplicateCheck.department': '部门', + 'workbench.duplicateCheck.opportunityStage': '商机阶段', + 'workbench.duplicateCheck.openSeaName': '公海名称', + 'workbench.duplicateCheck.leadPoolName': '线索池名称', + 'workbench.duplicateCheck.lastFollowUpDate': '最新跟进时间', + 'workbench.agent.select': '选择', +}; diff --git a/frontend/packages/mobile/tailwind.config.mjs b/frontend/packages/mobile/tailwind.config.mjs new file mode 100644 index 0000000..17b0ee7 --- /dev/null +++ b/frontend/packages/mobile/tailwind.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{vue,ts,jsx,tsx}'], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/frontend/packages/mobile/tsconfig.json b/frontend/packages/mobile/tsconfig.json new file mode 100644 index 0000000..20145c3 --- /dev/null +++ b/frontend/packages/mobile/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "src/**/*", + "types/**/*", + "*.d.ts" + ], + "compilerOptions": { + "baseUrl": "./", + "paths": { + // 路径映射 + "@/*": [ + "./src/*" + ], + "#/*": [ + "types/*" + ] + }, + }, +} \ No newline at end of file diff --git a/frontend/packages/web/.env.development b/frontend/packages/web/.env.development new file mode 100644 index 0000000..5685312 --- /dev/null +++ b/frontend/packages/web/.env.development @@ -0,0 +1,2 @@ +VITE_API_BASE_URL= 'front' +VITE_DEV_DOMAIN='http://127.0.0.1:8081' diff --git a/frontend/packages/web/.env.production b/frontend/packages/web/.env.production new file mode 100644 index 0000000..dc15ecf --- /dev/null +++ b/frontend/packages/web/.env.production @@ -0,0 +1 @@ +VITE_API_BASE_URL= '' \ No newline at end of file diff --git a/frontend/packages/web/.eslintignore b/frontend/packages/web/.eslintignore new file mode 100644 index 0000000..8c235cf --- /dev/null +++ b/frontend/packages/web/.eslintignore @@ -0,0 +1,7 @@ +/*.json +/src/**/*.json +dist +postcss.config.js +*.md +/src/assets/icon-font/iconfont.js +/src/assets/fonts/AlibabaPuHuiTi-3-55-Regular-normal.js \ No newline at end of file diff --git a/frontend/packages/web/.eslintrc-auto-import.json b/frontend/packages/web/.eslintrc-auto-import.json new file mode 100644 index 0000000..8086284 --- /dev/null +++ b/frontend/packages/web/.eslintrc-auto-import.json @@ -0,0 +1,74 @@ +{ + "globals": { + "Component": true, + "ComponentPublicInstance": true, + "ComputedRef": true, + "DirectiveBinding": true, + "EffectScope": true, + "ExtractDefaultPropTypes": true, + "ExtractPropTypes": true, + "ExtractPublicPropTypes": true, + "InjectionKey": true, + "MaybeRef": true, + "MaybeRefOrGetter": true, + "PropType": true, + "Ref": true, + "VNode": true, + "WritableComputedRef": true, + "computed": true, + "createApp": true, + "customRef": true, + "defineAsyncComponent": true, + "defineComponent": true, + "effectScope": true, + "getCurrentInstance": true, + "getCurrentScope": true, + "h": true, + "inject": true, + "isProxy": true, + "isReactive": true, + "isReadonly": true, + "isRef": true, + "markRaw": true, + "nextTick": true, + "onActivated": true, + "onBeforeMount": true, + "onBeforeUnmount": true, + "onBeforeUpdate": true, + "onDeactivated": true, + "onErrorCaptured": true, + "onMounted": true, + "onRenderTracked": true, + "onRenderTriggered": true, + "onScopeDispose": true, + "onServerPrefetch": true, + "onUnmounted": true, + "onUpdated": true, + "onWatcherCleanup": true, + "provide": true, + "reactive": true, + "readonly": true, + "ref": true, + "resolveComponent": true, + "shallowReactive": true, + "shallowReadonly": true, + "shallowRef": true, + "toRaw": true, + "toRef": true, + "toRefs": true, + "toValue": true, + "triggerRef": true, + "unref": true, + "useAttrs": true, + "useCssModule": true, + "useCssVars": true, + "useId": true, + "useModel": true, + "useSlots": true, + "useTemplateRef": true, + "watch": true, + "watchEffect": true, + "watchPostEffect": true, + "watchSyncEffect": true + } +} diff --git a/frontend/packages/web/README.md b/frontend/packages/web/README.md new file mode 100644 index 0000000..6dd2418 --- /dev/null +++ b/frontend/packages/web/README.md @@ -0,0 +1,39 @@ +# web + +This template should help get you started developing with Vue 3 in Vite. + +## Recommended IDE Setup + +[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). + +## Type Support for `.vue` Imports in TS + +TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types. + +## Customize configuration + +See [Vite Configuration Reference](https://vite.dev/config/). + +## Project Setup + +```sh +pnpm install +``` + +### Compile and Hot-Reload for Development + +```sh +pnpm dev +``` + +### Type-Check, Compile and Minify for Production + +```sh +pnpm build +``` + +### Lint with [ESLint](https://eslint.org/) + +```sh +pnpm lint +``` diff --git a/frontend/packages/web/auto-import.d.ts b/frontend/packages/web/auto-import.d.ts new file mode 100644 index 0000000..2cb0ca4 --- /dev/null +++ b/frontend/packages/web/auto-import.d.ts @@ -0,0 +1,77 @@ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +export {} +declare global { + const EffectScope: typeof import('vue')['EffectScope']; + const computed: typeof import('vue')['computed']; + const createApp: typeof import('vue')['createApp']; + const customRef: typeof import('vue')['customRef']; + const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']; + const defineComponent: typeof import('vue')['defineComponent']; + const effectScope: typeof import('vue')['effectScope']; + const getCurrentInstance: typeof import('vue')['getCurrentInstance']; + const getCurrentScope: typeof import('vue')['getCurrentScope']; + const h: typeof import('vue')['h']; + const inject: typeof import('vue')['inject']; + const isProxy: typeof import('vue')['isProxy']; + const isReactive: typeof import('vue')['isReactive']; + const isReadonly: typeof import('vue')['isReadonly']; + const isRef: typeof import('vue')['isRef']; + const markRaw: typeof import('vue')['markRaw']; + const nextTick: typeof import('vue')['nextTick']; + const onActivated: typeof import('vue')['onActivated']; + const onBeforeMount: typeof import('vue')['onBeforeMount']; + const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']; + const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']; + const onDeactivated: typeof import('vue')['onDeactivated']; + const onErrorCaptured: typeof import('vue')['onErrorCaptured']; + const onMounted: typeof import('vue')['onMounted']; + const onRenderTracked: typeof import('vue')['onRenderTracked']; + const onRenderTriggered: typeof import('vue')['onRenderTriggered']; + const onScopeDispose: typeof import('vue')['onScopeDispose']; + const onServerPrefetch: typeof import('vue')['onServerPrefetch']; + const onUnmounted: typeof import('vue')['onUnmounted']; + const onUpdated: typeof import('vue')['onUpdated']; + const provide: typeof import('vue')['provide']; + const reactive: typeof import('vue')['reactive']; + const readonly: typeof import('vue')['readonly']; + const ref: typeof import('vue')['ref']; + const resolveComponent: typeof import('vue')['resolveComponent']; + const shallowReactive: typeof import('vue')['shallowReactive']; + const shallowReadonly: typeof import('vue')['shallowReadonly']; + const shallowRef: typeof import('vue')['shallowRef']; + const toRaw: typeof import('vue')['toRaw']; + const toRef: typeof import('vue')['toRef']; + const toRefs: typeof import('vue')['toRefs']; + const toValue: typeof import('vue')['toValue']; + const triggerRef: typeof import('vue')['triggerRef']; + const unref: typeof import('vue')['unref']; + const useAttrs: typeof import('vue')['useAttrs']; + const useCssModule: typeof import('vue')['useCssModule']; + const useCssVars: typeof import('vue')['useCssVars']; + const useSlots: typeof import('vue')['useSlots']; + const watch: typeof import('vue')['watch']; + const watchEffect: typeof import('vue')['watchEffect']; + const watchPostEffect: typeof import('vue')['watchPostEffect']; + const watchSyncEffect: typeof import('vue')['watchSyncEffect']; +} +// for type re-export +declare global { + // @ts-ignore + export type { + Component, + ComponentPublicInstance, + ComputedRef, + ExtractDefaultPropTypes, + ExtractPropTypes, + ExtractPublicPropTypes, + InjectionKey, + PropType, + Ref, + VNode, + WritableComputedRef, + } from 'vue'; + import('vue'); +} diff --git a/frontend/packages/web/babel.config.cjs b/frontend/packages/web/babel.config.cjs new file mode 100644 index 0000000..dd6f53c --- /dev/null +++ b/frontend/packages/web/babel.config.cjs @@ -0,0 +1,3 @@ +module.exports = { + plugins: ['@vue/babel-plugin-jsx'], +}; diff --git a/frontend/packages/web/components.d.ts b/frontend/packages/web/components.d.ts new file mode 100644 index 0000000..f8a7471 --- /dev/null +++ b/frontend/packages/web/components.d.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 +import '@vue/runtime-core' + +export {}; + +declare module '@vue/runtime-core' { + export interface GlobalComponents { + CrmColorSelect: typeof import('./src/components/pure/crm-color-select/index.vue')['default']; + CrmIconFont: typeof import('./src/components/pure/crm-icon-font/index.vue')['default']; + RouterLink: typeof import('vue-router')['RouterLink']; + RouterView: typeof import('vue-router')['RouterView']; + } +} diff --git a/frontend/packages/web/config/plugin/compress.ts b/frontend/packages/web/config/plugin/compress.ts new file mode 100644 index 0000000..45a313e --- /dev/null +++ b/frontend/packages/web/config/plugin/compress.ts @@ -0,0 +1,31 @@ +/** + * Used to package and output gzip. Note that this does not work properly in Vite, the specific reason is still being investigated + * gzip压缩 + * https://github.com/anncwb/vite-plugin-compression + */ +import type { Plugin } from 'vite'; +import compressPlugin from 'vite-plugin-compression'; + +export default function configCompressPlugin(compress: 'gzip' | 'brotli', deleteOriginFile = false): Plugin | Plugin[] { + const plugins: Plugin[] = []; + + if (compress === 'gzip') { + plugins.push( + compressPlugin({ + ext: '.gz', + deleteOriginFile, + }) + ); + } + + if (compress === 'brotli') { + plugins.push( + compressPlugin({ + ext: '.br', + algorithm: 'brotliCompress', + deleteOriginFile, + }) + ); + } + return plugins; +} diff --git a/frontend/packages/web/config/plugin/visualizer.ts b/frontend/packages/web/config/plugin/visualizer.ts new file mode 100644 index 0000000..bf306bf --- /dev/null +++ b/frontend/packages/web/config/plugin/visualizer.ts @@ -0,0 +1,18 @@ +/** + * Generation packaging analysis + * 生成打包分析 + */ +import { isReportMode } from '../utils'; +import visualizer from 'rollup-plugin-visualizer'; + +export default function configVisualizerPlugin() { + if (isReportMode()) { + return visualizer({ + filename: './node_modules/.cache/visualizer/stats.html', + open: true, + gzipSize: true, + brotliSize: true, + }); + } + return []; +} diff --git a/frontend/packages/web/config/utils/index.ts b/frontend/packages/web/config/utils/index.ts new file mode 100644 index 0000000..a1e5d12 --- /dev/null +++ b/frontend/packages/web/config/utils/index.ts @@ -0,0 +1,9 @@ +/** + * Whether to generate package preview + * 是否生成打包报告 + */ +export default {}; + +export function isReportMode(): boolean { + return process.env.REPORT === 'true'; +} diff --git a/frontend/packages/web/config/vite.config.base.ts b/frontend/packages/web/config/vite.config.base.ts new file mode 100644 index 0000000..3b9b9f8 --- /dev/null +++ b/frontend/packages/web/config/vite.config.base.ts @@ -0,0 +1,75 @@ +import vue from '@vitejs/plugin-vue'; +import vueJsx from '@vitejs/plugin-vue-jsx'; +import { resolve } from 'path'; +import AutoImport from 'unplugin-auto-import/vite'; +import { defineConfig } from 'vite'; +import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'; +import vueSetupExtend from 'vite-plugin-vue-setup-extend'; +import svgLoader from 'vite-svg-loader'; + +export default defineConfig({ + plugins: [ + vue(), + vueJsx(), + vueSetupExtend(), + svgLoader({ svgoConfig: {} }), + createSvgIconsPlugin({ + // 指定需要缓存的图标文件夹 + iconDirs: [resolve(process.cwd(), 'src/assets/svg'), resolve(process.cwd(), 'public/images')], // 与本地储存地址一致 + // 指定symbolId格式 + symbolId: 'icon-[name]', + }), + AutoImport({ + include: [ + /\.[tj]sx?$/, // .ts, .tsx, .js, .jsx + /\.vue$/, + /\.vue\?vue/, // .vue + /\.md$/, // .md + ], + imports: ['vue'], + dts: 'src/auto-import.d.ts', + eslintrc: { + enabled: true, // <-- this + }, + }), + ], + resolve: { + alias: [ + { + find: '@locale', + replacement: resolve(__dirname, '../src/locale'), + }, + { + find: '@', + replacement: resolve(__dirname, '../src'), + }, + { + find: 'vue-i18n', + replacement: 'vue-i18n/dist/vue-i18n.esm-bundler.js', // 解决 vue-i18n 依赖包报错 + }, + { + find: 'vue', + replacement: 'vue/dist/vue.esm-bundler.js', // compile template + }, + ], + extensions: ['.ts', '.js', '.jsx', '.tsx', '.json', '.vue'], + }, + define: { + 'process.env': {}, + // 定义特性标志 + '__VUE_OPTIONS_API__': true, + '__VUE_PROD_DEVTOOLS__': false, + // 设置hydration不匹配详细信息的标志 + '__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': true, + }, + css: { + preprocessorOptions: { + less: { + modifyVars: { + hack: `true; @import (reference) "${resolve('src/assets/style/var.less')}";`, + }, + javascriptEnabled: true, + }, + }, + }, +}); diff --git a/frontend/packages/web/config/vite.config.dev.ts b/frontend/packages/web/config/vite.config.dev.ts new file mode 100644 index 0000000..d069ffd --- /dev/null +++ b/frontend/packages/web/config/vite.config.dev.ts @@ -0,0 +1,54 @@ +import baseConfig from './vite.config.base'; +import { config } from 'dotenv'; +import { mergeConfig } from 'vite'; +import eslint from 'vite-plugin-eslint'; + +// 注入本地/开发配置环境变量(先导入的配置优先级高) +config({ path: ['.env.development.local', '.env.development'] }); + +export default mergeConfig( + { + mode: 'development', + server: { + open: true, + fs: { + strict: true, + }, + proxy: { + '/sse': { + target: process.env.VITE_DEV_DOMAIN, + changeOrigin: true, + }, + '/front': { + target: process.env.VITE_DEV_DOMAIN, + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/front/, ''), + }, + '/pic': { + target: process.env.VITE_DEV_DOMAIN, + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/front\/pic/, ''), + }, + '/attachment': { + target: process.env.VITE_DEV_DOMAIN, + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/front\/attachment/, ''), + }, + '/ui': { + target: process.env.VITE_DEV_DOMAIN, + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/front\/ui/, ''), + }, + }, + }, + plugins: [ + eslint({ + overrideConfigFile: 'eslint.config.cjs', + cache: false, + include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.vue'], + exclude: ['node_modules'], + }), + ], + }, + baseConfig +); diff --git a/frontend/packages/web/config/vite.config.prod.ts b/frontend/packages/web/config/vite.config.prod.ts new file mode 100644 index 0000000..2d6163c --- /dev/null +++ b/frontend/packages/web/config/vite.config.prod.ts @@ -0,0 +1,31 @@ +import configCompressPlugin from './plugin/compress'; +import configVisualizerPlugin from './plugin/visualizer'; +import baseConfig from './vite.config.base'; +import legacy from '@vitejs/plugin-legacy'; +import { mergeConfig } from 'vite'; + +export default mergeConfig( + { + mode: 'production', + plugins: [ + configCompressPlugin('gzip'), + configVisualizerPlugin(), + // 兼容性配置,配合package.json中的browserslist使用 + legacy({ + targets: ['defaults', 'not IE 11'], + }), + ], + build: { + rollupOptions: { + output: { + manualChunks: { + vue: ['vue', 'vue-router', 'pinia', '@vueuse/core', 'vue-i18n'], + chart: ['echarts', 'vue-echarts'], + }, + }, + }, + chunkSizeWarningLimit: 2000, + }, + }, + baseConfig +); diff --git a/frontend/packages/web/env.d.ts b/frontend/packages/web/env.d.ts new file mode 100644 index 0000000..7615bc0 --- /dev/null +++ b/frontend/packages/web/env.d.ts @@ -0,0 +1,19 @@ +/// + +declare module '*.vue' { + import { DefineComponent } from 'vue'; + + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + const component: DefineComponent<{}, {}, any>; + export default component; +} +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string; + readonly VITE_DEV_DOMAIN: string; // 开发环境域名 +} + +declare module 'xml-beautify' { + export default class xmlBeautify { + beautify: (xml: string) => string; + } +} diff --git a/frontend/packages/web/eslint.config.cjs b/frontend/packages/web/eslint.config.cjs new file mode 100644 index 0000000..153cab6 --- /dev/null +++ b/frontend/packages/web/eslint.config.cjs @@ -0,0 +1,174 @@ +const { defineConfig, globalIgnores } = require('eslint/config'); + +const pluginVue = require('eslint-plugin-vue'); +const parser = require('vue-eslint-parser'); +const globals = require('globals'); +const simpleImportSort = require('eslint-plugin-simple-import-sort'); + +const { fixupConfigRules } = require('@eslint/compat'); + +const js = require('@eslint/js'); + +const { FlatCompat } = require('@eslint/eslintrc'); + +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); +const path = require('path'); + +module.exports = defineConfig([ + { + files: ['**/*.js', '**/*.ts', '**/*.vue'], + languageOptions: { + parser, + parserOptions: { + parser: '@typescript-eslint/parser', // parse + +
    + + + diff --git a/frontend/packages/web/package.json b/frontend/packages/web/package.json new file mode 100644 index 0000000..9b853bf --- /dev/null +++ b/frontend/packages/web/package.json @@ -0,0 +1,87 @@ +{ + "name": "@cordys/web", + "description": "PC web", + "version": "1.0.0", + "private": true, + "author": "MeterSphere Team", + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite --config ./config/vite.config.dev.ts", + "build": "vue-tsc --noEmit && vite build --config ./config/vite.config.prod.ts", + "build:local": "vue-tsc --noEmit && vite build --config ./config/vite.config.prod.ts --mode development", + "build:localProd": "vue-tsc --noEmit && vite build --config ./config/vite.config.prod.ts --mode localProd", + "report": "cross-env REPORT=true npm run build", + "preview": "npm run build && vite preview --host", + "type:check": "vue-tsc --noEmit --skipLibCheck -p tsconfig.json", + "lint-staged": "npx lint-staged", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", + "lint:styles": "stylelint 'src/**/*.{vue,html,css,scss,less}' --fix" + }, + "dependencies": { + "@antv/x6": "^3.1.7", + "@antv/x6-vue-shape": "^3.0.2", + "@types/color": "^4.2.1", + "@vicons/ionicons5": "^0.13.0", + "@wecom/jssdk": "^2.4.0", + "echarts": "^6.1.0", + "jsencrypt": "^3.5.4", + "naive-ui": "^2.44.1", + "nprogress": "^0.2.0", + "seemly": "^0.3.10", + "sortablejs": "^1.15.7", + "vfonts": "^0.0.3", + "vue-draggable-plus": "^0.6.1", + "vue3-colorpicker": "^2.3.0" + }, + "devDependencies": { + "@types/sortablejs": "^1.15.9", + "@vitejs/plugin-legacy": "^8.0.2", + "@vitejs/plugin-vue": "^6.0.7", + "@vitejs/plugin-vue-jsx": "^4.2.0", + "@vue/babel-plugin-jsx": "^1.5.0", + "@vue/test-utils": "^2.4.10", + "color": "^4.2.3", + "consola": "^2.15.3", + "fast-glob": "^3.3.3", + "jsdom": "^22.1.0", + "less": "4.3.0", + "less-loader": "^11.1.4", + "rollup": "^4.61.0", + "rollup-plugin-visualizer": "^5.14.0", + "sass": "^1.100.0", + "typescript": "5.9.3", + "unplugin-auto-import": "^0.16.7", + "unplugin-vue-components": "^0.24.1", + "vite": "^7.3.5", + "vite-plugin-compression": "^0.5.1", + "vite-plugin-eslint": "^1.8.1", + "vite-plugin-svg-icons": "^2.0.1", + "vite-plugin-vue-setup-extend": "^0.4.0", + "vite-svg-loader": "^5.1.1" + }, + "lint-staged": { + "*.{js,ts,jsx,tsx}": [ + "prettier --write", + "eslint --fix" + ], + "*.vue": [ + "stylelint --fix", + "prettier --write", + "eslint --fix" + ], + "*.{less,css,scss,sass}": [ + "stylelint --fix --custom-syntax postcss-less", + "prettier --write" + ] + }, + "engines": { + "node": ">=18.0.0" + }, + "browserslist": [ + "> 0.5%", + "last 2 versions", + "not dead", + "not IE 11" + ] +} \ No newline at end of file diff --git a/frontend/packages/web/public/favicon.ico b/frontend/packages/web/public/favicon.ico new file mode 100644 index 0000000..e06b02b Binary files /dev/null and b/frontend/packages/web/public/favicon.ico differ diff --git a/frontend/packages/web/public/images/login-banner.png b/frontend/packages/web/public/images/login-banner.png new file mode 100644 index 0000000..1c15dd4 Binary files /dev/null and b/frontend/packages/web/public/images/login-banner.png differ diff --git a/frontend/packages/web/public/images/login-logo.svg b/frontend/packages/web/public/images/login-logo.svg new file mode 100644 index 0000000..1914870 --- /dev/null +++ b/frontend/packages/web/public/images/login-logo.svg @@ -0,0 +1 @@ +CORDYS \ No newline at end of file diff --git a/frontend/packages/web/public/images/logo_CORDYS.svg b/frontend/packages/web/public/images/logo_CORDYS.svg new file mode 100644 index 0000000..1914870 --- /dev/null +++ b/frontend/packages/web/public/images/logo_CORDYS.svg @@ -0,0 +1 @@ +CORDYS \ No newline at end of file diff --git a/frontend/packages/web/public/logo.svg b/frontend/packages/web/public/logo.svg new file mode 100644 index 0000000..cd8939c --- /dev/null +++ b/frontend/packages/web/public/logo.svg @@ -0,0 +1 @@ +CORDYS \ No newline at end of file diff --git a/frontend/packages/web/public/templates/user_import_cn.xlsx b/frontend/packages/web/public/templates/user_import_cn.xlsx new file mode 100644 index 0000000..08d497d Binary files /dev/null and b/frontend/packages/web/public/templates/user_import_cn.xlsx differ diff --git a/frontend/packages/web/public/templates/user_import_en.xlsx b/frontend/packages/web/public/templates/user_import_en.xlsx new file mode 100644 index 0000000..9071622 Binary files /dev/null and b/frontend/packages/web/public/templates/user_import_en.xlsx differ diff --git a/frontend/packages/web/src/App.vue b/frontend/packages/web/src/App.vue new file mode 100644 index 0000000..2695859 --- /dev/null +++ b/frontend/packages/web/src/App.vue @@ -0,0 +1,182 @@ + + + diff --git a/frontend/packages/web/src/api/http/checkStatus.ts b/frontend/packages/web/src/api/http/checkStatus.ts new file mode 100644 index 0000000..87ae5b0 --- /dev/null +++ b/frontend/packages/web/src/api/http/checkStatus.ts @@ -0,0 +1,90 @@ +import { useI18n } from '@lib/shared/hooks/useI18n'; + +import useDiscreteApi from '@/hooks/useDiscreteApi'; +import useUser from '@/hooks/useUser'; +import router from '@/router'; +import { NO_RESOURCE_ROUTE_NAME } from '@/router/constants'; + +export default function checkStatus( + status: number, + msg: string, + msgDetail: string | Record, + code?: number, + noErrorTip?: boolean +): void { + const { message } = useDiscreteApi({ + messageProviderProps: { + max: 1, // 最大显示一条消息 + }, + }); + const { t } = useI18n(); + const { logout, isLoginPage, isWhiteListPage } = useUser(); + let errMessage = ''; + switch (status) { + case 400: + errMessage = `${msg}`; + break; + case 401: { + errMessage = msg || t('api.errMsg401'); + if (!isLoginPage() && !isWhiteListPage()) { + // 不是登录页再调用logout + logout(); + } + break; + } + case 403: + if (router.currentRoute.value.name !== NO_RESOURCE_ROUTE_NAME) { + router.push({ name: NO_RESOURCE_ROUTE_NAME }); + } + break; + // 404请求不存在 + case 404: + errMessage = msg || t('api.errMsg404'); + break; + case 405: + errMessage = msg || t('api.errMsg405'); + break; + case 408: + errMessage = msg || t('api.errMsg408'); + break; + case 500: + // 101003 用于查重功能,表示模块未开启,不需要弹出消息 + if (code === 101003) { + errMessage = ''; + } else { + errMessage = msg || t('api.errMsg500'); + } + break; + case 501: + errMessage = msg || t('api.errMsg501'); + break; + case 502: + errMessage = msg || t('api.errMsg502'); + break; + case 503: + errMessage = msg || t('api.errMsg503'); + break; + case 504: + errMessage = msg || t('api.errMsg504'); + break; + case 505: + errMessage = msg || t('api.errMsg505'); + break; + default: + } + + if (msgDetail && !noErrorTip) { + if (typeof msgDetail === 'object') { + errMessage = Object.values(msgDetail) + .map((e) => e) + .join('\n'); + } else { + errMessage = msgDetail; + } + message.destroyAll(); + message.error(errMessage); + } else if (errMessage && !noErrorTip) { + message.destroyAll(); + message.error(errMessage); + } +} diff --git a/frontend/packages/web/src/api/http/index.ts b/frontend/packages/web/src/api/http/index.ts new file mode 100644 index 0000000..3dd997f --- /dev/null +++ b/frontend/packages/web/src/api/http/index.ts @@ -0,0 +1,16 @@ +import createAxios from '@lib/shared/api/http'; + +import useDiscreteApi from '@/hooks/useDiscreteApi'; +import useAppStore from '@/store/modules/app'; + +import checkStatus from './checkStatus'; + +const { message } = useDiscreteApi(); + +const CDR = createAxios({ + useAppStore, + showErrorMsg: message.error, + checkStatus, +}); + +export default CDR; diff --git a/frontend/packages/web/src/api/modules/index.ts b/frontend/packages/web/src/api/modules/index.ts new file mode 100644 index 0000000..8735343 --- /dev/null +++ b/frontend/packages/web/src/api/modules/index.ts @@ -0,0 +1,841 @@ +import createAxios from '@lib/shared/api/http'; +import useAgentApi from '@lib/shared/api/modules/agent'; +import useClueApi from '@lib/shared/api/modules/clue'; +import useContractApi from '@lib/shared/api/modules/contract'; +import useCustomerApi from '@lib/shared/api/modules/customer'; +import useCustomFormApi from '@lib/shared/api/modules/customForm'; +import useDashboard from '@lib/shared/api/modules/dashboard'; +import useFollowApi from '@lib/shared/api/modules/follow'; +import useHomeApi from '@lib/shared/api/modules/home'; +import useOpportunityApi from '@lib/shared/api/modules/opportunity'; +import useOrderApi from '@lib/shared/api/modules/order'; +import useProductApi from '@lib/shared/api/modules/product'; +import useSysApi from '@lib/shared/api/modules/sys'; +import useLicenseApi from '@lib/shared/api/modules/system/authorizedManagement'; +import useBusinessApi from '@lib/shared/api/modules/system/business'; +import useLoginApi from '@lib/shared/api/modules/system/login'; +import useMessageApi from '@lib/shared/api/modules/system/message'; +import useModuleApi from '@lib/shared/api/modules/system/module'; +import useOrgApi from '@lib/shared/api/modules/system/org'; +import useProcessApi from '@lib/shared/api/modules/system/process'; +import useRoleApi from '@lib/shared/api/modules/system/role'; + +import useDiscreteApi from '@/hooks/useDiscreteApi'; + +import checkStatus from '../http/checkStatus'; + +const { message } = useDiscreteApi(); + +const CDR = createAxios({ + showErrorMsg: message.error, + checkStatus, +}); + +const orgApi = useOrgApi(CDR); +const sysApi = useSysApi(CDR); +const clueApi = useClueApi(CDR); +const roleApi = useRoleApi(CDR); +const homeApi = useHomeApi(CDR); +const loginApi = useLoginApi(CDR); +const agentApi = useAgentApi(CDR); +const moduleApi = useModuleApi(CDR); +const followApi = useFollowApi(CDR); +const productApi = useProductApi(CDR); +const messageApi = useMessageApi(CDR); +const licenseApi = useLicenseApi(CDR); +const customerApi = useCustomerApi(CDR); +const businessApi = useBusinessApi(CDR); +const dashboardApi = useDashboard(CDR); +const opportunityApi = useOpportunityApi(CDR); +const contractApi = useContractApi(CDR); +const orderApi = useOrderApi(CDR); +const customFormApi = useCustomFormApi(CDR); +const processApi = useProcessApi(CDR); + +export const { + addCustomForm, + updateCustomForm, + getCustomFormDetail, + saveCustomFormAdmins, + getCustomFormAdmins, + relateCustomFormMember, + getCustomFormRoles, + getCustomFormRoleUsers, + getCustomFormRoleUserDeptTree, + getCustomFormRoleUserRoleTree, + removeCustomFormMember, + getCustomFormList, + getCustomFormDataDetail, + getCustomFormDataPage, + addCustomFormData, + batchUpdateCustomFormData, + batchDeleteCustomFormData, + updateCustomFormData, + deleteCustomFormData, + deleteCustomForm, + enableCustomForm, + disableCustomForm, + getCustomFormOptions, + preCheckImportCustomForm, + downloadCustomFormTemplate, + importCustomForm, + exportCustomFormAll, + exportCustomFormSelected, +} = customFormApi; + +export const { + getOrderFormConfig, + getOrderFormSnapshotConfig, + addOrder, + getOrderDetail, + getOrderDetailSnapshot, + updateOrder, + batchUpdateOrder, + deleteOrder, + getOrderList, + getOrderInContractList, + getOrderTab, + addOrderView, + updateOrderView, + getOrderViewList, + getOrderViewDetail, + fixedOrderView, + enableOrderView, + deleteOrderView, + dragOrderView, + updateOrderStatus, + updateOrderStatusRollback, + sortOrder, + sortOrderStatus, + addOrderStatus, + getOrderStatusConfig, + updateOrderStage, + downloadOrder, + deleteOrderStatus, + getOrderStatistic, + saveAdvanceConfig, + switchOrderCirculationType, +} = orderApi; + +export const { + getFollowPlanTab, + deleteFollowPlan, + updateFollowPlan, + getFollowPLanPage, + addFollowPlanView, + deleteFollowRecord, + getFollowRecordTab, + updateFollowRecord, + addFollowRecord, + addFollowPlan, + dragFollowPlanView, + getFollowPlanDetail, + getFollowRecordPage, + addFollowRecordView, + fixedFollowPlanView, + dragFollowRecordView, + updateFollowPlanView, + enableFollowPlanView, + deleteFollowPlanView, + getFollowRecordDetail, + fixedFollowRecordView, + getFollowPlanViewList, + updateFollowPlanStatus, + updateFollowRecordView, + enableFollowRecordView, + deleteFollowRecordView, + getFollowRecordViewList, + getFollowPlanViewDetail, + getFollowRecordViewDetail, +} = followApi; + +export const { + addProduct, + getProduct, + deleteProduct, + updateProduct, + importProduct, + getProductList, + dragSortProduct, + getProductOptions, + batchDeleteProduct, + batchUpdateProduct, + getProductFormConfig, + preCheckImportProduct, + downloadProductTemplate, + getProductPrice, + updateProductPrice, + getProductPriceList, + addProductPrice, + deleteProductPrice, + getProductPriceFormConfig, + dragSortProductPrice, + batchUpdateProductPrice, + downloadProductPriceTemplate, + exportProductPriceAll, + exportProductPriceSelected, + preCheckImportProductPrice, + importProductPrice, + copyProductPrice, +} = productApi; + +export const { + deleteOpt, + getOptTab, + transferOpt, + addOpportunity, + batchDeleteOpt, + updateOptStage, + addBusinessView, + getOptStatistic, + sortOpportunity, + getOptFormConfig, + addOptFollowPlan, + getOptFollowPlan, + dragBusinessView, + updateOpportunity, + fixedBusinessView, + preCheckImportOpt, + importOpportunity, + getOpportunityList, + addOptFollowRecord, + getOptFollowRecord, + deleteBusinessView, + updateBusinessView, + enableBusinessView, + updateOptFollowPlan, + deleteOptFollowPlan, + cancelOptFollowPlan, + getBusinessViewList, + globalSearchOptPage, + downloadOptTemplate, + addOpportunityStage, + getOpportunityDetail, + getOptFollowPlanList, + exportOpportunityAll, + sortOpportunityStage, + deleteOptFollowRecord, + updateOptFollowRecord, + getBusinessViewDetail, + advancedSearchOptPage, + getOptFollowRecordList, + batchUpdateOpportunity, + updateOpportunityStage, + deleteOpportunityStage, + advancedSearchOptDetail, + generateOpportunityChart, + getOpportunityContactList, + updateOptFollowPlanStatus, + exportOpportunitySelected, + getOpportunityStageConfig, + updateOpportunityStageRollback, + getQuotationTab, + addQuotationView, + deleteQuotationView, + fixedQuotationView, + getQuotationViewDetail, + getQuotationViewList, + updateQuotationView, + enableQuotationView, + dragQuotationView, + getQuotationList, + addQuotation, + updateQuotation, + getQuotationDetail, + getQuotationSnapshotDetail, + getQuotationFormConfig, + getQuotationSnapshotFormConfig, + deleteQuotation, + approvalQuotation, + voidQuotation, + revokeQuotation, + batchApprove, + batchVoided, + batchUpdateQuotation, + downloadQuotation, +} = opportunityApi; + +export const { + addClue, + getClue, + pickClue, + updateClue, + deleteClue, + assignClue, + getClueTab, + importLead, + getClueList, + getPoolClue, + addClueView, + dragClueView, + batchPickClue, + exportClueAll, + fixedClueView, + transformClue, + getPoolOptions, + deleteCluePool, + moveToLeadPool, + deleteClueView, + updateClueView, + enableClueView, + batchToCluePool, + batchDeleteClue, + getCluePoolList, + batchAssignClue, + getClueViewList, + addLeadPoolView, + batchUpdateLead, + updateClueStatus, + dragLeadPoolView, + batchTransferClue, + getClueFormConfig, + addClueFollowPlan, + getClueFollowPlan, + getClueHeaderList, + getClueViewDetail, + exportCluePoolAll, + fixedLeadPoolView, + generateLeadChart, + exportClueSelected, + preCheckImportLead, + deleteLeadPoolView, + updateLeadPoolView, + enableLeadPoolView, + addClueFollowRecord, + getClueFollowRecord, + batchDeleteCluePool, + batchUpdateCluePool, + getLeadPoolViewList, + reTransitionCustomer, + updateClueFollowPlan, + deleteClueFollowPlan, + cancelClueFollowPlan, + downloadLeadTemplate, + getClueFollowPlanList, + getGlobalCluePoolList, + getLeadPoolViewDetail, + generateLeadPoolChart, + ClueTransitionCustomer, + updateClueFollowRecord, + deleteClueFollowRecord, + exportCluePoolSelected, + getClueFollowRecordList, + getAdvancedCluePoolList, + getGlobalSearchClueList, + getAdvancedSearchClueList, + updateClueFollowPlanStatus, + getCluePoolFollowRecordList, + getAdvancedSearchClueDetail, + getClueTransitionCustomerList, +} = clueApi; + +export const { + addCustomer, + getCustomer, + mergeAccount, + importAccount, + importContact, + updateCustomer, + deleteCustomer, + getCustomerTab, + addContactView, + getCustomerList, + addCustomerView, + dragContactView, + checkOpportunity, + fixedContactView, + dragCustomerView, + exportContactAll, + mergeAccountPage, + batchMoveCustomer, + getOpenSeaOptions, + exportCustomerAll, + fixedCustomerView, + deleteContactView, + updateContactView, + enableContactView, + addCustomerContact, + getCustomerContact, + addCustomerOpenSea, + getOpenSeaCustomer, + getCustomerOptions, + moveCustomerToPool, + deleteCustomerView, + updateCustomerView, + enableCustomerView, + getContactViewList, + addAccountPoolView, + batchUpdateAccount, + batchUpdateContact, + batchDeleteCustomer, + pickOpenSeaCustomer, + getCustomerViewList, + dragAccountPoolView, + saveCustomerRelation, + getContactViewDetail, + getGlobalModuleCount, + fixedAccountPoolView, + getCustomerFormConfig, + batchTransferCustomer, + addCustomerFollowPlan, + getCustomerFollowPlan, + updateCustomerContact, + enableCustomerContact, + deleteCustomerContact, + updateCustomerOpenSea, + switchCustomerOpenSea, + deleteCustomerOpenSea, + assignOpenSeaCustomer, + deleteOpenSeaCustomer, + getCustomerHeaderList, + getCustomerContactTab, + getCustomerViewDetail, + getGlobalCustomerList, + exportContactSelected, + preCheckImportAccount, + preCheckImportContact, + deleteAccountPoolView, + updateAccountPoolView, + enableAccountPoolView, + generateCustomerChart, + getCustomerContactList, + disableCustomerContact, + getCustomerOpenSeaList, + getOpenSeaCustomerList, + exportCustomerSelected, + geAdvancedCustomerList, + getAccountPoolViewList, + addCustomerFollowRecord, + getCustomerFollowRecord, + isCustomerOpenSeaNoPick, + getCustomerRelationList, + downloadAccountTemplate, + downloadContactTemplate, + updateCustomerFollowPlan, + deleteCustomerFollowPlan, + cancelCustomerFollowPlan, + batchPickOpenSeaCustomer, + addCustomerCollaboration, + exportCustomerOpenSeaAll, + getAccountPoolViewDetail, + getCustomerFollowPlanList, + generateCustomerPoolChart, + updateCustomerFollowRecord, + deleteCustomerFollowRecord, + batchDeleteOpenSeaCustomer, + batchAssignOpenSeaCustomer, + getCustomerOpportunityPage, + batchUpdateOpenSeaCustomer, + getCustomerFollowRecordList, + getContactListUnderCustomer, + updateCustomerCollaboration, + deleteCustomerCollaboration, + getCustomerContactFormConfig, + getCustomerCollaborationList, + getGlobalOpenSeaCustomerList, + getGlobalCustomerContactList, + generateCustomerContactChart, + exportCustomerOpenSeaSelected, + updateCustomerFollowPlanStatus, + getAdvancedCustomerContactList, + getAdvancedOpenSeaCustomerList, + getCustomerFollowPlanFormConfig, + batchDeleteCustomerCollaboration, + getCustomerFollowRecordFormConfig, + getCustomerOpenSeaFollowRecordList, + getAccountContract, + getAccountContractStatistic, + getAccountPayment, + getAccountPaymentStatistic, + getAccountPaymentRecord, + getAccountPaymentRecordStatistic, + getCustomerInvoiceList, + getCustomerOrderList, + getCustomerInvoiceStatistic, +} = customerApi; + +export const { + exportContractAll, + exportContractSelected, + generateContractChart, + getContractDetail, + getContractList, + getContractTab, + getContractViewDetail, + getContractViewList, + addContractView, + updateContractView, + fixedContractView, + enableContractView, + deleteContractView, + dragContractView, + addContract, + approvalContract, + revokeContract, + batchApproveContract, + batchUpdateContract, + getContractFormSnapshotConfig, + updateContract, + deleteContract, + changeContractStatus, + getContractFormConfig, + getPaymentPlanList, + getContractPaymentPlanList, + addPaymentPlan, + updatePaymentPlan, + deletePaymentPlan, + getPaymentPlanDetail, + getPaymentPlanFormConfig, + getPaymentPlanTab, + exportPaymentPlanAll, + exportPaymentPlanSelected, + generatePaymentPlanChart, + addPaymentPlanView, + updatePaymentPlanView, + getPaymentPlanViewList, + getPaymentPlanViewDetail, + fixedPaymentPlanView, + enablePaymentPlanView, + deletePaymentPlanView, + dragPaymentPlanView, + getPaymentRecordFormConfig, + addPaymentRecord, + updatePaymentRecord, + getPaymentRecordDetail, + getPaymentRecordList, + deletePaymentRecord, + getPaymentRecordTab, + exportPaymentRecordAll, + exportPaymentRecordSelected, + addPaymentRecordView, + updatePaymentRecordView, + getPaymentRecordViewList, + getPaymentRecordViewDetail, + fixedPaymentRecordView, + enablePaymentRecordView, + deletePaymentRecordView, + dragPaymentRecordView, + preCheckImportContractPaymentRecord, + importContractPaymentRecord, + downloadContractPaymentRecordTemplate, + preCheckImportBusinessTitle, + downloadBusinessTitleTemplate, + importBusinessTitle, + getBusinessTitleList, + addBusinessTitle, + updateBusinessTitle, + deleteBusinessTitle, + revokeBusinessTitle, + getBusinessTitleDetail, + getBusinessTitleInvoiceCheck, + exportBusinessTitleAll, + exportBusinessTitleSelected, + getBusinessTitleThirdQuery, + getBusinessTitleThirdQueryOption, + getBusinessTitleConfig, + switchBusinessTitleFormConfig, + getBusinessTitleModuleForm, + addInvoiced, + updateInvoiced, + deleteInvoiced, + getInvoicedList, + getInvoicedDetail, + getInvoicedFormConfig, + getInvoicedFormSnapshotConfig, + exportInvoicedAll, + exportInvoicedSelected, + revokeInvoiced, + approvalInvoiced, + batchDeleteInvoiced, + addContractInvoicedView, + updateContractInvoicedView, + getContractInvoicedViewList, + getContractInvoicedViewDetail, + fixedContractInvoicedView, + enableContractInvoicedView, + deleteContractInvoicedView, + dragContractInvoicedView, + getInvoicedTab, + getInvoicedInContractList, + getContractDetailSnapshot, + getInvoicedDetailSnapshot, + getContractStatistic, + sortContract, + getPaymentRecordStatistic, + updateContractStatus, + updateContractStatusRollback, + sortContractStatus, + addContractStatus, + getContractStatusConfig, + deleteContractStatus, + updateContractStage, + saveContractAdvanceConfig, + switchContractCirculationType, +} = contractApi; + +export const { + syncDE, + addApiKey, + updateAuth, + createAuth, + deleteAuth, + getDEToken, + getAuthList, + getDEOrgList, + updateApiKey, + enableApiKey, + deleteApiKey, + getAuthDetail, + sendEmailCode, + getApiKeyList, + disableApiKey, + getPageConfig, + getConfigEmail, + updateAuthName, + savePageConfig, + testConfigEmail, + getPersonalInfo, + getThirdTypeList, + switchThirdParty, + updateAuthStatus, + updateConfigEmail, + getPersonalFollow, + updatePersonalInfo, + updateUserPassword, + cancelCenterExport, + getExportCenterList, + getThirdPartyConfig, + getThirdConfigByType, + exportCenterDownload, + getThirdPartyResource, + getConfigSynchronization, + testConfigSynchronization, + updateConfigSynchronization, + getTenderConfig, +} = businessApi; + +export const { + getMessageTask, + addAnnouncement, + saveMessageTask, + updateAnnouncement, + deleteAnnouncement, + getHomeMessageList, + getAnnouncementList, + getNotificationList, + setNotificationRead, + getNotificationCount, + batchSaveMessageTask, + getAnnouncementDetail, + closeMessageSubscribe, + getUnReadAnnouncement, + setAllNotificationRead, + getMessageTaskConfigDetail, +} = messageApi; + +export const { + addReason, + sortReason, + addCluePool, + addCapacity, + checkRepeat, + updateReason, + searchConfig, + getReasonList, + updateCluePool, + noPickCluePool, + updateCapacity, + deleteCapacity, + uploadTempFile, + previewPicture, + getCluePoolPage, + getCapacityPage, + addCustomerPool, + downloadPicture, + getReasonConfig, + getSearchConfig, + getFieldDeptTree, + getFieldClueList, + getFieldContractList, + getFieldInvoiceList, + getFieldContractPaymentPlanList, + getFieldContractPaymentRecordList, + deleteReasonItem, + deleteAttachment, + moduleNavListSort, + getModuleRoleTree, + deleteOpportunity, + resetSearchConfig, + setTopNavListSort, + setDisplayAdvanced, + getAdvancedSwitch, + previewAttachment, + addOpportunityRule, + updateCustomerPool, + deleteCustomerPool, + noPickCustomerPool, + updateReasonEnable, + downloadAttachment, + getCustomerPoolPage, + getFormDesignConfig, + getFieldDeptUerTree, + getFieldContactList, + getFieldProductList, + getModuleTopNavList, + switchCluePoolStatus, + deleteModuleCluePool, + saveFormDesignConfig, + getFieldCustomerList, + uploadTempAttachment, + toggleModuleNavStatus, + getModuleUserDeptTree, + updateOpportunityRule, + getModuleNavConfigList, + getOpportunityRuleList, + moduleSearchMaskConfig, + switchOpportunityStatus, + getFieldOpportunityList, + switchCustomerPoolStatus, + getModuleSearchMaskConfig, + getFieldPriceList, + getFieldQuotationList, + getFieldOrderList, + getFieldDisplayList, + getFieldBusinessTitleList, + getDatasourceRefDetailList, + getFieldCustomFormList, + getDatasourceFieldConfig, +} = moduleApi; + +export const { + addUser, + syncOrg, + updateUser, + deleteUser, + getUserList, + importUsers, + setCommander, + addDepartment, + getUserDetail, + batchEditUser, + getUserOptions, + getAdminOptions, + getRoleOptions, + sortDepartment, + deleteUserCheck, + renameDepartment, + deleteDepartment, + getDepartmentTree, + resetUserPassword, + updateOrgUserName, + importUserPreCheck, + getOrgDepartmentUser, + checkSync, + batchToggleStatusUser, + checkDeleteDepartment, + batchResetUserPassword, + checkSyncUserFromThird, +} = orgApi; + +export const { + getRoles, + getUsers, + updateRole, + createRole, + deleteRole, + getRoleMember, + getRoleDetail, + getPermissions, + getRoleDeptTree, + relateRoleMember, + removeRoleMember, + getRoleMemberTree, + getRoleDeptUserTree, + batchRemoveRoleMember, +} = roleApi; + +export const { login, signout, isLogin, getKey, getThirdCallback, getThirdOauthCallback } = loginApi; + +export const { getSystemVersion, changeLocaleBackEnd } = sysApi; + +export const { getLicense, addLicense } = licenseApi; + +export const { + dashboardAdd, + dashboardPage, + dashboardDrag, + dashboardDelete, + dashboardDetail, + dashboardRename, + dashboardUpdate, + dashboardCollect, + dashboardModuleAdd, + dashboardUnCollect, + dashboardModuleTree, + dashboardModuleDrag, + dashboardCollectPage, + dashboardModuleCount, + dashboardModuleDelete, + dashboardModuleRename, +} = dashboardApi; + +export const { + getHomeDepartmentTree, + getHomeFollowOpportunity, + getHomeLeadStatistic, + getHomeSuccessOptStatistic, + getHomeOpportunityUnderwayStatistic, +} = homeApi; + +export const { + addAgent, + agentPos, + updateAgent, + agentRename, + agentDelete, + getAgentPage, + agentCollect, + agentModuleAdd, + unCollectAgent, + getAgentDetail, + agentModuleMove, + getAgentOptions, + agentModuleRename, + agentModuleDelete, + getMkAgentVersion, + getAgentModuleTree, + getAgentCollectPage, + getApplicationScript, + agentWorkspaceOptions, + getAgentModuleTreeCount, + agentApplicationOptions, + getMkApplication, +} = agentApi; + +export const { + getApprovalProcessList, + getApprovalPermissions, + addApprovalProcess, + updateApprovalProcess, + approvalProcessDetail, + deleteApprovalProcess, + toggleApprovalProcess, + getApprovalConfigDetail, + getResourceApprovingDetail, + reviewResource, + revokeResource, + getProcessedApprovalList, + getPendingApprovalList, + getInitiatedApprovalList, + getCcApprovalList, + rejectApproval, + backApproval, + addSignApproval, + getApprovalResourceDetail, + getTodoStatistic, + revokeApproval, + batchRejectApproval, + agreeApproval, + batchAgreeApproval, + testApprovalWebHook, +} = processApi; diff --git a/frontend/packages/web/src/api/modules/system/log.ts b/frontend/packages/web/src/api/modules/system/log.ts new file mode 100644 index 0000000..c7e08f8 --- /dev/null +++ b/frontend/packages/web/src/api/modules/system/log.ts @@ -0,0 +1,26 @@ +import { GetOperationLogDetailUrl, LoginLogListUrl, OperationLogListUrl } from '@lib/shared/api/requrls/system/log'; +import type { CommonList } from '@lib/shared/models/common'; +import type { + LoginLogItem, + LoginLogParams, + OperationLogDetail, + OperationLogItem, + OperationLogParams, +} from '@lib/shared/models/system/log'; + +import CDR from '@/api/http/index'; + +// 登录日志 +export function loginLogList(data: LoginLogParams) { + return CDR.post>({ url: LoginLogListUrl, data }); +} + +// 操作日志 +export function operationLogList(data: OperationLogParams) { + return CDR.post>({ url: OperationLogListUrl, data }); +} + +// 操作日志-详情 +export function operationLogDetail(id: string) { + return CDR.get({ url: `${GetOperationLogDetailUrl}/${id}` }); +} diff --git a/frontend/packages/web/src/assets/icon-font/iconfont.css b/frontend/packages/web/src/assets/icon-font/iconfont.css new file mode 100644 index 0000000..38b86e7 --- /dev/null +++ b/frontend/packages/web/src/assets/icon-font/iconfont.css @@ -0,0 +1,1084 @@ +@font-face { + font-family: iconfont; /* Project id 4798260 */ + src: url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAHRgAAsAAAABF9gAAHQMAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHFQGYACqcgqDuzyC6yABNgIkA4pQC4UqAAQgBYRnB7BYG07sdwY3k6QKtwNoavnflCMRge6Av0akkzArgo0DAJF/YPb///+fmVTG0DSFFADV6XQ6vxsJUVCKWG22m4rRSG900mzqUiatRBUzXia2hS6yzYjsRkSuhreWA5tn2IIxz2M7m93bZ7LnSsUvx0h262brKtbTfdPN/XggspGS0YsPPPijIhVnkPhFLvf/3skxaOPKJ0SDK93O5Y8bQcLI2eUOJd8ONKbsTjz42UCIignKRTbkeqsEZAIBhasLRWle+a3yhu/C5bvy+WIhcR6PPdB5TS9PkMvTlNmpyYtGp8hDz37f7sy899etOeLdElQ8mTdC3NAolVC3YZ3SOJTCmeF5t/UUTBFQPuLCAYI5wIEijsVHQFR088ENrpE5ZovfprEsLVua1V1i2+pKK+ju2thUG6dp3bWGta8BBRIHOWcmKqDPTxC2AAKgAAJQxZlbRZAeTwE5NM9ZApq4+E37HhwFmyxhjmAhveNfIPLT2PKMOrLkynLS3MA5lyFW1Qc8MC8CBufWqFO5KWLeSIRISqgczOoBqNY2LMuF/2qOBgWNlj6hBKmQkbs1qjWyqj8QBHkVGCMmzIdqrSqC2d27i4TVFHwMqYIWoxlB2iUiExlBh5llejhfqU/m3S9z75dSVdDQUp6YdpfQ6pER7xs4J9obFA+4yn7GrnLaf/eMFFd1D9gPb/iRrgCkkQybSLJi69cqtOu/z5Wni+Y6cw18Qj5R4LGxNz2bUKS0Q6ADzmsA+qLu32/rY8dKJF8FlPADpqkTfFq2+Q9s27hTpTIFzBrHn9x8D5IapKJTu4mOHYG6PYl/yAH31amutkFSM9k9UgFDCGM4nDY+5l9Vq5YURzuSvW+evJs7j9/TXkgTisbrCyG01zT/f4Ak/v8ARQCkRJCiTJCWh6QTQEoySTkwmWbQeBUmSLY3ZK/3gjdlgLQ9ojSBkOw5aaNsX45divn1KZUqr9yivPT6VDRX9N3VxVEVJ3HCwRdsAFu7GnD7/9Np1Q6Xe8AVUZd9/QGWV2573RVdNPNntPLoSyF5QZ6QrSVbYWeBRloazRHZi1Tlutx1MRxxWaRuT2WohexfQxZGTSg333z8fJqbO9eRUhFJRcRKkHTMj6+b/M2+o6WyLrRYiBBxSAjw9v+mg2yrx7h8r6YiFiKCIFEb22BJnPfx9gjALLgmmqnobqIMLFa/bqwBu7DgbmEAHyZG9eeyAjB9KoOBmRhgGgSYSB8UK9ZvBHJFhvG1f8fHyy3ZggQjxp2V+x/ExeD7x3jAnD6nxve8QAC4eAJGQAEuOfMNR/lY7V+9w5c6naV+9w6Y03EBHRbMQn6MhtDOvYcSsgoqd1Z2DE1VduVWYT0/dMveb/eRRJWjwc48wa/iStzND5BJuX+/PiLJfR0/+799WNpBOAcRIkUfrPDBjk72hC4Obq6P+Pis3F0g/eHp7NW/mb28bXGk+Yx5nNddW7f4vIskK6qmG6ZlO67nB2EUJ2mWF2UVYspme8tDMT/l/5PjCMEICo3B4uKBoUKNBi2OozhJs7woq7ppu34wHI0nlpnO8i34QRjFuyTN8qKs6qbt+mGcDsfT+bK53u6P5+v9+f7+84a6Xmr6gsYM0wL+tuN6OEFSNMNyvCBKMvD+fH8otp/mZbc/HE/ny/V2fzyzvCirumm7fhgd1/ODMIqTlCApmmE5XhAlWVE13TAte7XeACAEIyiGC1UT3cxcROutNe+n/4VPYIgJ899+8/d//PNf//7tf/771z//BUFySipXV6NVqtQisUQqkyuEAp7lGJpCMRyBIRCQlu1I1+PVDVNTUUQMDRzuNC9Y/j6mGqI/BnYMduDO7rzYkS97CGBPfPYK3odigL0R3rhEwN6Jb7JiYdIkcJspDm5QPEyjBLi1EoF9SBpVMsymFBhfKrBPadMuHUaVAeyLjH1D5is5sB+KScuEQVkwPhWwXwXsRSU72cn+7GL/9tGmj3ZaOvTTaT9dDtDtIM0O0eIwrQbZ1Z/0uESvyyS6QpKrJDOQYphU10hznXQ3yHCTTLfIcptsI+QYJdcYee6Q7x4F/qHQOEUmKDZJiSlKPaTMI8r9S4X/qPSYKk+o9pQaz6j1nDov2NlL6r1iN69p8IZGb9nFO5pME+g9QT4Q7CMhPhHqM2G+EO4rEb4R6X+ifCfaD2L8JNYv4rTC/wDQN0D6DkQ/gMmBEEDJg1EApwyCIiQRFOXQVMAQw5LAUYKnFQJShFQiogox1UioYSdDSi0y6rAbxnEEeAcR4l1EhPcQMd5HJPgAkWLpyPAhIsdHiAIfI0p8gqjwKaLGZ4gGnyNahOjwBaLHl4gBXyFGfI2Y8A1ixreIBd8hVnyP2PBD7PgxDvwUJ36O6/HFjV/iwa/x4rf48Hv8+CMB/Jkg/koIfyeMfxLBv4niv8Twf+IMIAm8iCTxEpLCEkljsWTwMpLFK0gOiyaPJ5ACHkaKeBUp4TWkjNeRCu5NFc8hNbyB1PEm0sBbSBNvIy08grTxKNLBY0gXjyO9+U0f8CQywFPIEE8jIzyDjPEsMhlkCngemeEFZL54WQDuyxL3Z4UHssaDyAYPIdvlmh3g2uxxXQ64PkfckBNuzBk35YKbc8UtueHW3HFbHrg9T9yRF+7MG3flg7vzxT354cL8cVEBuLhAXFIQLi0YlxWCywvFFYXhysJxVRG4ukhcUxROLxpnFIMzi8VZxeHs4nFOCTi3RJxXEs4vGReUgoNLxSGl4dDScVgZOLxMHFEWjiwbR5WDo8vFMeXh2PJxXAU4vkKcUBFOrBgnVYKTK8UpleHUynFaFdijSuxZFfaqGntXg32qxb7VYb/qsX8NOKBGHFgTDqoZW9SCLWvFVrVh69qxTR3Ytk5sVxe2rxs71IMd68VO9WHn+rFLA9i1QezWEHZvGOs3gg0axYaNYaPGsXET2KRJbNoUNmsamzeD1ZvFss1hjeaxZgtYq0Ws3RLWaRnLtYJ1W8V6rWG11rFKG1i1TSzfFlZoGyu2g6XaxSLtYaH2sXAHmL9DLNARFuwYc3SCOTvFXJ1h7s4xTxeYt0vM1xVm7xqzdYNZu8XM7jBL95jRA6b3iGk9YXLPmNILpvaKSb1hYu+Y0AfG9wnj9Bmj9AXD9RUj9A0j9X3SfgBG6yfG6BfG6jeG6A+G6i+G6R8G6z8GiUAjnX/x+lM8Jr8Nf+6B82x095v9HY71/x64b01iJmWHkPZ0/RhGA3wlGkV3UZIdxSfwiNIIic3JfS+BbGUrnmunnt/KWKevwgvyyjOuMrkWdAcnpDnVBMy6I9qzGEbEFQhE/Ps7RRAtF/IqMMAVGPk1iopFTsC7cApCWYUFEu5LfEr4BBMeNEBCaFRsJw0oEQHKlHtCrYKwwoY8AbS1kq+fUARgLeACO2+4R6+Am/mt59MbVCAxATbcXQ48mi8JjyiYB6JOfIp84MVBZQ0zftKHOV6bkJI/jX8qsnW89nY5OPcgSfEeLdjJYpxYMaYJiy2KAtyhJ46zqS0HvR0cnBJ5//KeDm6YTy+1PPOiGeIKgdzOKNjaAXOeK3jzJ32CBVhUmIk5Vfm17u8tECK8qdigjXO7/r4tZYrNx8luN1o3AEWH2XZEtlG1CqTUxKo8q+t/2jSNnGx3c2OWmW0CzAXRA/OqFLLsCXDNant7ZVmV49gPZjE/BNZWdUauLWvhKwy92RgjUKpqvR537npLSY9Vx0Yr3+a1yzIkPTiB977lGOQp7XFo9I2epKRj+Tpaon13ANRxdAW5v+oNuI2mbpr/WmaTq0+EpdjfqsLkPoIcZ5CrKAOn2IN3LC68ud40/Mj3G3Ed7bkdR5k382JgfFAS3GDaQ0TWKq/Ks1balJ3qiYht2XDKPLZqhlpzzs+GVm5hGWzuYxGQoki1hndyswYRYaFC9Xv1FWxuMxQAOwX38uUFHKhUJg61EiFnH6lVIw0s1XBRCymGDBbGrZNEMCPKcdEpN8uXCUScfiAZ+L61KDBTjevK2yjmeS1ie8wYAQjWtAg2495g7W1TggSgLPFahaBP6LFp6WZ2ZgXMWlYArIRwSCn18gTkg9j2xBGmJ0ENRYHcQxu2+97RDe5sOGDjagl1AQZ0Zuq8tWcySErZ4OyIGFpVw9jCtgJCK8aYA1W1j/ZcNduswD0sOICqg6vK7+AXfJZNOlfByINsO0bveHBrZMAu8SaQ56yLrOeRBkHlp65tORWKfroIvvUGxF3KGvO6meTjoRdmPFWIu6ydLef6GLPnkIXApg7GZiEDTKzgPX/s13DBoURRMDy8CoayfIfFfWZzH/SbFUAEkpZT0Wt58IJ7WbTw0XPilTh1FeYRu69e53K5Z1gV2emaeJdjRxNEFeyo7AGFkC+ZCNMtlxQphTpRm/j1oVMB7MF00A7PRGa3f/0KCFJ3dMM6+5VWK2yv216ujiGd9Q7weNyqkhRPET5sAiyxNuyg1FSZOs+/9nmjZnxoJ6Z9sJpZlbvcVCYqBZffnX5mnb2reRC9W/0CmjFz319MfY4uihXyRVxRPEsisgPAQcS2mTMUpVw6j4mibcqq5EIEr2ieFZEcSdANAI7412hivbpvyI1PKhFR8fAI+f5n8pdXl98FQEwxjR0y6PnVBxe9dDKmR8WnHdsUSILqszS+PPTsSPGMG49PppOqlQWhKFV6/y9W9nr/ObLqzvjCNhFMUl3SybXsOHFYW349B1Pvb/yFOFMZZEJumi1i4c0jTIweBCImd8XbYiFm9Qyia5VA75PzcgtRQ5xGfIFvIEaschVIDxHAGdLzX1KEhJhI82gnRiXtxUYVNO1/jh6HVNyYTAunB1Rp2yDzIBAR4kGtFWhACWBOpcNb5vF7UYIfBG7QZZTYlLZEBCW1qLygDDGRkhRRqGW2L2INAd/xmCijBKdanMJDwkKEmu7xvsPxr/6Qg1HTLBXRiP4EAmYxMD87a5TZY1d2+dKcEhrOS4JUO2VW1/gKqsrQ8ttkZgAUMnpWYBiylg+Ro2/kEKHPySiIVQ5IGEyCgp8ECOBWR7BKKXu08fdMLILvMArbxvhusl3K5ObhQ24qEY1lk2nlfGYj8Tc6ZWgzpdt6EAg6cs5afMGCl3akQkApUoExg3OhIIz1oktiCf7AYKwB9pAddmJRO+HKxZwK9oAs06lSZK9O48CtoHZF2z3Q+z02nPp3NoG+SI8dlhpCnyo0agi+URoSvsJPOntyGDwKPnXqVfYI+NW8BoRPF6+hrzYhxq7IjMG9gSY8FoPLkiO9w3pAQHFIeUkxxJEYxJFw4bSUbJQluRG9adEjflFMN6tPEb3IA/45gF4BnKmS/NSsyE6M6CQ4PPbRlXTJmLIxfQTAY4q/gr1LwNqpHWJGtEUgxEIVcKe1Q/QlgfGxb7fM109Dv3+FE7MjD1wKO5IhSU5MKYAhKkYdlVgnVatl14X41rZ63Yq/An1X8SfXrN87DvuTu3Sal8zqIvYRWwIFt9cVVzTVnfTT/wFStmckNLMIU1qzJ7EAOuTRUX2aI3BD8DIaPXj/G2XSfByUn/8EDddWpcHyOgQ7fgi+3KRg/ZZ1DNpHIvLsq7sccvYD3ZO8V4dmWv1mTd+Dlj/HRket3/RNl0bOFLGtS8TZB+WuikGAHrBzhkbYSyD63OJL8/Xyx+UfAhqTRCtf03dDIqrHkn5T/dsf3XA58HfuTkMXregn8R0hboUFuA7YqUiFlNznhrT5TrfDVW3X7jRKEvUXtJEax/wDFYU0E3IzCpEvPwrDY/gL3ArqQshk9VlqsnoE9LlTG0zWsByEaD7PMJjPrGeoZCBCIxNbYHYm0PV5d9lNn5kj4ErXx1f39G25nXvfePCG9LY0trbm9NlNFN4bH3qtd/rbz+qIbVavHro7NlbQfvxExhHqFaQ3FZH6nJT9HoWO6FSloyBXeMTPTCq+EAvAkB3KV0ZB6WV8jddaUnzoeyzL6UjLt+e6mLlxM0yyj56Ubt9qUUNUMCmRXTPKutQi492e1VfEHVOM4sa9gQVBErnHT/OLMKzPQADA9CBiFW4uNkSu7jjkkV9fvl0a/a2w96c6dl/zh18PMSB8LTuVQXSRm/GFPJKgvLriB8UEYSKQfWS/94Uz+j38obJeVTnRREp2cyjMAr4hxGdL+uxT8848RL9yl2hbUKdal9/mf1DZyvlz72ZmVko4C/qxEorw0reyMUeIPvRxFBrskEzdIVHG0M1af0vvtQE9D2VB+uXX5svFOrC6Hbyy3vD7B48QE7Kbb/XSM+vFCsRfPGCvR8S7O2Oirn8OKFY/HC3H4lud53bNyxoQr0kmxFSK4y0y18ZUYbW3gs4CcDsUkADTcy+RChdeq4wxb+4+LtNd4j2umZmSLkfEopExkZ404qmD++s3BR+7NGTZTYi43zz28CVwq01aO54UHv1G85N2ZaME/esTj6ddSOSTpEuEnYCdH56CQGmei/laFRWLG9Eu76sJuJRDCTx4Ld9kTOyxEynToNKvedy0/da01qPGZ3tj1+tiYAp6x3jNytk0FxyFh+LmDvsGwOLNLVme/8p9PDYl+CVw8IvowOBDdz5lKMazEX8PMZ8umv2sjOXn8zxxMk2fAtCQuNdn6DytZVPedEsimmf9MN05B84lFAzRhJmKQ8ehYDk++jna4a3ZsfNxlw0mtUWBlDEWIlxZRSyADHlkQhF2lFipgkzSfo+T8p9PU10DRxCfOt7NX0XIq+hddD1RRUH8lS54rqCiY80DROyyYgRjspBgZvHDVlZ3oiCQpvLKtMLBzFLxycm0RG6zI+fOlRmlO6FwCq77K+3odK6vArQXtumnWjq7Yw04IKnTQGoe8/kyurinloYyTx8Xl2+3+lrzi7NeMhvDDQ1PRTsac5JpxdufcCJ8YWl2JhCnND0PFhbhaaXVyfiXsl49NKEJDbhz1C45FESMxgKzqiAgyRcPIPocx1mmiNHXW1LVcxdF8+InRjlMsC3FqmLhbJk1JhsdRSNAz8jw8EpUXSKww27Rjg2pRHamZy21cQrPAfCxC1l6ErsylzyA3+L3qhIemJiU6kFgcW4wZri2QsxW9tgZZDjIblz+zkzZjkRx6QsWdVnGsI4Y8VQCwwmQB976cDvpm1S1jKRzALXCqbWtp0Q8NyV2eXFX/KudiFK9WqHPu8l/YttVtDGC8n2sTZSDhqUP+wD7kUR6GLriGnDJb1Ggb2tfBZb24uL0e/ZdkpxCOBc4vp+GMexlryWvF6ySURykN4+HV4aqISQgrl4+LAAjL9ZGzHpJN+FZUtiZ/Tqt1dZ6OolEOjMI6/08Z6OHTTf/L/kf8DHfv2eU4qmYKPFTfxjbtvSpY27R85CvxEVvD7Y0EgSca4aLKNhiTMZHGnrgM0QEtJE+4V1xfsTIrXLgEeI2J3AkenFPlmUaSF7diVolEOv9tcimelhTThr+z9M0X9oRD8sl3N4RwQhJ5Ru1sLW0Mk6fT35hLNFOtB4dHxhdfOW1zQJKz/35RQTfnCw5kpRi+BSkPUo2yOTQ+tSAXYzL/nDfSsaiq2QIz0FI3dB7oSnjYcvx8cFeORNu9l5y+8rFCmMYFlinj1vZOdBh8Wmf6zxfhqlf8O8KD1RVlWUVg2HQCuKeCRnQ4jI5Xi+VyK99bmDDOXvYQanNWX1uS/jG1UQECNOALQ6l4faEPOTG+WqOflIZ9pLJ2DcI3FICxLPmOhYjSao/UJd49RrdzngUEJWI0nCFj/E+vFQ5vdJ21zNgD60cPoDU6VsGNGZFZkQRuJBB0kHl/sqx/7nuS9qOjK5o8uEdM89U+CdrChmJOEmBY1rRPn/MyyEKCRGrbuo/07VupqKIJ4mOudMZIzpKEUTszVpoffqVF8KhCfY5doCwd+tzceS29chyX1x9JQiOMqdZLpwTOW02unbE+YechG3+sWQhxSJAm6Nh/d74mDLGu5MpwLYk9gwhB9u64wgclQDz5p7nyqrmGa6LKDycNP4r6wQtXtUdc90UCpV2uAvWnI6ux8Q79/HCCVCqw9UwT3Ua17aywCytWK85kjMUpKb1eP89meHfJUQTMsrnDqlbWwEwhDYi7XDlep0JFFqKBkcU7PyoK2ZP2x9bhX7h7jUgAGH29QwwK0TFbapxSL+kB32un6UTtBH+xzG5apG4Fh0SgzQboPVqcxckkZaPgnhj70bb9R2F4zPQM6ZN1elVsSpIk5RtU8v3cMnm2/tWD07vLg5uFUmWHl0LYTpangd3bPpAptQgXi7cbwUBPSaGk2MVwbo7M7++WSlQul8pvRhWAbZKWblSSxwBfWpTphyuPqlDRRuI7OgJqiVNx2Q2ZEW0RcdETNqL1HKVRFRGt2ZCQSB5nlq9oHSsn4kQfBfPhiB8OfcUqPjpAdGWU02pFoRQ44qnCVeWIPwpumlKFe9Xy66XblwjgYAzneyy+uma4CLS6YAUl1xfExq3wtn/sBTNb77bztQ4UsGWgoD0WJxrsyG7IXMgsd7kyk0HD351hqmuDe4hJr0K/kNHjz8OYfFvFA6o4XU4f0o6TVjOuLTW7ikJv66Jv1sDCxO2I0mFT3UptrXhT+eaHne5pmZX3uiwHUcpnbzMij+TS62qdjSWlR51Ot6kXPws8yvJpRJYFPPHbDExez274dfc7sZ90x8oPZTfsQgkc51uQeAkCYTpWtsjfjaVq1k9sHowDxBzozNd+LrLeF78dmyu5b5q7F9oLfGTaavxUdCZ3bkEjss1SeLqfNd9pqZMHKy+Ww1L7YNemTGr4xAZPiU55dq2ieGsHsqYLblqSc+br09n44Us56AvCZREAiiao50qF/pWH0vQgH0yWZ0TLLbbQJ9DQbR13HjyorI7LBHIQLlDkHwykY4WFECkfIX13sufOZRnqKBLlbsFEWInIwVXCqmx9HXiC/PSxq3rUyJfoGcy/zqSrsS/Zpz4kHSObUCLwGESPG+AjQVAJUguj2dBLVg2fc2zHCmXRG075cqU4anGuXFFwVolmWPs2FYrUgZTwRAmVoRF0qaudknMti8QXbfJz6mlJVurbeDQZAfr9Mrt7SjUHMMQ8zquXFEwMhQ64IkfofTrv5RC5O+YzkTSJPytqeJPXZ3SZi31EqFd9WHUUzl0qqxXhqwzfUJyx6M1pUpOyF1dXkf+gRvsPetDgnfVf7mu+ji8qdwLW6wDp5rjwtT7kic7AmLmo3WitWwjV9xY7IJ0UaZd+hyQSlFdUHogDeWkrio/o9SysrNOVckFB8XQV/r4I9r4aomGtoJyi/x78631bfzrHFt5g8VyVRHLFC6gpudhIt+GqVt2Do4UtZ77N5OUfwKn/CLmq2HRuyhXzBCzxfY7PHgX0NDIp06jdbrHalY2Jvr3qMM0bx17mPChj5kaghg5/W7me6M1UgyJQ3mCsWnWmxmrqhuF7nAiOynC1xq/gY0+cJl+rDa3K1+fZT6xy29aYK+j75GkOUXzkLgeOzVT4McyQ62ifMFtr5Eicc7J/GG/CSZDecnfKskCju1i8OWzOtCzlKMqCygK7KuMZn/9kY+GqklDGXqaF27r1GilyFdcw7fogc76BejB8XOJdmjsXr0Ka8/O4yVONvu8XYGbVDXxQzpXLO1JCMET7FtNS1TOVcue3U93SykxJ7sVlV5BJLXIgiVBw+cgZKOm12fUXFYcIkqGt5ylL2xxBqeP34AmN95jNa6tqS+DNTMzj8pGe/qx7+phw7boDbzN36pWUo+Hw8nzwTyAD6Fuk8RzXxgcISPDboSpm9wIO38zlWInZJIcQpupkElefkEFG1wcmvE3hJkYTaWKDJJxkhGFVXPYyF00KQNSfOdJmCyLJlF5YODJflignSNdwi3ESu9GAuC5l/Qf7MJr6MVzpCtcnbgaSuiN2bZngcXPbrSmjabcQbcvpOrPCqkL26accpVo+dWlSXtIMJA/4bu94GW/u/lRrnkdlG+b9gH5DpsSflfMR/aprPrHq/d2KXei4+h2fbiw6xqeJ5TO9SZ96n8loK0g6jIhr24viu163K12BhXS+tSB26ovt/V7vSfnPjDuv/6LlppF6QEt51ezifzEzjMsXCYb48gMjmHdzIWVIdtCKxA/O4BBe99R5b/1n/dPfFBf8y2akTgQIuYuRRWKn3EsfhAKzYwlklkP+t6myfDdSUZrPxi/6dcwLLsig8rulFsPmwFKY4TSGCKFMaLeLdPBL6fUh0mm2lT/Z2tUfC5OxcI+aatAF0nxhiEuKTA90aA6ZMmmw/hScQpKn5ZQFGs20tWgtygTm7bAyMjH7oP6P0CV5xJzeKNBt9jhB4iVP8lYg5Sc+LV7Ew3lMAMlhXqpUTUm3oC7Pbu2dcT6csMb+pYGQRv4+9iQo3/kDK5+gCEz/AQ0s5DNX2mIWL9HjBo5ufkPfKY+RSWTnYPB2/+f6P7iqnR0+j4krdvjfNz5f4cCIj09jQeMklXoz5niBaGCxQOipy93eLQ74hWYt/ZjbSIueDJXG2/NGiWkPq9gGbDOCNsS8kcYqZLvs4Fgn6qlmgZc06al5moqdYQZAUOynIL4uS0hElcyam9g0hCkxGWy5OmTso8Xgy16F+PtGA2IWkFwjjfR6++sDz+Y+bQlCoKNpcRmFxEkJt1w7NK5S8ye20yWxIt0fbtdQi+UpCkJrrX5DM3RtwngXk9DkufKu31ej++ZLatzRiu/oaE8/bRuBKPb4Q4v2yozhe9XNeilPFVHPNjpVaSare4PPOUf14LZO2NaLwO9ol26exNpdf9BPYgjE2/S5Qt1PYglNlCjCNP8XtP+pJy/cGd7HmQo2GY/diU4l3qmBM1Jcv//FrvS/TnQaYdalDWXUHd+r5Ml5DCe0v/heJvjTX82oKHOEmddkQvhPNqZ/VLcAj6I+BtxnMJEjrolLogWK4Pf/GIpIHzhr1u03zODd38rNG4EPsTkJQHIwmJUC5xmfMNhBsJ+VlTkqzESGNzGsDqCTOLdfy8uHwTH4c8Z4x94W08dq5Dr2d1qY0Ev7GF1OE3lpH92/qagn0OPxqez3OtDduDdJS1M+tCDBOxqFNkpkkIo4o8Se5bwN+xBWixxpBhFkuFtn5nKz35vC/ZcX0RmVijVGb4gRNm/bWI63oZyuD1G8ENKReyWjwhRpJmRawg7KMKEVwMJcdSlF3YPVXuDQ76tSjRZM+RDhW7M5TGLEDJGOst9z7m2t7BQ+jGfKPsb5JMZD0aaAdUZyq2h+68bfNKnK69jNFkSIi16TsaOhZFc7hanuYWSbYATs6vr1RnUn9lYm8OF+nASLZIG8D4ylEDYEQSLj3rpi4gw8/ptqqwgNEBOatGynBpmWAquOLIwG0xHyEs+jZHQELy+Ye+zaHYZKXdCPaLQ/x5wOFP1RIARpjgmCFsDhLoSmQ7i7pKKMzZjMWkzxTWDpAKOzPSXGTsBOhK8AKG7ty2JyN16+frYMEaZBCgzSMzTCZRmw4gUeSqpzfESfZyAc6Y8OmQFzbO/Fp9DlRaq7RRkWKJzMmSYdkQyTf1538dIR15UBtq8LVUB4ceQQNOIZxeGdMyoFFrZtimHXqrq2IlteD3NHE4JfbA4VA/xCKdmXpKw9PhxT5FodyV/CiWHWiqkfTyYJD29OhzJ4neNooBkTpe/15B8TILtjSNx139uYM+6CPLZdpU49L9Ng3q1TH1A1E1qub0f8DecsdeMbtM6/IRBiY7vJpeCe45bHvCDIfVqvRpkph9xpPNTlecGPiSM0pNno+PAUpSLLx4VgMuUJlgaR8sRDPXz1ogZJegV62TG7Ixw/vlgHJlvLp6JhUE6qdcI+AiY6i8DCIzEnMREyCXDLJWUUniQFdke1Mj2siEmc6xOMIBeQmJfz9lAC84FWiRdz1Nky8R0fxchEzH0jNE/NTEpG7iwnfeD9Q50teLbgGVaNv3O8TFUDCFuavkCFFLCUguWU6lES6gfbV4fgvOEf7egPJFWDPQLTB/YOAdWij3xlGyap2xDeqTC85t5vTBuewzZ9fdu9wPDjLR3k5t1ikqzhFPar+t5h5pzxa0GUZCrrhJQ4qUi609OpCW951Wen/TN4ZpdhPIcTql4xcU9Xg/G+gX1Ayg8FRsycmMUg/rDtseQXQ/rIx/1g8NUiUt8iwRXFIHg7LATafJLgcU/7xS0xV97yjM2VoFqyoCiKrj9kU1NfyQO17g16bQrU0o7aAj1wz/AMmlZcVCFB9CbQslAjFX0lrFkErwhYCAgpLbwDeX8Q8h7wayJWkMKpF4bU61uTL2cNa44yoOqHHVXRQ16S1Q91I5WacX7Jl0oo9CgwqD03mgBRRTbUuPnw0wYfSr2jBcqqOhN4fl+/KgONQPiFcaF19XCJ1/m6Kk5ZC4ZZEIsvTOLdtWsTHTaCpY6e0NTcBxZmJEkFM2V+T6CQ7to0hsxqCiPo6BwKcL9NyqpaVhjrHjibcIANKFwmAv5wsxK0ydkNhPj6uROBObrBSuD4CM/+LT5azwkJDCd34wghvB8P77ws8WavmMO90qhcQ3UWbCtFUMqDpqUDpbHtl1lhemAcZbdM4tGa4adSltTjW1+yNl7RkPYsmYV9BCaQEeMtALCwlk0WjDymOgxL5C6j0UClR66DBQmYlvEezQYGCK0YNBmw1388Pat4uOHIWywumOsepQj95s1ovT4Uc0vtaz4okNaVc2rNtyDggWydSwX56KCKHLCMKt2z/rWu4KOYbwPOoRunfzpUdMD2O9YqaTjfKuTnBFwgxPR3zDQ9VWjQC8JoZIfAL4jScgJ0MIEdvfCIA3zlN2HhntghvDtRJLmOZSqvi3o1UILM6qd0bEJjNp+NlhJcCZGZWeU+i7GIjEFRg2q+sQNJN1bygTGlB4R6xWhSbe3LVJ2Q/GBOlU/uubaCPOQ3bC097VOk/OYv1hCbpouchkNoJzhtLANf8+dqjT1fDxdCYs8I2AEo3VCdYTlDpdXRtElHQnGh2PQFzEh95ReRBUMSg8MEzLCMUd4StQoE7iyg0vJwtNtwAQoB5hp0L46C0I4Xnnb+hD6+897NPwlq94sLCM5ZaAu0L5bYdePtIGegj/dDuLyfvPbrz/ufgr9vifsm2BC48h9FOnI+Zs6BKkRDgNyrzdH8Z/3AKmi2od7hNNynjDkERUwWH1gaR8BKuY+qmUbCE5fDjubfvoDbCc72CQyqL9jGn6CKVlMGNmMyA9Z74BUOz4IjgMClpWkf/Crai6pIRG5IHtUeMgi9YeavVHB08oW/uhfWscf7Q2veXPySePwPTHQ0Ah3+h35gA1/xGJBwNV4wno4LpGpV+VV3D7H+b6ZvC6a11CQOFF+ClI+q6mYaHjB+ggydMXH9rfBzjngYsgPHyxDv/spb1v7cv3Y7R+1+qFtMQTh0cRS1EMO/ZE/N4893BPqShI25rGmihuSoDv+xUTFBybAvYm0ttAMvBu7dj21lPLKwmYqLOYKmIcgx4JKdhpVrkBKE6yEpqg8c5xbGITU9RoPuPWquJM7Hh2I7dQn/EKz9g0ExMUgyN1yyo6jyxSGh1ajXVp/oLRWKPuhIY8Db06e8nH3V+gZ4wn4Ib00y617jjbz9Ubk7GZvEWRQ2oTWfYnsBc5+fz8ZYCrr0gSffFuRgMj8c76l7HscqQ5w51vJKYZiZaYKyc2h/kzoxZ0nMB/4c9EPW6WhjnZG2ZRHpVJDUuWUINizoggPaQaZVvKeKoRztCLEmGyS1cQX4Wk0nItG+BWGjSFnG5piXOD9JOEgkRibgN4cqXrRphfcFao5pSuKOftU4UZVgwZeQRu9dE8bNFTe0uiDffDMhLeQiOSz+Qb7XQg2U2nob8krkiwgHZaTMCau39GTWPoOxloiJwLdqgeHMVSoV4P06XjuGyx7ZflmSqt28hDT4SCqVE3R1YdYD/vnR20SLpfSc6VeyXeKpaxbPEx8COgAwkGlDvrhYb6Y4qQeIyP+ikhP69W6Vy8p1DIaa5shn7xN0OVPHxE95Rj8BWSnsdcHD9ZPM0Xjans5Z84Vj6LizX0DfZzlKzqEyULupncNmTKyYTfKq/arGwVqIxAX2Ejq5TTa379PapLtRwAPhAqVA5SX//gSJsIdAQcfBihUT44WSB6axG5Dn0v1R1uQrFPTVs4h9fHVXD9YV9e29mpoxmTGoOdv2iduvB/85CGl4DulLaBnrP8q8kG/cTlcsCwYKIEIn51sU+JkpKy4zPoPExxSY8Wtbf4zkqEG7R52Hkc8Ou+LJQv0/B5ipnxv0o95weFzSoJU3NMcNdT3xRAfYqf3jwtIzJbtG2YbcucjH60p3Jm/hxMtW8RCL6OBQzMlMy7A4kIjNwcrCpMHRiFFQqtM+E4CySJ5ZFsOp1b8YL8VZpFCXpNMR8z84Gb1EbeerHFW5EPsjDsfOIIplOSvbxNr+9XTK+jOr09lVtflB7dLTwb0cHDfGsSR3hI+QKr+FvGm7wl5dLhv9v6Vodqk2+Oo2jGprb9lzK5Kc7rSe6tD7pRV8YOnnLJHbDwaJ61Es8AeEbCzYHIGMhd0ounT0rVNen2m09U8dZN+KDPb6suugwdXoZN7SIE01rmGomyYDVt1TTGtBAyHrzhjzKkSOcuh0jK0D8q/wQu3QQwap4LM6FMq6/Xj30666Ds8C4/VQo81tPy845j/RZ8U1pRF2qKk6J1iqpdjCa2P4Sk2yXVnbIk586Jg1AiPWze2peOH0IplCNVQhMVLo337rvgIZqQucxQjF0rTQqnIaBTI5YYmtYvw3oZwWJV1DIFUdahkae5GST+VFQ/a5jb6NdrkPDL5p3OITo/IJ9hcxO56frdRIjnGAYa6xkoUplnHSObh8+Kt261jnSN9urJ7/YVb83X3pc6Q4Vkyd7iVRmRnqfrB8J3VTYtdGnfyzs1NrrUr2S4mvv8NjSZgZxI2WNVQo3NNjl33f3cQikWpXIPaU1SVikYWDyadVUmCFdm1kk5BAV9+IlRxG9zJVb32yuRy8XnDlQ0vwTHcJScHAZpzfaVUZrFtUryBj9Z3k1/3XzokMnjDiQb6fSjEwV5vLb1WRWJTYsgW7fBcZzibTSqK1+1tSFLw5VbVMV1uuqPK52zhmw9ZF9MGtRq1qrQyA9wbBJfVVGv2lHSN//F472/b2sioVahVqEztLyrZxoO6Bn5A4zpivRtjAV33M0HWDYtlXo7X+2nkFp2XSxfF5Cj0oL3i2/3CT/cVQ3riO1AWHXQR5++MKkkD+dm7gOIApDMD7WofFAsFyp8Tn5EHb/5TA+f5y6ZSQzAIGWIkSH9mouT4gCLjAsT8Yk4i9961E6PX57YEjkkpwHZGvs9FfzPsTIkUYxOO54mFSjvRxFR3WvFvz99psHMyiwR6Ve8uzFQrhG0cJ4cZK9bdmqitDTztECVHAjp9eQCJn60FuiQJrePK64BeK0qF05Li83bBCnZKyotuRY5XK21+EWwXlMehUS+jx+wzaNQocvGMmKEmayH3YopeOqmY0B50vwgcfcondNRSKv4IRwHBRRDbiWFIOZVYSzdKRHWcfiggPSXLkUFJHhYL161Bhqg4oNiIOMjf3YlLryiisHJtm/RIWS2bqqyZD5DhITC1SEKwhrjs4C70fUfW+GBPl7pHFQ+HU3ISmo6Mjs0kYhA07VWZjwfKWAZJTiHvBPIDFVjtxI+LuL59iP7wCMTHM5pyHxuoRqD3bqE6gOs3GbBNDjYah3ur4Wk0wCrM79NXrrUubhBknb3J0vBKpafG0229Y+df5FR+zTV36LxlAxfMMPdc7rUvcG94xtKwBQlOizhNt8WQjLpXaPEKbPXwSJshM0Own8nL6QaTvpupkix/xKGacSLP9pYPfMh+nxmCQ8woS5lLKWxsqUUplrKMuWwLHQC2dzI6KWjHS7mnmUMGC4OWtp15lTmUmckYhMwwVquu3UNMjWOfgXm6hzm0FWv91QOEfvAiFSIm4pkmNcnQXz6qX/jarXl5Wz/rDj7hutFIBbsqht3dK9/OT6WcelsJPv761yw3rDOtLbI0vKgkvCziQ3lEcXhsQFRpW1pYZ/WnD0trI3bbD3T4zD3s2yYo4GT4p6T5yzjnMzj2clLOjSoQ+LX1zPXpGJgxEKEalZE1UD9Zw9SQ+yENMFLIraZXjca8++nOeWHaSgZaNcgNeIDdnS7wkr4c7NjxQshnwsAl8EKXz0UAHYOoZ/EroqIq+LN+Cfj8FVGz+L9sD90nr4A07hqoQoFrz8trxymevDjFt+fVnqfAufQuID+WvGDjgOsAO3VTVF1d1KY0se/i49u3nWZqmFe3bc9Z7CuuyQ/O4QXlhOSNh+TbLCcoP2QWokhgSVhf1hMOsuJZcRLvBPbBeHact3ccO/4gO8FbEndqkhcF0jA1EMiNGAg1/bRpxcaH77bfHY4PPKqNnLLj5Kvsyzf0gBYRSC1DqALvDMRJZ1fFhJh6IMhNscrOgylrU5Verq+yhRTT/r4MXXYZmFcLCmZDzfQDPLwFKX6HlLQc/ss35O7wYow9lsUe+aYQGZGnlJevZQTJFjJGQSgGxNtljjXKgByoXxQJ8+Rn6eaUvU9fBsC+UXjl6BgFDhgkDBeA4QA27D8xBbo3H0sbS3ZG+wqe9ie0RutYEYRLX2AkayAUDaLEGr38J9h6sw69BL3mVMv83TNxSsvcoERe8gFEicnDpvinc5S/q0AeO91HtqtCOSPXDJmp8AhL6i7wS80PToz9M+qq/eHq2Vr65dCLS7aeQsoGFbGc4ORVBWYK75BUc5VnZmqYEpflcSc5pLg+AsW28NIKafOp6pSShlDBYffziGZx1YkwaFNljpgvsRM7MwXE+G4PoP5IdHNiEEdnwu4pCe7wWvNMxFvEpifgUw/IOalXTD8h80hNnOoiOBIJyePx8mYJ5PyQPu3eKRm9GqW/NVkDOeTidhQW7sDlOuTM0Q64vMLt2wvzcPvePNy351O4HZfn0ulZY7WFWNrcLBVbVI9nZ4Ho6mtZWa+qf/8YNkulzfni5K2avt1Jycl9mv5ax8DV6lcQrl0nPbdwVboCky8NAvv4fxX1xZBkuj6s/aoIlh5Qzo0sDy8tjiyLbjN1mkrtDL71z6B2e0yXaQYGU5w0mCAsPKleHhKPHe0yVhA1BNfad7UjTz+Ahzce9Dfemd5oZgT+8cJvMcD1LUm7n1zg0+SHtcZbYYAiricmFNrSvM/taBm76cRyqZPIMeYT1nZesXlGcBrPD+FkrhQr9yW3rgk87nKybOuuglM2h8NEMDMY0BvYorKgP0ekvP+ffty/kmlqAg06cjwPkzVkLfg60ucko54llttgHj/7wZN+vFUdJEbi6/fGe8Rny1cC8PbMX2wvHI4COcie2Z6rlFfqKEcKzOxpFJKTYwUmKDPYQ5oRY67DJkFLmGTwqSonsYkMJzciq29N2cfKrmsFH8kt6yFq62bHCqrb+i07llMh3IoLanDQ+tEyz+n8TVZkYlOzjWs2Jr/KzU2OXFNkMcBKS9ogTQ4UQNOaAOvnPR/twSJmV1h9L7UvKX2j2qtYBMtvGu0cZXW87HxJ6bCycGpghmB7bC+qCFYuGj6yAiPCkrE8LDUhtH7HjlQTqBjxkS8Ie/IjEmN+GiLaBw19e/rh/M55Q7+9Ci/8t8EimgeVettO2JW+bH3DmmC9UavfLOHO2+qaa7LtspsA3PQOy2+eTUYPc0BkEN3o9+uFi+7TwaNvkbAeB9txP/iywvOsIKsKitWauy531lrZGX1+Aev5z1yeLrC2e+vzJl79soqDeRL85MOMDp388P8spycYp3vKecRSWwWxE+9C5bP8PK2P89cGvMilyYq7I9SK7ARZAaOag31ttsqWZ2nAeYXWcpj5utmuub0vcE/OKybj0m1Xmb0GK1m4R+UrDVp/PsYvTfL5i6QRdjMwB+F67Xsnh6Rbck/ea71Kz5GgMBPkzjLiI3bb7Y7ALwY3GE+hFolCraAIVT3Z2eP5iO2orU57IFS5iHyZQ4hlJE9QC4GmR0E6pGO047l2Irw0RJ3MYTXAcoDN6GWJWbvEu6hiai8DIbQxIgGaaUhG+9JSP7Wwd4diIaY3E5J8hgIFIO0UZGC6kdWA+4yyPWcAMOTWtmQN1ItDLl1CcC3kXNOQXTIyAqBX2xim+xZ1OUUPE4S+/2SJxcrNHLGlbm+ybFWuj6eGaW/LQ+qv/EYlnOSo0xxLsBuooqfNWeDJW8fdx46wjqkYgR13GwO+9GqcNKX//yM7fx45cGj8+wtx3z6EwxnCYOT79kF2OQ65YlHrcuyg83+TF5NyGCuoWT5//uloyECwmNNwSokc/wJ5HRGd7TFcOaK+vlYqtH6Lzx25Djli8dUA+gDb5fbT4rWql0zIXesOMdnmxV+q1sqe3g5A2Fq2TkZ/qPuKL9tzMA2EWjOTLGgZdHgaz9kM8bWFnBOJDx+wHwjHX7YbhA60yQ73hLvRG7AfObQ2ZoSuOBS+RBEfnr/IiqJ3IFRRnfGHw3WEsB/XXSppf/1y7rM97T1sBt1ffdZB1l+AbTXnskIVvFhTH8ursAogYJ2uaZkeopKJSpfUo3qcrzIqjJcQwHrrgYeokHmflS3FifY7keJZeA/6stOypuZUBMvfypCTcK1l3ALWT6qYJ6ZK3L6hAvQb+CG0wDJOnaB2XP4HmXCOeZNAltC/qAXqL/Sfj7mdfQT2UfsBNqHYxi6uw0cM2B/FgbJy45qfmBEehYhTYObOA+b8/Q2TCzbdXr9y7nbTN3GqVv+1XnNCxcqE/Pn8bwfIal5azXJ80W0IoNkgig3eHRcDaZTRilWJ96jxDnH2ois/0valvfzHTuQgcYx/ZzWVq0extMA/OjZ441ZdZbaaDpU5VF8YYSMmy3oJcn5sQG79dolj2RDzdODl6cGCArAFnh/x1IbUHTkwLnMjTYG0YkNv4nqSzdOI+cUoO3+WYkLSRL8oYRSsGUbIrYxjfoIUP2mikKByL0/337ic/PKvHCEW3f0ugsAyd34UDk4Ho6QmGtY9dUSGjCLHZflWzuStsjTivY+YhWnV4b4FcbfsEtYJN0KNNDOyhjxp9GM+Lkv/Onzt56YuipWI6nD9UEhD5qZZwP+8fnAGtkhbI1slFn+JcVfLxGZsMyAQzzZxj/mC4DdBfDIlhZICyRFIMh1cGoJATTQzTvrGkRTT6zuCMrmBivLAzMzAckUgN7Ocq1BwuYiz05Gww85hBH24Hg98OCudXQcTYnYkuWfUHe7AtGqH3x1uH74bbwfsIM1N9ah2tLl+yH1IyFKZp9Vq4QoJCvqHD08zhygdRFLugAtnGRjA+N0R9hG78UCrdM8HhP3EPS8yZ2YgCEBkRkDfiFHAc8LETK123xKQHXH7wfhgM5xZEsv4Sf9K/8k4anYSUDVVffRrls2ZjDVDbkMlqkH6YAeG3JqdIRFLREP0PPas23xy5aDLSD6TVRVPu+8KzuA3UO2oG/AnTYBJzpOVf01AkalpIN57l90ub3yg2xu3wonyzWnIdycl/fRSen9CfEJCl8tK5iBd2xu8wWm21XkVo9+tv6RES1+qp2gIHVK72PboG1mN5sf2CwRX6S2mBpHIKT8VOmpVEvsOOSol8d3mirwuM7Pd/vl8qSbwQljz4A27sL/OXvr8j1s287KXyG0uX0rC/FnnjE2FnPJFouVyxFiCtAgEsfvzUH51VEVF9Cy+R1to2Q0XAbPlofEn7U0rN4Y+Ud50bAkkaU4wP521ojQj400Qr2xLBgiERYrmagyVA/ZGYCOn2lClVFYaaGJIpFz695kWf5cN0rZzh5gRx8uR9icOCTc2pqv2VgQrv0UxzshoNqEHhFevXLkqvL+YQ5eRXb4msc67XhDQTZ9vjP7XbKs3XVBjXeMlUT+EBJ4Sj6R4atBvg/bSY3X9FaJdm5I8JJ4y67SRTBfxzHjP3FzP+Jkil6wkW+szzRpnDaPNQZOQcJLIdQoknqwcAyeGs2bRIpEHxrxOY+/BLQ/jHGEfH/bOyokUB4Gg5nL2OW/d0hlIcZov0Pol+/om5/imJPvkWt6kPAH/tgs4aKSczawE9Jc0IOfazGekdfqmWz/EmgnOvSeA9W5GxsfUTzxSfGSaVVyajc3ZfPnWn1QT/k6o67yxydmuI5pT2hC6tZ3TWQ3ifGeu+obvHMW3ucAoxbcvbilBjCzibaK3LqCMXGv/3s45PN1rjchblrZ/BKGLhNeuG65fDwl+4rvhtaPf01u3XhpnNzZu3uyyod1ZJQUwbD2F8VqdOp+BMNKjYZlAoBWfkrml0X/GBEGYRx2/DbpdofWOZVqXRS3irIlxeBZZZh3LnE+pPDGraMjydriCJpyflqhNbHAtGq6wHCkaPDGrotJb6OkpTBfwQLq1z0vmJAr+fOaBUSYIa0aM73vfn56VkZzdF58lS62+xG3TLTVByMgEMLOyLoFKtm2TQ8iDzau4pgZEpaG+dZsycKdxrImAmqnAQoZW8yvPSNUAmc5fGVXNN9oWy+y9fsDr98jKddhM8y5z7jSegRUqype4LQjIhLEqaAJn7FMdsboCM6yOfv0BNXc3Xzlj5/FI+IDn75EV68wuf+wKiy21K7ZtMQ+yCGz/8qAMemope/ZQtJ79lNs2QzZ7b706BoO3Kf3Oo2MjNv2HLm/Tfo958fbaWByyaRMSbD1ZQwZLumvIOatWtSIlRjmyfMUKhS1kqq46mXf+x1X83BXLkVhrpKQ1QnGa0cq8smpV5i8le99FNZ7z6lpyyPeNgVnZRrLyLK48MFDOzbohYK888Kx/4/nGe2yd+SPGYbV1U6Jk3ozVIeinBrieK8v1bCXMi5o108tEA7w2IpTr++/azGSa2WazmYxqkMmWt5uoNRVYrKvIYtuyGAMswAZq7bKlLYUg3WvfJi1hkZa24C1QQmKHfUViCZvU6oqQHRD627+l+yHNe8/lKRpPui1+IMKePWDbGHKCjG5Ln5GVPaRjAcDuJ2sgZH1pGggWInbrjTi7S64jSa7dkzQSscMZ12fbqBuDXecuBq5qWV+dgAf4S7+CPKeD6zXZtGafvHFK03Rj6Ar9yul+Oh780OhJZLETFGzp0MtNJCxWvELjthI2WwYHxXgLubZT/dH9wB3DtpN7/FVsXZrIrjIe+KWdZTs+oJ1f/9cTYqjjF0wo8fHf/Pr2AHy77NhL6osMyiw9rFe/AHJQyXfBnwvTE1xs2WzCOWOQBmKf2DEQbhc+ECZXUpqOkB1KuCxMQG05+yYdfFu2Lc5AvvrINDtNHnLxfZFBfUnh30IBXpjqecqBu0ze05tRCLhtcLIzpG6iMHaJa4O9DC5O1+PVktLqVQLuMvFDcNQx2BYmiJPQGiVJpmHPwzoZEl9+rJoeh9WxoS9OhmdIeqwOUSiEiL2QYiNDyGEhycmyIUkQ98PJVruIBu+T7IKcOoOxQXKPHx1lHcOjLFlRC/0y03X7ysJbeUZFvWS5EGwIrixGl3sX1Y9AJriwo1742lvhCf5Ozt0ePU72iB+b2XOMsv03q+Xsb6d+fMSA3UAEXtsXosLqg+KPMKUHZSIgANO8HwQPIC74wy5jLofxS5d1wVHZGG4IN13kw2bb67LZq1xWjVrqWLqAE3BmXrd9IRvjCnderPIwYvRfb2BdE/RnTs2TzJV0CEbRjq9rO9CN0PMvi9a4ulX89MGw67LFm1LUrTL182I8eH4CUZ/IK3s/Sjg0dqX+o1iv0c/8YrU1T1CcUMuznnXRws+MGZITmmNj5m9xcZZVcNwsQSnPuvqiuZ+ZDUcRoGDGyZ7kEahkAHbV/wxBEemrW4u9j82x1/aHHCwd/9pB95zLihU/quxdSPP45OET9sDT5yE3BuNPJBEuW8zYx3ze4Gdjv/QoI2KCIcT4Z56hkH7Y/jj/ImDzbwQyv/0/DPf3eguPlz9LVjzjV8H5dVHbR8BwJK/7XkDR7vgvz3/bsnJm8I/UNoHgsv/7IBHG35VEvJKk39OWm/wp9stuM6N+ucdiSOVnbUlE259jloLN+4g2qb322OONEbp3e7iwjuF10N5cpQnRbtjzf9M6uvtBm54kVgLa5N7NzU0OFsgqnCHFdI3rkUB4HhAkdOmAcwWQYQlCEGAssEoYg3JuDE4pZUtE73XE37V6NPf8nWsRFid/vzmTGyiTBRbOu4ml7Wj5D6wZQi5H0HefO9K+eN75u+f9j3shIU/mEfERMMvATN9u+16/t7b+jkNQd4iJCk0sNtlYLSnX+0a4TB6FYrYUUpcwuqJ9fTIUyNsQnewk1Mpo+VyyldQUlCIklfKyaCRXUuY7kkZi0nrF9tYuD2/5epgReiPsI3oJzsIWscOstJLSpvC+1I7M/kz1MTa6X6iA6W4OT60SRQHm+EOUxdMpI+JeabsodcSZ9i2SQn5+p/HRgDDgps3PBCacm16CzpDHjK4Mtk/0v5XpwnY2+zN1pbnl/YPamWFQwVhkLjCxZyfL/fx/O6KouGjncybdeV7o2/pd59ykOwYnJiNSR44lW5q2MAHoCVCCjYFXackU2zDyTNx8pwZHUyCxlzp2cM9uo8Y0rnDG2DSmjsczYlzyvAQOXVCso+E2Vw6YNSYgJRn4xB+gVohkers6M6/AfFHXodCSDTHKAzGr5nlnkO+NW0xRyHTT9e3u1+afK+9DFq1kLDID+YSNHXfXsrAYExObKmBSddwg9N2e9t8bhfz/tceReMdYZMOGu4ZHF/bszehcdo/MKTRJBkcauYQYLrfdzFbo0XEId1bQlsXF/5M+Qd7cgSS/j5XEp6Z+zGIQf9n+tnaw5sDi6bYs5COpqLEDaxYlunBipgqIud5wKq8w8//1/1B+7+DiS3tS78abL4qclqvfxuZyk38uwedhTGfgaXiLhvowh0b3EksT2xRTKnfPntq/fv/4NIFNwriZWnulNb4cL6GuBvEhCz3elFcHXEjzLrhQFVj+YOaiYPy3jAnyjHT3+Nny6YVR8ebtXC5pBqKuzG85v1mvcVpPlH997A5Fk0xnmuAp+LUI1rfl5+yfrdmtMQtM6U/TzJmEEDBwPKCW4fqHlxvWhhjA+beDew+2wIW+0vy+FGfBkJMII+ampIxHJ9KhuOA4xirmPHunuGv4dMaMwg9inIWQXsT3XTYAyYu6FR6iBMroW1D9rLIJ9Zo5Im/Hpwc4fLGKlNm6A+fpC8tcMRq827s3wKt3d/CorN8qE44TlEpmJXgL8TIT4mtiSlJTfBJu/P24yZgQlxAzGiOPkwtH52FSz8UlqRmQiFnOfP2oxjShZpcA3GRnc+pRzfkIrtxcRG2Ebcz+Wzr+crJQt+79Wtw65V8xM5sPG6fXGvNfDWdnsWc8W8mayDLw8EFWNlDrnEROKNDUW3cfnDrYEV0dmpyBdlU7OgOuXGLoN/K30BdGD5Mdn03ueYo3k7RcQvzRgGMaqflehc5zjIJ0HVDQhRMCAVe6EHTLzeRYxOwzhi0x6+jVOdBBG6Ih4Ea2ve6elJion+H6cc/XQbFq7apzfUxexhqSVRYncd4jLRKT7XvGkehmFcYLUeUFWLnN5IDgSFoFh1lBIOiwKZFB7QiBq6d4DHgTjWQylsRLLPaSeO8TcJqX2Fsik7lpZTGC2lrB0gCRHiMjkoGRFWZs/p+6jrjG/lhYtybclvP/0nVaQU5kK6B9cV7Gn/Atn8CfC/EK7Ex5VY+Dr2dViodDTV9umDOycXRNkVBl2YgnClayceSNR0KfH2o65SzLRrzWIP4peEGOvVQy8zuCz2ATYcUuplYA1wpD/Yt6o1PFbGWwEE6dGFP9uspVKxOrEt0FTgxqTBxVwBA4tWk/V30+sgnsMfOBkRL0DPVosqu18ZmuHnHsBFTSzNNt1Yu7cSj55mfzNpe2AM7nWCw6XpHrHDrH7O3z4vDnRfzQ8NGC8ABV4Xq1YJPlGLYEy35oOGDKpg3Sxko/3t4IP8sQPg4dGJxBcEJXRYhXuWIEYAAl8sjKfKJoElZZ2c3sOGspqrD1kHhZuVHdrDxbU1s9rVbenrFmFZaisx3TH6+n8pVYeaxvrM/57hrTNz5vgHeG1IpMpPcEidBQGDXlHro3yGmbEBl31C97sGLOHaiVu7ujo2i2aX+5sDtZ22ew8anuj9tylkgPthR7/OVxrBjTC6QQPyy1lRZlVuP5N2m26bYGYJj2oK1VW+9c7yJDXLuTem1E5BYqGbciMuINJZ2SNp5mWxmvW1tPp7SkXPXPLgJzrmKx8ojIzVSI2hYZcTq1NWUIBGHoXi1e6DSm5qPEGLmMnl2bUB64jQCPb8cQOPYxsZhX2Fg+RYVOVyWSrpqyfa2y8ViM6WjprJLfzdirsxS2zfSQeBMZTmSi1/KUFi+rFcsj1rbNUqjb6/zRoCBcPf+Y3S8fo08X5DAIP4oTDvnWFwh7LL+QGY6Xv2ATRqIrYri1pLlrF6i7qAwnzcJFG5ebV2TMXOSD1iQJ+fqm5wShgI6m6/vyeQtVs21bF7Xr5EmN1Xaim9U2Ytf1pq6umtlbttQzLC1ngZ09zN4pRj6oKIGdfY4d8ht1CTtsv7jBpo+qHbRxK6/f43A1+Z6rvFfNzltPgmn17TkOzQU9bhPvWIBlfNBD76nwiKVtP2OXYpURKEr0YAemczjpRQJeRNSwgrqisXELdYuxzZH8ZT2a/HyN07BUQUphvBDjpZ6B5jzlf4FZnc5v1yz5b06GBKlwH8MgcW8kJT//ockakYrxL6DQU5iX4827aDKTnux3FaHzAbMsk4zb4bHIJ6vT6ZucdP6Floou3YIMvqW39yHwvBmUWXTCwyNmRDcrGEUoTGbH0GfZmvHwFTrPMYOepoEiq0NrespH+FMKrPwEf1Q+oVZ1/KbxS1fqbdPsXTaa+duh0eiRQ79V3NgjfMjRhO24v1FjQxRSiTXlmhKpQheMZuP9A2Eazk1hI3+1GMgcGiULxUAMwWbShFpySKODzMo5W3P8gK9ejIspw4lMpCK5iySNx1v13QkprZ8GgCA3gwhf3vcZxsqxxowaPkUrRoDzCV91fG+jHxqV6JDYMB0acUwH3Ay2V6tnbsZtfLX/2qwvmEdaP5tTEfcujlu5EVEryIqjVrpZMPKViYLNVMyuLcQnDItHd7P6A5V0sDumNm3d3MJCblFqVkzSc5SVlVNYHlbxOB23ssZIiDo1eMplETng1v630SeR87j3hgdQpPcxJ/HsoA4zCHP6F77Rdzt0n3u9fjP5NyjrX597MdO/WTjEga9g/IplLWFHnl5C79j09e2bBcLkoBJKyYK0kLe/1rg+3TvWSyj2lHhn8IWzYUG1ININJkbByLhJnVn/I050gyMFH6iz4ZhKBijiDiekrLj0P6VUOb+3FGagVwCTQhuMHgLhRzpGhEwf8SaVdOyYolRBHpVREzg1ozKIIzLc3bXQ2jvKVXzHHL6LU1MD1oVn7xivJ4zMH1N3GqfTgtgGA0DH0AMyOP7pRQIeKLSWsqYr/4MfK2HlY/g/ZfImGL7F+wADO9DYQrSxeW9/KPzIJ/QWW6hILKSf6VBTSaNFcdBG0vkV0WWRkWXRFf8L2FsWedb//9lwHr8qh5cZFJTJyxlLkGCDs/7Yc0alLiQ3ODi3RsADNdak2R6P8xPzHyeu9OAYjTZEQ0kcZgGM7tQgI3y1bcWsx+m0lZmulheyWMmHwP0oTUtjl6KKRbCoV0gbwWLaBsSyiTL+9RGzJrgp5wH1ZM6jSkKlAg8GiE8D1HPyhLoMhaamnTYdzDqiu6SausoPMQZGrtDJC4VJTdJotfaOS3MO2xfVdhNdaYeO0A0AtvD4baOz1C/bF/T8z9Gf8+HkT/Cn5FOgbL9kHSU8OraaPspCTHkUWdOx4xNGNPKwYZXp9CtC9E76I7PvIN9zspNLAFJ3EuILyz9SkBe8MSkUYM10I9KU+rXG4T4uEFjJr2IWbYToZqW1crOxJnToqizlzSJQTk7BU938Bs2KidNDZULPHvwQn/8mThqXVwn+XN9h1yEe6mARWgmsDkbeA13NJF8ccIEAX41yAlDrUBj9sq4avplMlOLj7BMcK5ugJOsEmwRbaW2t1HazFuskqKnSMcE+Di8lJrs8fGg4fC0GnlUDL54Fu28xXNO3uAZeAIy05HhUM2YZAXwdzPuZTozgi9UBAwecX6yWQQaSjJ4Sy+mBa3NDg5WgJiQ/L9idujvxOID3gk5xVQu8mcggrib7VSeH5YeE5NeFKI3rLZvSlq9d5ynw8BAkC8RA8gSq1OK+X/Ym1WTWF19yN5FB1IM/Ffg7FqALNsemALDi1nBLHqso17t5+E0sTRJL7ykeFgh7JP3dsYKY4ZWayPUro04Iz2TnPyyenirOzj2TLrucPXY159Nwbxrp1sK/gkoURo3GMmmq1ulRPZg0AhjollgPILODVWv0KRn5h9Kk0ivuAXa9HSbATAUqoOPre/yisOE0TNOpUlCjukhxEuWckfnq0LTKqNKIiNI5Ah6YY2mvs+NZrHh57I5nyY138tiDWa71p35LrtPDU0QGsR1y/XxMfrl+lnPK+/XwemONgBsAf4Aj8Jb1Qc3ff0wA42RTZUgcHA/9eNoCk8e3x9MfUDwcVx5iR2MHzUxi/3u1pH76dZK0uYAX1vOhgNp4zXq7R260Ui1i2fllB6P1Gb63MeIba5RqK4Bo+SiM5pPBnLLI4oiI4siyj3r5I4qLI8oiP9rWmKWGz544MX7pEpg9O4ygsue+dcqoEsYMpVzVDqUOXWs5IaO+4ToU3GhvaMjOcuOS/j+6fLmGumJQ46QZHfl028W4rAo0LnKDocxMN55bVjajwf1mBgncFr0NXOmaszsarydbp30cANN1aclWYcySjzqG6XdOMKl27P7NsHrTr27jnIy5bWfJbyvvcnMbcWD35dnL0ox//9lP72+FpXv5IY3/OgARAmqrYrdFdYk/imKqhpe2CjAWlwYYDJxGVM/DcO5hVPYZq+GPyh83If3sCrU3yGwehDZc5MMcV9dx1anlJEFtXz4hcijEosY4E4YbOVlZnMaFGr16GqlCFKUKOX1bCVaMVEdj19eDX8nN8+DGNopEjbFcjzwumBA4tOvbHQQTYJ8rUySQcgzmMHh2OfORX1q6tn0svYEOQNwGvBl5984D9lAMHh5yCcCQQFB7CUxPN8MkP40TQdC71Fq49mvd4N72BSQl4jLPp6U8/h3cE8uhp5BkBhmkDll8HOxOpFT21c02wy1VneK94BYujLz56pQkD5jUGbN88SR0jHGkdXgd+T/Xcv8F+Iy7ad9bXcP9wGgjgxTj9Oe3wyxmM7cejw8PBvq3Hx6fZTmWCfffLYHLv6z4XDIlsND8T7cs7wUqGP31bVf63JpZYa75IzfXkJu7VICRfz9KwD9NJeABlaUaadH/ftLo1b121VGB+KkXm2WpasdNxuVZ5NARN8WaeTm2WVOQ4AYvvHTkAxNcXu7CzA3RPfE0yp9cInEHxhYN1YFseM/9QXjwMHz4a3QQrvyPyrqACIOP9Rpv5iT7StnsRN/k3OhZwbncrKyg3ODq8mMlPMXSqIvpXNeF6BZfaZciKC061zeZLZX6JPvmZHUpfKUt0RfOredi1FKe4lhJeXVucFYQNys4FwTasLbeOGV6D2CnwbKl/bT+tWtmzDBojWvWDtIGly4bpA+Ce6bbiH17EkpL37nExRUWzvQoLHjEgSsG1lenuskk3uqiOiHzc6x7/gJ618xtRXPWCnsCGGxwXq/8BbHunzP9wtJCUogqq0L+2VixWJjronQ9T08NTw5jy30zgU4dNDQNlz8PKcqtSR+Y1F2xu6ixK9MfvTgLBTf5oBXDyhUIv8y2B7nJCgvcS/4DvvLIlH7qSHJa06fgqfUmDx9NPHpo5bNebV6t3Yat69or4Ir2dVs3TCx79HDi4SMrn1+i35lLRZ16eQAYjTrcENwF64F+p6B89W9snPbBKOhR6xYC8Ig9uteF7yHjXrfxxy4UC/YhwGQfLIOBzl8P65Wz67lAN5NG/ZRe6fiBdmcDTiFcUT7c3U1iQHPDW2gYEMgp7A2CyRAxbrX0jaJYYOwGQTiRLKaH+br0u46ojltorTbCS1OISxSKJcSUpc5Z4cPi8nLx+P+vCRiFderln4qt3CLhO9KdimUXrd5V3bm8pMWscUY5typo2aUZLTMaAiq4VQFWsrBh4qnsOi3hWs4f/fVl2CKzLHauT/3+GaVmxewsn9ygKoOYLzSINEEbMCNQ6bJnCc4ywRK35Io2UoCaWSKT3HQ1DFQaHwAi7OR5XBOxW+dtNp3SARtn9WmXOfhRAAMlo+Y1wsRg7xrA8VufM57/7V5MfroWl4Bb+5RcfN79xLWRiueAwK8on+juVg+BJhPV5MaEokaDR6z5ZlHroG+Ggc92BZQ0mJulWxl2tlFn3VsEnq3FfQaegaLDuI1UQgoiIaRTObp6V+m+q4BKRSKMazgYExW/92jcHmH0wSXRazZ3S9mMVNJwn095LT7bfcMkRirViKtvLb5zGHAZ+VQsPFbQ2AAw12ulyD4Od9pVYBrnDj+OwwpdLhMku8fmPcy0kdG50pwEBvtL+uHY/F6C5LLQNc7scYppnGvMUJylyH7l2LzxCSGPCWJNYqFYUmkIMNlNGHjKrXEOc64JEqZT2sk2T2OUBGfX5yYB1bSo8nFJCZTHJ0LNTyRICVoA8+tqBYsE3LnJQrhqFn9RNV9QBXqezsjiSP3jElnJrBMAABuci83IwmCBGcY8LqV5vjAe5nhuDjs6+cic+p8xjyBMrWqNXClgsIV5DqTHzMCqmSq9iq+bi+pB4dPCCwUXxmnd22be/7u0OnC/v3NbYcCR0pZLf/rbOEV1fFk44h2+Tn42Lw1aSo9QPOf+n+CQ0GcExm6W9LKRSkUCW+LtLWEnHGSZ6h+0bRBwLSIE3yXdDa5/buJeeyWFLxs8zgb2NZffFT1pfdza8O5yjT0bfE+R8ZWDtSbf4aQwq3s6tgqtpTKq4olIELfoVQFilRnV8fUq7v+F8gIzPhCiwOl1cOoBBu+MsVCDCwKdUacDspSRjg9UgC9ehvaggeFP9YnuMTA6uYv979+JE3+Ul4/vKtaLYpraHy0H1/6QRXpxWMMNyDx1rjG4U/HtB2h8PaynKekwXd0tqWF4NgKMOqHOk7MZ5G+dz7FVZ78l2/iRGN3zvEigO1KivGH3FDK18BQ6pVPCxChADTjc/GoY3u30526YMmpFX2hwMLUa8yTa5H++wsj5MDamemW3Y3umYrML/3PWm/v+clj80Ucbs3YACga+padkOnifP59Dl22/hb/nI3HX6UBAb1Gf88cG9D16hDGCDABoP2PgGIl5Y0rM/Dh6WGIspP6G2/2HQlIv7R+7cd/UUOw7OfS4mPmNKYAvFhfPaUnNn2tCgdoIYKUrVT1wjzJ/9/PgJ8Fl+qUrm0tIX3biOJY7v5BKCFmHGxXg8fbbV6J5ackXozCqh1EYdHejC4yEMbVwHlJlX4oWZ49/Lnk0pbwlKdVN9k9XqDInQEWoeicx9bFxtfiucvvf+r+VzyVY1EiNAKM6/+eg8iHrWQPUaMHdc6OxratQVc6TJPCkITGKR3aaqGmm+0/sNwU0Jd/zT4WWi0x98euUwp8wZYr8ScZSAZPKFOtYVVXDvb0tNGhueNdn2XSgncygohWQoMN0FJ3CaQyl6rqEcQ0dvk0mG/TagVoHA1jJh7NUBQt3lC1CCXrNIjIlmg6WjOkYep0qNC84OC9U9UjA3rzg07CuHJGwYr28YlkSbYAAe0TC0tpGkPI6L6cD3WpFiKjjz6VAU6Q8e9BtVKJsTNynua5zp4t8BKStwe0tFZuZW5jNFSHt0FYfgZBLnMduUma0ORdY4E4S53Fvh/CJLxeAlbBAlQpFJYhMry0Srp9TPQ8WLBDNxLAvOL1twuP29tKYNzGl3LACQ+T4wi3ASx46ZvVZbhPGvUkzLmK8oOPT26MHo8pKJ0rLHOzLSg2lZYB/Uuhp/wS9qe2BUT2avI6BSXiyFcP580wygAasXbZ8uUGtXqfapFEMlDSb1AeHWf0guTQZsK5vX163m7e4vi7kS+IPfY4dM6QGhKYLluyq6PEtW46ZtmPR4w7ubcb2bqfk7hNo6XGz5gHX7hsTQ0xUXTcKPpmkdsNdMAaOS8IobY8Tx88xg8p49YpBddjlwI5+HCcsxqwuLmbC12/auBHQ1CpYJe5O4mbpeoUbyqQRzNGtiOVNr0rhxHj2ikzdCKuBydctHcei6TAdZ6CFU8HLBEGmKMplazHX5RAKrDBwEIzFO6TmYv4mwAgDtc/GjZu6EwQ9kGFUe1cP5rLPBMN6O4JvPwpz5sS4RF9RQMKYRcXbxxlv2V3EwcTRFXbiL0FB/NM2AQFV7PS/bXbCvhJjt9pUaDNXCm2fEe9gGWe/xT5ozkFm/R8TxOSBRMc9esqiNOr+vbvP4f+2cZZI9lqkQP6c/0QnkdOFxcpmsQqCl/1OeUSlByszf1NEL/mZYXqnAjb5Ds2Zo9Opyd2UVy7PZgqd3Zw529tLotdtLAFYqEWNxzfAyiDrgb6xYVs8H1WXZgGONs6AGXrAH4Vu+CiV8f59qRivM27cSMDYoDp8FDREG7fx9Om/jbLJ5h7jkCcDto8JjrG32hkMMo87smCWxWKfuTpPhuchNwWplF5CR+jyplLsbotInKFD0vbAOBlZGjI5FQ57egvjPTPj12+rJ4svNQG+Q54yWEaWzljglBRU7hs+NVkaEqGkDHl9JkE3YQCuza8DwAPPn03swxxMBlMANgFKuCFnh5wUWTKnw5bRHVsknS32wH4dWKLF/1q3BBuEWYcJwib7O9hFkySJHP+5Eozo88eRRH2dU1LGKYso42wGk2re/ypqYlHVjsugJ4u5GbhkevuzQtcCekS6kdtZRFXcqGi8WGVpdD2qp/EPEX9qhY6xDS8KjlKaN/iXZgs3K1AY1UTAVWvF8iyRf6lFQ1R+YXA36zusYMURSilOQTLZFiTRKd9C3YJT4bj4BvTiGrZ24kSO0bgCajH1XaEYqgs4+dV52TLnrydvQ0gsZNOTJ0JCGPjAViboyVmARf8vkZkxMSnx8VEqcea38FsIbcPXd2zaDHeiXM+tnlz0bo0sD3pgku2E7VbwlCRu0lZ1mAtq94CjwymgywFOnOZvDgIPdCV1AaOFMNr/fEcKo77D37VUZEOxsRGXuvp31KcwOs77H0znALsYjMG3lbxkH6mPT4Jv0jEBQxXZunzykkS0GNf8WhuxwNIWqV+RMjPDM68mn5fD4825+wkS/lETk+Cagv1IaGKl8EpVvwOXhAv84rdkuBw4tW/feGfnTmA8abydATJ+1lVDoI72pvL3xEmCy31hvM2OiRm6CrlKs3RYBYPu69+XlMMwa3E5uc9JTH2Ky3g+aIUsrnjCv7Gkomq+Ht+2RZWF95VHfnhCSUT4tvDikt4P5X8+HF4cVZZ400MJz0z8pPTwGVk/O63SnAtzqVfaQNtTW5qBb6CxGLh1X8nP1uGsmM5ccLfLchfO1nGDJUlOL/X4xI719oq1hD0QKeLhmHwVdL1xCYfDcTsLj/xlwWF7x8Z6dxhjAKHIEfQZZHJvZrptXBBG1GqmHA9XHn82dNb/1N1e5j46HZl8+8BtFM2orm5z8FZ1wnW5D3e9fnVrNlTpnoQ41DjPSkmvdquxzYz3riDOdn32bA2ckvJg9BU2gF5bQ2+64S5bMFlPP17i8qWJZqeDiwI1H4Y36nTV1Rmo1tZ5+9dh9NXpMm5X61QPnvdJ3e0bbBhnGRjPymq4rwCl7ir1yWJl8LoJgEHCEuVNGIWDQnqmQnJcLrXPzA8IhoMdP0jzS/zguH29MCbCjJgMCOOwJigmQ5hhAiKgrj7XvnJaE/SlGwfNFCpcSiDzWxNpudMcKvm5VuFMCNf9BWrCZ5+mTOy7d8zsHhAvbY0sDSsqCi+NeCdgsJ9fZGkrwd4Zl0PVY/P4R0ISZITVhbKNpzr1ZvKAE2NCSAL/SHgeVh61mycqsBZi5UbnGdgdvs4xk0/z319YibrSpuSoob7+/XsDjQZWBx796DVMbhZxt97q9ZjU6WH9pBro5ffwqSrwWYn2LiKL3NSBOoYlUnH8VYnnJqw3pD81KLCoz8XZRSQezH1kacD2wq6HjwoKy5kg3LR24YM79DutnCF9n8O9jO+k7mn+Sf4f5aW9nbo6/sm6d/VLoNcJjKK//9b+/bfxAwS2Ydes0V665OKiBae336i+eiTCRnVOTxtcXXfuNAC/NyPMU25wIq2PCQsV4YF5szi5snDecIKqJmRBAq9tmCcL5+TOCsxThAthZh8t0Q0+xRxZvTMsIcNbBQCDnanxcplJrMBEPB5ofN0qDXXLmpUgkVCQm6Vb3ERuwqPvwyxIauOl4zYi3Ic7dxSeAEM7J6y5dw1oTAoSWEZIQ4Xbq/67Fq2+u5tGM7x/X18vzlC+3bvlNT0B+Xg8haUKHYMj1OnpujrDzp2ubPzaVYl+udGoY1hulo1onZjhdxB+6GoINPDw9hDGeHo+8BQkJ3wiKDsdlm9AQpPgsJACZVjYgzDlRgoLRtQEePSo61FeBrucXTonZBhdxmi6KbhdwABEURie4ojs9yVjogrkODUKUBBw0+WqeMQ/x3+flP6t9P59w+tXPc+gswyk+//cM7x5uQeQMICQRbGlBNQy/jhVxkN4Yp7oEPOHbqj4R2++NOmMpDW8uuJYDdTpvvGsMCoTqiKVO5c6l6/fXeRd4F2JqcaoAu9k+4V1l/plreGVNyWwV20Tima2Ah06UL2wZow6/FvvExJZ5VLtUhV5xer/dUAFM7ZstmmyQy4Rxyf78JtRGFWBmmbKVU6Jn+xarobXpKf3w4OOGFZoViBDowHdOhil63nAdBTWlIhU42w30PFZD7d1C5+6OGAvhq5fH752zcBgWjzxvXXrJ5gpvk8dJz62fCu/+QZn5w0ggEx4904Mi/v+sSfXDnqWM64Ty2dZr//onKiY+0BM0Oq9DlIZbW0MHsPX14CWGXV21e9acixZIpKsemXkXiS2NTC+MMqA447t9O18rSKNogq06wPUOgADhdUMU5aAZYkdjwnXOGmiQ8GIWmYEOxwv0y9r+cVcAEFbfFw8RIjo+3p0fBWsUk5aXbqbdRgU+zE0HRhFPss+f90TwAonXWdEdTTQ7ZkZidSlJvylEfhm/vzZsJcXQkIGN7ylZXAsYeAedCyOy1RUMD9iDJ4J+kHVwmwlmlgM93n++ltkkR6SwvNB/BRmlwKRgPT0ACTwkpnCD/HhpYSkW+w2z+Ek+SUm+iVxcsyVgFPQkyRJN5mBjD/UgRyZjFMs4IFia9K02+fZMbM/x6wyORArffncPsHJBPk8WOk1Nfta3WTT5ZawsgIr9vJSS/hmZcDoazwqBmJjMfF0MenLOtwiJxm1bn97LMKt+0IqBr4YUlC/FmPVDrCX/uvt6hYXCPAnpzgnrduhHjrD8GwcmFz67Pl4drnxDQ05uZQ9/LAlbh/BTssP61Zina8ryaQwLpJTYTFF7UU+zuHdzRxz7CDK7MfXcgSoN03XJhTWPQ6bjQKcRSs0ApIAWm4Ynhg2BBU7FVHj46lFTsXCpqaSSf34z1dllZXDWrJv/jzZxYt9TU0QHxMNaOqeFDR+ETTq3ihjYOIlvSbte9Av9Jrl2QTFSdwDEgBM6nq0I86vuZU6ykIm1cquyaahTXZ9RowABRTQkXismQEObV+3LgPOaNEwPp3beXaMHuEwqlBMVFdPJCWWx4XVQleMkX5LgFbvRJAyO1wsOg41y5AWKOfixc7O/wXWRmsBz8ZbTm5aoiMzKbgZ+r4QOfLUsPHc2CvNTD03gtIiGAg6lI2TBnqngsmGogLD4QNHDxpKCj/Cuu4uJAWlikEaQkWdZFQ6tYBmXAY33xyeLQWh4DSBPKdu1t9F6bkWl2QlWTERBRvsEymCMPHL32SuIdvPVXT86Wt8IIu1dob7Sac+uy3LaJp+HvD0azqblRv2yZQebSF2f8ogmZxPj/H4WV6JFVh1r7m8tbJT4PiJFeA/flIQZi66kJ16ODHJ3OTmgOTPNSxgpJC9e8mn9QxP/YfOe8iosAb+oPCq6wDSJr82CY8oiSybA2nIVX90Tpgj4/aWf+grKQkXv1Fe7UAGZLCBZLVOmeabwuYkBSZbDnnJPNPjg6QheZYg2u2GqQhYKgrWbZYpTOMp/VwKBGSwZ053WUrshQGsuYHfrGDGoH9eAl9Qt53indpGncsKsBPiJF3T8R7eUwAnI4NTACwGvPjDAUPLvdwVM7VTs455zfugtXcj/1foBzeUy7sKPib2T1/mXnW/+oNc7h8Eg+se99Za+c90wSn6+a4vBuB5W1bjmpG0RZq70akRsTO1mh3FbLBv5m+cbr87X4jpB0OyzGYGdlI5Rx+qn6OcxB7f/GWZQ6AfI5x/t316t799M7MharaVqR3S6LQxV7olI6nG9fbQrD0rOCv2zBr61yRn0yfIkWpJ8GLEvi0zxaogyNra2aTph4fIHse8EbJ5IXZxrFI0swDHip9ZF7wYu3C555CLJcs9LsZ/qYmztTVbgzXdJlWwLCC6I/Rpk3iFwCVOx69Y0BukCOQqKmAKWcFAruwHML46ncOH4/XhegJw2R2VkOggTWj7TPLY5bKUV375vB+R2uRauPvz4vcL96jYe/D+0PlzyxG5sdfOc+einKF4/IZbLls6xk3db50qtLATdHumgXF2W9cNF8YhCnDjpmlHv2FnLRAEusfZhFj2E47tOXRHsNgSg5lRcHoj9hwj9FuGmJjMZAoDA7SjXup4qUybqO2T9SXKtNorCXuawqc3YW9B87O5FzhmsP9AWOYUe63ns74ARYl+XPU983Kp+7qgJ7Rn/tf689vFUwH7En667AcBVWRFWbHn9YOELYIpUePjkeYsNfXLg9d7VlyDmQ9KhhR1XGQXcGO6JeF+UOxIiunu0Q6ytf2RiDvlalXAeDq24nPw+AH47gF8ShybsFxxdQVgFEXnqtT6qQL04OySnEW+sT9TrM3m8bKD88cFDDY4PePP42tifRcdP3ToTwSdZc0LxegF90QPqAOWbrghcgNmW7d4ckMRXUSXpH2CcVdx7F8Cy9TCn9LoErroMwNVMPikIJrTKFkt4NG4TSvG/pCQs29/lgB1Doa+M3k3/ZuqksOtvzvIN6g3NqZQPtnBp8v+9kNqP0xrJUZ92A3sgf5kWE58hlcudIygm0h5l70mLOpvtYtqddx1Ulpn4WzlvV3cJDimnaLx5Kir7O6T5xhOUX6/NcHWOvKC6VMu8eS6p4aQfailjB/mLwo6jmoSIC3s1u/Z+Yo5k8d/2g/g/5IGrRMYQDX+7142lYOGRHLn0oOjnmQ3PcgE+y1PgnP68ONTaJ8CPtnrvqQsatVKL6nsB6hzq7nzhvdNe0nprfXWM4FCgcPzd6EP9FRupvxTVeahGwcoXu2lxAYxVaGuYE6V+h9XZ+MX9Oy0gLtndd/9yYp1X3V/aVoS2YNO5aR4t4JManHiap85rrkgfKHTGx+dK4jS2ax+pncXRzEWMaGZGN1JiyXUH001XJiNrC0q5RnHQa+NU2dSdxpPdEWK9C7RerIVIHMBo0QPWMjuQjBHrxgx64EduuccweOnXeHu6qWcWkJR+9rC9axnw4eY2kIwExox1KrPGjEoLlKvvw5KQr90mOtr4+SZjNyKSPcC+p2mS4o6JiQ77I8oepqyKoGOtqfH8nUZdfRPG7mGasg7WWhTlaPR1k6ARIOt0rxElKDnF2oo7WqlL2QUz6ofLsGpUJ26Zq7uV/qWgvIb6GdO8MJOgjw3/UEf59VoAk+r9Kt+oKy01PcVoIKIokBXV9E7DO68EqFTO71hwegvjFCrH/IaB1EVFD2elEyHwKvUfY6OXNOelXp0D90nFY+o2m2+MqHfkcqouBJLUFVFiCKNGMNMmJX3doLCl2ujJRZ4lmgox0YmxYUSiXKP1j2FuVnRZnXUZ5DJ0Z4iHxaF+1ryS5fmV3rKQt4DPdl/bD16f0QY64QW99b/L6Eqb20ASQUcSama2NV8nYesfieK38Er+0ypvjdQz1kQGc+sLjO40mRFP0BPqdyhwtw55FDf3j1KKFoIEsX7SnKTkHPcW8Okc7uoatmVS+ljuXPW3sEUTkU8VznpmusKUTBiBHf0xPRrD/tv5WrVLYp6Ur8X3Id+enkC33utih6j5ZYX5MLpy99bIoq9ekR3Bnu+V7j0763jeY7bMvUBX9XwDwt11TNUG6XMRKVv126BJa3IKlRI33Yv6klnOFpFNTPmLT07E5p9nb6/v3d2pEIA+odFY11BVn2vpL9jwO8arzz0Y5c8lkmPuPM7gp7zO1N4XG3n3OQ1R39nQcHbcNH56Py/mtkF68h/8AP4y8/70jgjL4KeSb3CNPj6hAJ4iy3qswE0zoY1uf6PqEI2AB43cRs1/0pq/z0YtCSAkDmbB5CA2ftpvIGg3asefA/m18TMM54e2JP+BFNgr7/ANNi3D90X+kcwB270T8A0uNk/Cy4SP/QcWFOrQGBncGzxoT/Asr7+BIvgrL/Asov2oftO/wg2Pe2fgGU/+2fB0yA+6jlwfMzJhuCYDsPlQFElq3h/ef+EY2ewz8X/htYIew9HP86nB3+OgvYl2rTTeO2eD9m0HH52fHXIrIdqeoPR307u9Ufv/Espy2+jlg9+bAiO6TBcnu9MUSXX9/G73p9w7AwmtP9tnzWCl/9wfPteYP85FVFRJ9ntNF47JvIhn4zXcvjZXCtk1kOlW91g9LfTRKL+6N19vSylb+2FIvfoT9ldqcf7PVzSEbs/aReaJPnpRSbBPXPhSoWXsoQuYel/Nf7ir/7m7/7hn/7l3/7jv/4n+AQYRAnKRhNyY8YKoapbpnHdydmdy3sDQAhGUAwnSIpmWI4XRElWVE03TMt2XM8PwihO0iwvyqpu2q4fxu00L7v94Xg6X663++P5en++PxTDCZKiGZbjBVGSFVXTDdOyHdfzgzCKkzTLi7Kqm7brh3E6HE/ny/V2fzxf78/395/p4kqqphumZTvS/b83/E/m8yNMKONCKm2s80EYxUma5UVZ1U3b7fUHw9F4Mp3NF8vVerPd7Q8AIqxqRKeMC2mYlu24nh/LGkJRJRQ94fXie19DSZ/LYS4HcFpOepZNmTE78IuMxsk/G/a62iwFKiTjdtfm/6olaPfjFcIWVCpTJky744bUElrIauW4AQ1ICpvNeBkULA2WXtC0t/VySCmoYPneW4oTPAPGdlHNkMFJhZllDjaiCxJUArdeqm4QnalOYORTQae4bF8cGDl98gzk9w9tM9VQGQTQvaAYTJJ6c7s8nsldYp/5xnHEJMj2gBP5jVfYjnK0DMxNlo9R8+d/IRPjmy6z6FlCVFaD5OMSoCjzNQfBoIIUv/29qbjtMkQCDpE7Vm5xQ/JgJOGr6VxyxNAbCbamvfu+/b7jfh/dhhM4hIrWVLYn9QzSVGMY1RLCUWVdfldUZqgNH5Z0wLsK0kjlCm6sTXp1j8ax6gI1Be+rkwqq7iWjkow7OYGz4S+lxCzqx1AcrRo1vOe0ttogr2IiP3J4X7msxTj1ufyrtqGR9mbgRT1GxIS+xJj2WTywxvkM2i93bE4ykj9Wcw4tGqJsZWc+KQcVBN6ZqFlU3RfhjZ7XilqqYWvIrBPkor1ovJMKjFjzsmfnV6vv35nyRzmQ1I/Wd5azGisve7pfm9HjtFOYfN9wooT6sidzeBeRM/X2vH7857/R/+52y9rjfZ3jndAqifq7Qpr/kM+GU+aJLCZvwEq3btBy1JBIRgeeF4UKg827r0VnjBO4dD4776CvaCpXJ7t/9dt/bI7PhAlsv0FHC5ERbHXjXt0GKgIp4a1YKdok5XcDXRIak+DufH9yYIobYHIDw856wLEFDdHGBxW3MWbfYrnroJweNYs4tGBEiQrUrwnYunR2qoyhIWO4+62gOSSsYF5QvOFcMwKjJLDwFRwfdd8ZlbsdPiBllOO12yrQnUMjGRlDnJRiGC5nkmTQ/zaBpLORO0poNAp4N5RnPHR3HaGAMRqmQB0MYYlJUDTDa0GAELmvKVgGtIlmeetDViu/dlaKW20ZqBhnZY3A4eewRCdKJGMf2hY9+d3zlKJOEY/F012QSMexxUbAQTqY9lBL7QyjjkJOKt5Xqp15ak5Bhjs57WuTwxhLTZ8HPIEBxW46XvwmYYso9ANF1LMz6+nZvWrlQZ7dRHsQ9+je0EbTXifRiDLtUDXBcyiaOuPqZ9CbqNyLLKpMzVetJ4A5au/ubdggJFYOm3I7Q3VsfddQPYGDtR3xSXG7Q6+7KMxCeM/yNlVuV92oX/oLMIbwaMnKKTC2a8iI/BQuRRgtGNQ7aysUtcy4V1ZIpgiE4Z6UCFf9C9ODGkqZKmIcySLbTHTbsU8yKw2zYZuSnR45ruoZTXN22XFX3L9XDSM1DEWFXK1JGok8o8pwIeMvTnB1LYuehFrW0E5oqtjclDmbissWgYLQ1JMyd59dCb2hAPTMu6KaGcn4mAZWBfc1txY1oXjHGZ5Ie9vcXWOAOMPGzU1nr6TOIxlzfmVzrfTMNu2S5gjb3P1hd5vA+N4FFqfmbXYKVbI5qDJvGPxdCWOeAVx10vqYcNJB48bQppYTcMJeZmM3TMoeU08iYfRuVVD6G6iVKap6/Hg/G9chTkeDgVMeFzJTeyeGZIrfASPuStb8SRaLOmVEbIMPu/6Z9BYMo4OMjMd938zyP3XcIisw5Am9J9OsO4sKXqS/agHi41NCSQjvyHSvUbJ0D5V3F7zzPfvme7hYGgry0V+sTaqQbnqLR2yMojXlm16qi9HEsKg5Hx9hhQSYbmOorm0oWX3dgcWoxUwyhy7vDCbSUfvAUStWwAWtLRvrSK7mF9UKzfGwC810ZNC2W3lWsyUaz/1FJfuqV5sBo6j1UhowCAKJvEqQzkz1BxSsrWe0qiQ+83kIUgowzMLMT5hM+sNlE3KdR7O8Ae8IZ7GmvC2PlN2U4YiympzAAo6zdQK3yHhCMCcBKr8GSVYpkVttCeyuv5dTc7XL9vB9tdvI0NpjFEnUbOEzpxXlMU8nbKVWAVEq1cxOHLLXtsogubZrB0rMXPVox0cqDk2+vbWW/fOKehOJB4ID8j3ihHH2npYWTrbWZ7WAuUM0TOQRLHX/XFavdlcZPc0+QXWdjNlL46R94BVdkrj2cPW+IeVQGTyrleW1thTkhKwDUv05v3boSoahOLL2FK+zj3B8ca8004QKSo1PQW30+gMV2SUf8ByHXW3ey8W8N9QHveAxtbeUB/NeV9qSY/OeFtDgcEJ4bnRbLHeRXUUTbpf2HNs1EOy5aW069PuKJv2QO7ZgnsQ2j561yEy10R8mBUyBgLfvebCCWtQuXs7YLIGhtwKcJCWwMn1XoyaQS4bOjB7CjI8aV4ZWgPlzS60EasKR6SEpaGt6CAAVRy/iBOZvBvDaCpjpuSm7WTixZRRpKUOlMQEySo9+H/N4r3u2DLKa+UCIB9W5sYxVmYAAe4FIhAQFb3HDUvCl4A5x+jRxVh1MEGzrICUT+lenYE1czXR2pj/tLcW19Ss27wia8yP9pGO2O3MaG5k33Ot7WU0OffgnXpTAATzgTgv/86tT0FrVvAt5WisaHpDriOjtStW5gM3//FlRTVOPRprtzbVMp870nVkvb/UXM0R886eg/ecg5an5G2CwElnj/AEAAAA=') + format('woff2'), + url('iconfont.ttf?t=1780293772856') format('truetype'), url('iconfont.svg?t=1780293772856#iconfont') format('svg'); +} +.iconfont { + font-size: 16px; + font-family: iconfont !important; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.iconicon_form::before { + content: '\e800'; +} +.iconicon_move2::before { + content: '\e801'; +} +.iconicon_send::before { + content: '\e7ff'; +} +.iconicon_end::before { + content: '\e7fe'; +} +.iconicon_down::before { + content: '\e7fd'; +} +.iconicon_left::before { + content: '\e7fa'; +} +.iconicon_right::before { + content: '\e7fb'; +} +.iconicon_up::before { + content: '\e7fc'; +} +.iconicon_aiming::before { + content: '\e7f3'; +} +.iconicon_zoom_out1::before { + content: '\e7f4'; +} +.iconicon_simplified::before { + content: '\e7f5'; +} +.iconicon_order_form1::before { + content: '\e7f6'; +} +.iconicon_zoom_in1::before { + content: '\e7f7'; +} +.iconicon_keyboard::before { + content: '\e7f8'; +} +.iconicon_numerous::before { + content: '\e7f9'; +} +.iconicon_order_form::before { + content: '\e7f2'; +} +.iconicon_add_one::before { + content: '\e7f1'; +} +.iconicon_qichacha::before { + content: '\e7f0'; +} +.iconicon_correlation::before { + content: '\e7ef'; +} +.iconicon_target::before { + content: '\e7ee'; +} +.iconicon_pie::before { + content: '\e7ed'; +} +.iconicon_contract::before { + content: '\e7ea'; +} +.iconicon_arithmetic::before { + content: '\e7eb'; +} +.iconicon_table::before { + content: '\e7ec'; +} +.iconicon_wait::before { + content: '\e622'; +} +.iconicon_skip_planarity::before { + content: '\e628'; +} +.iconicon_industry::before { + content: '\e7e9'; +} +.iconicon_functions_outlined::before { + content: '\e7e8'; +} +.iconicon_timeline::before { + content: '\e7e7'; +} +.iconicon_waterfalls::before { + content: '\e7e5'; +} +.iconicon_list::before { + content: '\e7e6'; +} +.iconicon_file-unknown_colorful1::before { + content: '\e7e4'; +} +.iconicon_link2::before { + content: '\e7e3'; +} +.iconicon_bot::before { + content: '\e7e2'; +} +.iconicon_365::before { + content: '\e7db'; +} +.iconicon_facial_clue::before { + content: '\e7dc'; +} +.iconicon_jin::before { + content: '\e7dd'; +} +.iconicon_facial_deal_win::before { + content: '\e7de'; +} +.iconicon_facial_business::before { + content: '\e7df'; +} +.iconicon_30::before { + content: '\e7e0'; +} +.iconicon_7::before { + content: '\e7e1'; +} +.iconicon_data_person::before { + content: '\e7d8'; +} +.iconicon_data_plan::before { + content: '\e7d9'; +} +.iconicon_data_record::before { + content: '\e7da'; +} +.iconicon_folder_collapse::before { + content: '\e7d6'; +} +.iconicon_folder_expansion::before { + content: '\e7d7'; +} +.iconicon_dashboard1::before { + content: '\e7d5'; +} +.iconicon_export::before { + content: '\e746'; +} +.iconicon_phone_outgoing::before { + content: '\e743'; +} +.iconicon_that_person::before { + content: '\e66a'; +} +.iconicon_file1::before { + content: '\e742'; +} +.iconicon_enterprise::before { + content: '\e732'; +} +.iconicon_send_colorful::before { + content: '\e7d4'; +} +.iconicon_edit1::before { + content: '\e71d'; +} +.iconicon_my::before { + content: '\e714'; +} +.iconicon_general::before { + content: '\e6ea'; +} +.iconicon_serious::before { + content: '\e6ef'; +} +.iconicon_succeed_filled::before { + content: '\e6ba'; +} +.iconicon_block_filled::before { + content: '\e6ec'; +} +.iconicon_testing::before { + content: '\e624'; +} +.iconicon_off_screen::before { + content: '\e62d'; +} +.iconicon_full_screen_one::before { + content: '\e631'; +} +.iconicon_disable::before { + content: '\e661'; +} +.icona-icon_file-compressed_colorful_ash::before { + content: '\e63f'; +} +.icona-icon_file-compressed_colorful::before { + content: '\e640'; +} +.iconicon_file-image_colorful_ash::before { + content: '\e642'; +} +.iconicon_file-ppt_colorful_ash::before { + content: '\e643'; +} +.iconicon_file-pdf_colorful_ash::before { + content: '\e644'; +} +.icona-icon_file-compressed_colorful1::before { + content: '\e645'; +} +.iconicon_file-image_colorful::before { + content: '\e646'; +} +.iconicon_file-word_colorful_ash::before { + content: '\e647'; +} +.iconicon_file-sketch_colorful::before { + content: '\e648'; +} +.iconicon_file-ppt_colorful::before { + content: '\e649'; +} +.iconicon_file_video_colorful_ash::before { + content: '\e64c'; +} +.iconicon_file-excel_colorful::before { + content: '\e64d'; +} +.iconicon_file-pdf_colorful::before { + content: '\e64e'; +} +.iconicon_file_video_colorful::before { + content: '\e64f'; +} +.iconicon_file-CSV_colorful_ash::before { + content: '\e650'; +} +.iconicon_file-sketch_colorful_ash::before { + content: '\e651'; +} +.iconicon_file-CSV_colorful::before { + content: '\e652'; +} +.iconicon_file-sql_colorful_ash::before { + content: '\e653'; +} +.iconicon_file-text_colorful::before { + content: '\e654'; +} +.iconicon_file-word_colorful::before { + content: '\e655'; +} +.iconicon_file-xmind_colorful::before { + content: '\e656'; +} +.iconicon_file-sql_colorful::before { + content: '\e657'; +} +.iconicon_file-text_colorful_ash::before { + content: '\e658'; +} +.iconicon_file-xmind_colorful_ash::before { + content: '\e659'; +} +.iconicon_file-unknown_colorful::before { + content: '\e660'; +} +.icona-icon_file-jar_colorful::before { + content: '\e7c5'; +} +.iconlogo_dingtalk::before { + content: '\e7cb'; +} +.iconlogo_lark::before { + content: '\e7d1'; +} +.iconlogo_wechat-work::before { + content: '\e7d2'; +} +.icona-icon_file-json::before { + content: '\e7d3'; +} +.icona-icon_file-JMX::before { + content: '\e69e'; +} +.iconicon_file_har::before { + content: '\e6a1'; +} +.iconicon_filter_clear::before { + content: '\e7cf'; +} +.iconicon_filter::before { + content: '\e7d0'; +} +.iconicon_file-excel_colorful_ash::before { + content: '\e64a'; +} +.iconicon_font_color::before { + content: '\e7ce'; +} +.iconicon_add_pic::before { + content: '\e7c4'; +} +.iconicon_text_underline::before { + content: '\e7c6'; +} +.iconicon_text_italic::before { + content: '\e7c7'; +} +.iconicon_add_text::before { + content: '\e7c8'; +} +.iconicon_align_text_right::before { + content: '\e7c9'; +} +.iconicon_align_text_left::before { + content: '\e7ca'; +} +.iconicon_text_bold::before { + content: '\e7cc'; +} +.iconicon_align_text_center::before { + content: '\e7cd'; +} +.iconicon_map::before { + content: '\e7bb'; +} +.icona-icon_multiple_selection_of_departments::before { + content: '\e7bc'; +} +.iconicon_calendar1::before { + content: '\e7bd'; +} +.iconicon_link1::before { + content: '\e7be'; +} +.icona-icon_collectbeifen12::before { + content: '\e7bf'; +} +.iconicon_department_single_choice::before { + content: '\e7c0'; +} +.iconicon_find::before { + content: '\e7c1'; +} +.iconicon_handwritten_signature::before { + content: '\e7c2'; +} +.iconicon_button::before { + content: '\e7c3'; +} +.iconicon_select_data::before { + content: '\e7ab'; +} +.iconicon_single_line_text::before { + content: '\e7ac'; +} +.iconicon_multiple_choice_of_members::before { + content: '\e7ad'; +} +.iconicon_subform::before { + content: '\e7ae'; +} +.iconicon_picture::before { + content: '\e7af'; +} +.iconicon_phone::before { + content: '\e7b0'; +} +.iconicon_local::before { + content: '\e7b1'; +} +.iconicon_dividing_line::before { + content: '\e7b2'; +} +.iconicon_multiline::before { + content: '\e7b3'; +} +.iconicon_member_single_choice::before { + content: '\e7b4'; +} +.iconicon_radio::before { + content: '\e7b5'; +} +.iconicon_serial_number::before { + content: '\e7b6'; +} +.iconicon_text_recognition::before { + content: '\e7b7'; +} +.iconicon_pull_down_single_choice::before { + content: '\e7b8'; +} +.iconicon_hashtag_key::before { + content: '\e7b9'; +} +.iconicon_pull_down_multiple_selection::before { + content: '\e7ba'; +} +.iconorder-descending::before { + content: '\e7a0'; +} +.iconicon_video::before { + content: '\e7a1'; +} +.iconicon_zoom_in::before { + content: '\e7a2'; +} +.iconorder-ascending::before { + content: '\e7a3'; +} +.iconicon_zoom_out::before { + content: '\e7a4'; +} +.iconicon_usergroup::before { + content: '\e7a5'; +} +.iconicon_wifi::before { + content: '\e7a6'; +} +.iconicon_view_module::before { + content: '\e7a7'; +} +.iconview-column::before { + content: '\e7a8'; +} +.iconview-list::before { + content: '\e7a9'; +} +.iconicon_wallet::before { + content: '\e7aa'; +} +.iconicon_usb::before { + content: '\e78c'; +} +.iconicon_user_add::before { + content: '\e78d'; +} +.iconicon_swap_right::before { + content: '\e78e'; +} +.iconicon_user_avatar::before { + content: '\e78f'; +} +.iconicon_thumb_up::before { + content: '\e790'; +} +.iconicon_time_filled::before { + content: '\e791'; +} +.iconicon_tools::before { + content: '\e792'; +} +.iconicon_user_clear::before { + content: '\e793'; +} +.iconicon_time::before { + content: '\e794'; +} +.iconicon_unfold_less::before { + content: '\e795'; +} +.iconicon_thumb_down::before { + content: '\e796'; +} +.iconicon_user_talk::before { + content: '\e797'; +} +.iconicon_tips::before { + content: '\e798'; +} +.iconicon_unfold_more::before { + content: '\e799'; +} +.iconicon_upload::before { + content: '\e79a'; +} +.iconicon_user::before { + content: '\e79b'; +} +.iconicon_usergroup_add::before { + content: '\e79c'; +} +.iconicon_swap::before { + content: '\e79d'; +} +.iconicon_usergroup_clear::before { + content: '\e79e'; +} +.iconicon_user_circle::before { + content: '\e79f'; +} +.iconicon_queue::before { + content: '\e782'; +} +.iconicon_refresh::before { + content: '\e783'; +} +.iconicon_pin::before { + content: '\e784'; +} +.iconicon_poweroff::before { + content: '\e785'; +} +.iconicon_photo::before { + content: '\e786'; +} +.iconicon_precise_monitor::before { + content: '\e787'; +} +.iconicon_print::before { + content: '\e788'; +} +.iconicon_play_circle::before { + content: '\e789'; +} +.iconicon_play::before { + content: '\e78a'; +} +.iconicon_remove::before { + content: '\e78b'; +} +.iconicon_server::before { + content: '\e776'; +} +.iconicon_rollfront::before { + content: '\e777'; +} +.iconicon_scan::before { + content: '\e778'; +} +.iconicon_rotation::before { + content: '\e779'; +} +.iconicon_pause_circle::before { + content: '\e77a'; +} +.iconicon_pause::before { + content: '\e77b'; +} +.iconicon_pin_filled::before { + content: '\e77c'; +} +.iconicon_play_circle_filled::before { + content: '\e77d'; +} +.iconicon_qrcode::before { + content: '\e77e'; +} +.iconicon_previous::before { + content: '\e77f'; +} +.iconicon_rollback::before { + content: '\e780'; +} +.iconicon_play_circle_stroke::before { + content: '\e781'; +} +.iconicon_swap_left::before { + content: '\e766'; +} +.iconicon_stop_circle::before { + content: '\e767'; +} +.iconicon_sound::before { + content: '\e768'; +} +.iconicon_setting::before { + content: '\e769'; +} +.iconicon_share::before { + content: '\e76a'; +} +.iconicon_search::before { + content: '\e76b'; +} +.iconicon_service::before { + content: '\e76c'; +} +.iconicon_star::before { + content: '\e76d'; +} +.iconicon_round::before { + content: '\e76e'; +} +.iconicon_star_filled::before { + content: '\e76f'; +} +.iconicon_shop::before { + content: '\e770'; +} +.iconicon_stop_circle_filled::before { + content: '\e771'; +} +.iconicon_slash::before { + content: '\e772'; +} +.iconicon_save::before { + content: '\e773'; +} +.iconicon_secured::before { + content: '\e774'; +} +.iconicon_root_list::before { + content: '\e775'; +} +.iconicon_menu-application1::before { + content: '\e75f'; +} +.iconicon_mobile_vibrate::before { + content: '\e760'; +} +.iconicon_mirror::before { + content: '\e761'; +} +.iconicon_move::before { + content: '\e762'; +} +.iconicon_page_first::before { + content: '\e763'; +} +.iconicon_notification_filled::before { + content: '\e764'; +} +.iconicon_minus_rectangle1::before { + content: '\e765'; +} +.iconicon_menu_unfold1::before { + content: '\e752'; +} +.iconicon_page_last::before { + content: '\e753'; +} +.iconicon_pause_circle_filled::before { + content: '\e754'; +} +.iconicon_money_circle::before { + content: '\e755'; +} +.iconicon_mail1::before { + content: '\e756'; +} +.iconicon_minus_circle_filled1::before { + content: '\e757'; +} +.iconicon_more::before { + content: '\e758'; +} +.iconicon_menu_fold1::before { + content: '\e759'; +} +.iconicon_next::before { + content: '\e75a'; +} +.iconicon_notification::before { + content: '\e75b'; +} +.iconicon_minus_circle1::before { + content: '\e75c'; +} +.iconicon_mobile::before { + content: '\e75d'; +} +.iconicon_order_adjustment_column::before { + content: '\e75e'; +} +.iconicon_jump::before { + content: '\e740'; +} +.iconicon_lock_off::before { + content: '\e741'; +} +.iconicon_loading::before { + content: '\e744'; +} +.iconicon_laptop::before { + content: '\e745'; +} +.iconicon_mail::before { + content: '\e747'; +} +.iconicon_minus_rectangle::before { + content: '\e748'; +} +.iconicon_location::before { + content: '\e749'; +} +.iconicon_lock_on::before { + content: '\e74a'; +} +.iconicon_link_unlink::before { + content: '\e74b'; +} +.iconicon_menu-application::before { + content: '\e74c'; +} +.iconicon_minus_circle::before { + content: '\e74d'; +} +.iconicon_logout::before { + content: '\e74e'; +} +.iconicon_link::before { + content: '\e74f'; +} +.iconicon_layers::before { + content: '\e750'; +} +.iconicon_login::before { + content: '\e751'; +} +.iconicon_file_paste::before { + content: '\e718'; +} +.iconicon_error_circle::before { + content: '\e719'; +} +.iconicon_file_word::before { + content: '\e71a'; +} +.iconicon_enter::before { + content: '\e71b'; +} +.iconicon_file::before { + content: '\e71c'; +} +.iconicon_fork::before { + content: '\e71e'; +} +.iconicon_file_copy::before { + content: '\e71f'; +} +.iconicon_flag::before { + content: '\e720'; +} +.iconicon_folder::before { + content: '\e721'; +} +.iconicon_file_image::before { + content: '\e722'; +} +.iconicon_file_powerpoint::before { + content: '\e723'; +} +.iconicon_file_add::before { + content: '\e724'; +} +.iconicon_file_unknown::before { + content: '\e725'; +} +.iconicon_error_circle_filled::before { + content: '\e726'; +} +.iconicon_help::before { + content: '\e727'; +} +.iconicon_fullscreen_exit::before { + content: '\e728'; +} +.iconicon_file_pdf::before { + content: '\e729'; +} +.iconicon_gift::before { + content: '\e72a'; +} +.iconicon_gender_female::before { + content: '\e72b'; +} +.iconicon_home1::before { + content: '\e72c'; +} +.iconicon_heart_filled::before { + content: '\e72d'; +} +.iconicon_info_circle::before { + content: '\e72e'; +} +.iconicon_file_icon::before { + content: '\e72f'; +} +.iconicon_heart::before { + content: '\e730'; +} +.iconicon_history::before { + content: '\e731'; +} +.iconicon_hourglass::before { + content: '\e733'; +} +.iconicon_info_circle_filled::before { + content: '\e734'; +} +.iconicon_folder_open::before { + content: '\e735'; +} +.iconicon_help_circle_filled::before { + content: '\e736'; +} +.iconicon_help_circle::before { + content: '\e737'; +} +.iconicon_image::before { + content: '\e738'; +} +.iconicon_gender_male::before { + content: '\e739'; +} +.iconicon_fullsreen::before { + content: '\e73a'; +} +.iconicon_image_error::before { + content: '\e73b'; +} +.iconicon_folder_add::before { + content: '\e73c'; +} +.iconicon_file_excel::before { + content: '\e73d'; +} +.iconicon_forward::before { + content: '\e73e'; +} +.iconicon_internet::before { + content: '\e73f'; +} +.iconicon_check_circle_filled::before { + content: '\e6f1'; +} +.iconicon_chevron_right_circle::before { + content: '\e6f2'; +} +.iconicon_creditcard::before { + content: '\e6f3'; +} +.iconicon_close_circle_filled::before { + content: '\e6f4'; +} +.iconicon_circle::before { + content: '\e6f5'; +} +.iconicon_chevron_left_circle::before { + content: '\e6f6'; +} +.iconicon_chevron_left_double::before { + content: '\e6f7'; +} +.iconicon_chevron_right::before { + content: '\e6f8'; +} +.iconicon_control_platform::before { + content: '\e6f9'; +} +.iconicon_close::before { + content: '\e6fa'; +} +.iconicon_check::before { + content: '\e6fb'; +} +.iconicon_chevron_right_double::before { + content: '\e6fc'; +} +.iconicon_check_rectangle_filled::before { + content: '\e6fd'; +} +.iconicon_cloud_upload::before { + content: '\e6fe'; +} +.iconicon_chevron_up_circle::before { + content: '\e6ff'; +} +.iconicon_chevron_down_circle::before { + content: '\e700'; +} +.iconicon_chevron_up_rectangle::before { + content: '\e701'; +} +.iconicon_close_rectangle::before { + content: '\e702'; +} +.iconicon_chevron_down_rectangle::before { + content: '\e703'; +} +.iconicon_chevron_left::before { + content: '\e704'; +} +.iconicon_check_circle::before { + content: '\e705'; +} +.iconicon_chevron_right_rectangle::before { + content: '\e706'; +} +.iconicon_check_rectangle::before { + content: '\e707'; +} +.iconicon_close_circle::before { + content: '\e708'; +} +.iconicon_cloud::before { + content: '\e709'; +} +.iconicon_chevron_down::before { + content: '\e70a'; +} +.iconicon_delete::before { + content: '\e70b'; +} +.iconicon_chevron_up::before { + content: '\e70c'; +} +.iconicon_code::before { + content: '\e70d'; +} +.iconicon_dashboard::before { + content: '\e70e'; +} +.iconicon_clear::before { + content: '\e70f'; +} +.iconicon_chevron_left_rectangle::before { + content: '\e710'; +} +.iconicon_cloud_download::before { + content: '\e711'; +} +.iconicon_desktop::before { + content: '\e712'; +} +.iconicon_ellipsis::before { + content: '\e713'; +} +.iconicon_edit::before { + content: '\e715'; +} +.iconicon_download::before { + content: '\e716'; +} +.iconicon_discount_filled::before { + content: '\e717'; +} +.iconicon_caret_down::before { + content: '\e6c9'; +} +.iconicon_backtop::before { + content: '\e6ca'; +} +.iconicon_caret_right::before { + content: '\e6cb'; +} +.iconicon_bulletpoint::before { + content: '\e6cc'; +} +.iconicon_caret_right_small::before { + content: '\e6cd'; +} +.iconicon_add::before { + content: '\e6ce'; +} +.iconicon_caret_left::before { + content: '\e6cf'; +} +.iconicon_caret_down_small::before { + content: '\e6d0'; +} +.iconicon_caret_left_small::before { + content: '\e6d1'; +} +.iconchart-bar::before { + content: '\e6d2'; +} +.iconicon_arrow_left::before { + content: '\e6d3'; +} +.iconicon_calendar::before { + content: '\e6d4'; +} +.iconicon_chart_pie::before { + content: '\e6d5'; +} +.iconicon_arrow_right::before { + content: '\e6d6'; +} +.iconicon_caret_up_small::before { + content: '\e6d7'; +} +.iconicon_browse::before { + content: '\e6d9'; +} +.iconicon_books::before { + content: '\e6db'; +} +.iconicon_app::before { + content: '\e6dc'; +} +.iconicon_arrow_up::before { + content: '\e6dd'; +} +.iconicon_backward::before { + content: '\e6df'; +} +.iconicon_cart::before { + content: '\e6e0'; +} +.iconicon_attach::before { + content: '\e6e1'; +} +.iconchart::before { + content: '\e6e2'; +} +.iconicon_chat::before { + content: '\e6e3'; +} +.iconicon_add_rectangle::before { + content: '\e6e4'; +} +.iconicon_add_circle::before { + content: '\e6e5'; +} +.iconicon_barcode::before { + content: '\e6e6'; +} +.iconicon_arrow_down_rectangle::before { + content: '\e6e7'; +} +.iconicon_browse_off::before { + content: '\e6e8'; +} +.iconicon_backtop_rectangle::before { + content: '\e6e9'; +} +.iconicon_arrow_down::before { + content: '\e6eb'; +} +.iconicon_call::before { + content: '\e6ed'; +} +.iconicon_caret_up::before { + content: '\e6ee'; +} +.iconicon_chart_bubble::before { + content: '\e6f0'; +} +.iconicon_data::before { + content: '\e6ae'; +} +.iconicon_clue::before { + content: '\e6af'; +} +.iconicon_business_opportunity::before { + content: '\e6b0'; +} +.iconicon_set_up::before { + content: '\e6a8'; +} +.iconicon_not_bookmarked::before { + content: '\e6a9'; +} +.iconicon_product::before { + content: '\e6aa'; +} +.iconicon_customer::before { + content: '\e6ab'; +} +.iconicon_home::before { + content: '\e6ac'; +} +.iconicon_collect::before { + content: '\e6ad'; +} +.iconicon_search-outline_outlined::before { + content: '\e6c2'; +} +.iconicon-alarmclock::before { + content: '\e6c8'; +} diff --git a/frontend/packages/web/src/assets/icon-font/iconfont.js b/frontend/packages/web/src/assets/icon-font/iconfont.js new file mode 100644 index 0000000..f2f1539 --- /dev/null +++ b/frontend/packages/web/src/assets/icon-font/iconfont.js @@ -0,0 +1,65 @@ +(window._iconfont_svg_string_4798260 = + ''), + ((l) => { + var a = (h = (h = document.getElementsByTagName('script'))[h.length - 1]).getAttribute('data-injectcss'), + h = h.getAttribute('data-disable-injectsvg'); + if (!h) { + var o, + i, + v, + c, + m, + z = function (a, h) { + h.parentNode.insertBefore(a, h); + }; + if (a && !l.__iconfont__svg__cssinject__) { + l.__iconfont__svg__cssinject__ = !0; + try { + document.write( + '' + ); + } catch (a) { + console && console.log(a); + } + } + (o = function () { + var a, + h = document.createElement('div'); + (h.innerHTML = l._iconfont_svg_string_4798260), + (h = h.getElementsByTagName('svg')[0]) && + (h.setAttribute('aria-hidden', 'true'), + (h.style.position = 'absolute'), + (h.style.width = 0), + (h.style.height = 0), + (h.style.overflow = 'hidden'), + (h = h), + (a = document.body).firstChild ? z(h, a.firstChild) : a.appendChild(h)); + }), + document.addEventListener + ? ~['complete', 'loaded', 'interactive'].indexOf(document.readyState) + ? setTimeout(o, 0) + : ((i = function () { + document.removeEventListener('DOMContentLoaded', i, !1), o(); + }), + document.addEventListener('DOMContentLoaded', i, !1)) + : document.attachEvent && + ((v = o), + (c = l.document), + (m = !1), + p(), + (c.onreadystatechange = function () { + 'complete' == c.readyState && ((c.onreadystatechange = null), t()); + })); + } + function t() { + m || ((m = !0), v()); + } + function p() { + try { + c.documentElement.doScroll('left'); + } catch (a) { + return void setTimeout(p, 50); + } + t(); + } + })(window); diff --git a/frontend/packages/web/src/assets/icon-font/iconfont.json b/frontend/packages/web/src/assets/icon-font/iconfont.json new file mode 100644 index 0000000..31e7415 --- /dev/null +++ b/frontend/packages/web/src/assets/icon-font/iconfont.json @@ -0,0 +1,2508 @@ +{ + "id": "4798260", + "name": "Cordys", + "font_family": "iconfont", + "css_prefix_text": "icon", + "description": "", + "glyphs": [ + { + "icon_id": "47677467", + "name": "icon_form", + "font_class": "icon_form", + "unicode": "e800", + "unicode_decimal": 59392 + }, + { + "icon_id": "47400912", + "name": "icon_move", + "font_class": "icon_move2", + "unicode": "e801", + "unicode_decimal": 59393 + }, + { + "icon_id": "47331100", + "name": "icon_send", + "font_class": "icon_send", + "unicode": "e7ff", + "unicode_decimal": 59391 + }, + { + "icon_id": "47291663", + "name": "icon_end", + "font_class": "icon_end", + "unicode": "e7fe", + "unicode_decimal": 59390 + }, + { + "icon_id": "47291429", + "name": "icon_down", + "font_class": "icon_down", + "unicode": "e7fd", + "unicode_decimal": 59389 + }, + { + "icon_id": "47291432", + "name": "icon_left", + "font_class": "icon_left", + "unicode": "e7fa", + "unicode_decimal": 59386 + }, + { + "icon_id": "47291431", + "name": "icon_right", + "font_class": "icon_right", + "unicode": "e7fb", + "unicode_decimal": 59387 + }, + { + "icon_id": "47291430", + "name": "icon_up", + "font_class": "icon_up", + "unicode": "e7fc", + "unicode_decimal": 59388 + }, + { + "icon_id": "47291059", + "name": "icon_aiming", + "font_class": "icon_aiming", + "unicode": "e7f3", + "unicode_decimal": 59379 + }, + { + "icon_id": "47291058", + "name": "icon_zoom_out", + "font_class": "icon_zoom_out1", + "unicode": "e7f4", + "unicode_decimal": 59380 + }, + { + "icon_id": "47291056", + "name": "icon_simplified", + "font_class": "icon_simplified", + "unicode": "e7f5", + "unicode_decimal": 59381 + }, + { + "icon_id": "47291057", + "name": "icon_order_form", + "font_class": "icon_order_form1", + "unicode": "e7f6", + "unicode_decimal": 59382 + }, + { + "icon_id": "47291055", + "name": "icon_zoom_in", + "font_class": "icon_zoom_in1", + "unicode": "e7f7", + "unicode_decimal": 59383 + }, + { + "icon_id": "47291053", + "name": "icon_keyboard", + "font_class": "icon_keyboard", + "unicode": "e7f8", + "unicode_decimal": 59384 + }, + { + "icon_id": "47291054", + "name": "icon_numerous", + "font_class": "icon_numerous", + "unicode": "e7f9", + "unicode_decimal": 59385 + }, + { + "icon_id": "46899131", + "name": "icon_order_form", + "font_class": "icon_order_form", + "unicode": "e7f2", + "unicode_decimal": 59378 + }, + { + "icon_id": "46661079", + "name": "icon_add_one", + "font_class": "icon_add_one", + "unicode": "e7f1", + "unicode_decimal": 59377 + }, + { + "icon_id": "46490896", + "name": "icon_qichacha", + "font_class": "icon_qichacha", + "unicode": "e7f0", + "unicode_decimal": 59376 + }, + { + "icon_id": "46261430", + "name": "icon_correlation", + "font_class": "icon_correlation", + "unicode": "e7ef", + "unicode_decimal": 59375 + }, + { + "icon_id": "46226266", + "name": "icon_target", + "font_class": "icon_target", + "unicode": "e7ee", + "unicode_decimal": 59374 + }, + { + "icon_id": "46180001", + "name": "icon_pie", + "font_class": "icon_pie", + "unicode": "e7ed", + "unicode_decimal": 59373 + }, + { + "icon_id": "46130676", + "name": "icon_contract", + "font_class": "icon_contract", + "unicode": "e7ea", + "unicode_decimal": 59370 + }, + { + "icon_id": "46130675", + "name": "icon_arithmetic", + "font_class": "icon_arithmetic", + "unicode": "e7eb", + "unicode_decimal": 59371 + }, + { + "icon_id": "46130674", + "name": "icon_table", + "font_class": "icon_table", + "unicode": "e7ec", + "unicode_decimal": 59372 + }, + { + "icon_id": "36192585", + "name": "icon_wait", + "font_class": "icon_wait", + "unicode": "e622", + "unicode_decimal": 58914 + }, + { + "icon_id": "36193519", + "name": "icon_skip_planarity", + "font_class": "icon_skip_planarity", + "unicode": "e628", + "unicode_decimal": 58920 + }, + { + "icon_id": "46016165", + "name": "icon_industry", + "font_class": "icon_industry", + "unicode": "e7e9", + "unicode_decimal": 59369 + }, + { + "icon_id": "36471606", + "name": "icon_functions_outlined", + "font_class": "icon_functions_outlined", + "unicode": "e7e8", + "unicode_decimal": 59368 + }, + { + "icon_id": "45749556", + "name": "icon_timeline", + "font_class": "icon_timeline", + "unicode": "e7e7", + "unicode_decimal": 59367 + }, + { + "icon_id": "45699122", + "name": "icon_waterfalls", + "font_class": "icon_waterfalls", + "unicode": "e7e5", + "unicode_decimal": 59365 + }, + { + "icon_id": "45699123", + "name": "icon_list", + "font_class": "icon_list", + "unicode": "e7e6", + "unicode_decimal": 59366 + }, + { + "icon_id": "34134721", + "name": "icon_file-unknown_colorful", + "font_class": "icon_file-unknown_colorful1", + "unicode": "e7e4", + "unicode_decimal": 59364 + }, + { + "icon_id": "45596831", + "name": "icon_link", + "font_class": "icon_link2", + "unicode": "e7e3", + "unicode_decimal": 59363 + }, + { + "icon_id": "45533015", + "name": "icon_bot", + "font_class": "icon_bot", + "unicode": "e7e2", + "unicode_decimal": 59362 + }, + { + "icon_id": "45453687", + "name": "icon_365", + "font_class": "icon_365", + "unicode": "e7db", + "unicode_decimal": 59355 + }, + { + "icon_id": "45453688", + "name": "icon_facial_clue", + "font_class": "icon_facial_clue", + "unicode": "e7dc", + "unicode_decimal": 59356 + }, + { + "icon_id": "45453683", + "name": "icon_jin", + "font_class": "icon_jin", + "unicode": "e7dd", + "unicode_decimal": 59357 + }, + { + "icon_id": "45453686", + "name": "icon_facial_deal_win", + "font_class": "icon_facial_deal_win", + "unicode": "e7de", + "unicode_decimal": 59358 + }, + { + "icon_id": "45453685", + "name": "icon_facial_business", + "font_class": "icon_facial_business", + "unicode": "e7df", + "unicode_decimal": 59359 + }, + { + "icon_id": "45453684", + "name": "icon_30", + "font_class": "icon_30", + "unicode": "e7e0", + "unicode_decimal": 59360 + }, + { + "icon_id": "45453682", + "name": "icon_7", + "font_class": "icon_7", + "unicode": "e7e1", + "unicode_decimal": 59361 + }, + { + "icon_id": "44783033", + "name": "icon_data_person", + "font_class": "icon_data_person", + "unicode": "e7d8", + "unicode_decimal": 59352 + }, + { + "icon_id": "44783034", + "name": "icon_data_plan", + "font_class": "icon_data_plan", + "unicode": "e7d9", + "unicode_decimal": 59353 + }, + { + "icon_id": "44783032", + "name": "icon_data_record", + "font_class": "icon_data_record", + "unicode": "e7da", + "unicode_decimal": 59354 + }, + { + "icon_id": "44765698", + "name": "icon_folder_collapse", + "font_class": "icon_folder_collapse", + "unicode": "e7d6", + "unicode_decimal": 59350 + }, + { + "icon_id": "44765697", + "name": "icon_folder_expansion", + "font_class": "icon_folder_expansion", + "unicode": "e7d7", + "unicode_decimal": 59351 + }, + { + "icon_id": "44764201", + "name": "icon_dashboard", + "font_class": "icon_dashboard1", + "unicode": "e7d5", + "unicode_decimal": 59349 + }, + { + "icon_id": "44646133", + "name": "icon_export", + "font_class": "icon_export", + "unicode": "e746", + "unicode_decimal": 59206 + }, + { + "icon_id": "44384502", + "name": "icon_phone_outgoing", + "font_class": "icon_phone_outgoing", + "unicode": "e743", + "unicode_decimal": 59203 + }, + { + "icon_id": "36511677", + "name": "icon_that_person", + "font_class": "icon_that_person", + "unicode": "e66a", + "unicode_decimal": 58986 + }, + { + "icon_id": "44245520", + "name": "icon_file", + "font_class": "icon_file1", + "unicode": "e742", + "unicode_decimal": 59202 + }, + { + "icon_id": "44245480", + "name": "icon_enterprise", + "font_class": "icon_enterprise", + "unicode": "e732", + "unicode_decimal": 59186 + }, + { + "icon_id": "38020829", + "name": "icon_send_colorful", + "font_class": "icon_send_colorful", + "unicode": "e7d4", + "unicode_decimal": 59348 + }, + { + "icon_id": "43908817", + "name": "icon_edit", + "font_class": "icon_edit1", + "unicode": "e71d", + "unicode_decimal": 59165 + }, + { + "icon_id": "43648007", + "name": "icon_my", + "font_class": "icon_my", + "unicode": "e714", + "unicode_decimal": 59156 + }, + { + "icon_id": "43356063", + "name": "icon_general", + "font_class": "icon_general", + "unicode": "e6ea", + "unicode_decimal": 59114 + }, + { + "icon_id": "43356062", + "name": "icon_serious", + "font_class": "icon_serious", + "unicode": "e6ef", + "unicode_decimal": 59119 + }, + { + "icon_id": "32853333", + "name": "icon_succeed_filled", + "font_class": "icon_succeed_filled", + "unicode": "e6ba", + "unicode_decimal": 59066 + }, + { + "icon_id": "34061213", + "name": "icon_block_filled", + "font_class": "icon_block_filled", + "unicode": "e6ec", + "unicode_decimal": 59116 + }, + { + "icon_id": "36193154", + "name": "icon_testing", + "font_class": "icon_testing", + "unicode": "e624", + "unicode_decimal": 58916 + }, + { + "icon_id": "36193894", + "name": "icon_off_screen", + "font_class": "icon_off_screen", + "unicode": "e62d", + "unicode_decimal": 58925 + }, + { + "icon_id": "36194721", + "name": "icon_full_screen_one", + "font_class": "icon_full_screen_one", + "unicode": "e631", + "unicode_decimal": 58929 + }, + { + "icon_id": "36196999", + "name": "icon_disable", + "font_class": "icon_disable", + "unicode": "e661", + "unicode_decimal": 58977 + }, + { + "icon_id": "36196448", + "name": "icon_file- compressed_colorful_ash 2", + "font_class": "a-icon_file-compressed_colorful_ash", + "unicode": "e63f", + "unicode_decimal": 58943 + }, + { + "icon_id": "36196449", + "name": "icon_file- compressed_colorful 2", + "font_class": "a-icon_file-compressed_colorful", + "unicode": "e640", + "unicode_decimal": 58944 + }, + { + "icon_id": "36196451", + "name": "icon_file-image_colorful_ash", + "font_class": "icon_file-image_colorful_ash", + "unicode": "e642", + "unicode_decimal": 58946 + }, + { + "icon_id": "36196452", + "name": "icon_file-ppt_colorful_ash", + "font_class": "icon_file-ppt_colorful_ash", + "unicode": "e643", + "unicode_decimal": 58947 + }, + { + "icon_id": "36196453", + "name": "icon_file-pdf_colorful_ash", + "font_class": "icon_file-pdf_colorful_ash", + "unicode": "e644", + "unicode_decimal": 58948 + }, + { + "icon_id": "36196454", + "name": "icon_file- compressed_colorful", + "font_class": "a-icon_file-compressed_colorful1", + "unicode": "e645", + "unicode_decimal": 58949 + }, + { + "icon_id": "36196455", + "name": "icon_file-image_colorful", + "font_class": "icon_file-image_colorful", + "unicode": "e646", + "unicode_decimal": 58950 + }, + { + "icon_id": "36196456", + "name": "icon_file-word_colorful_ash", + "font_class": "icon_file-word_colorful_ash", + "unicode": "e647", + "unicode_decimal": 58951 + }, + { + "icon_id": "36196457", + "name": "icon_file-sketch_colorful", + "font_class": "icon_file-sketch_colorful", + "unicode": "e648", + "unicode_decimal": 58952 + }, + { + "icon_id": "36196458", + "name": "icon_file-ppt_colorful", + "font_class": "icon_file-ppt_colorful", + "unicode": "e649", + "unicode_decimal": 58953 + }, + { + "icon_id": "36196460", + "name": "icon_file_video_colorful_ash", + "font_class": "icon_file_video_colorful_ash", + "unicode": "e64c", + "unicode_decimal": 58956 + }, + { + "icon_id": "36196461", + "name": "icon_file-excel_colorful", + "font_class": "icon_file-excel_colorful", + "unicode": "e64d", + "unicode_decimal": 58957 + }, + { + "icon_id": "36196462", + "name": "icon_file-pdf_colorful", + "font_class": "icon_file-pdf_colorful", + "unicode": "e64e", + "unicode_decimal": 58958 + }, + { + "icon_id": "36196463", + "name": "icon_file_video_colorful", + "font_class": "icon_file_video_colorful", + "unicode": "e64f", + "unicode_decimal": 58959 + }, + { + "icon_id": "36196464", + "name": "icon_file-CSV_colorful_ash", + "font_class": "icon_file-CSV_colorful_ash", + "unicode": "e650", + "unicode_decimal": 58960 + }, + { + "icon_id": "36196465", + "name": "icon_file-sketch_colorful_ash", + "font_class": "icon_file-sketch_colorful_ash", + "unicode": "e651", + "unicode_decimal": 58961 + }, + { + "icon_id": "36196466", + "name": "icon_file-CSV_colorful", + "font_class": "icon_file-CSV_colorful", + "unicode": "e652", + "unicode_decimal": 58962 + }, + { + "icon_id": "36196467", + "name": "icon_file-sql_colorful_ash", + "font_class": "icon_file-sql_colorful_ash", + "unicode": "e653", + "unicode_decimal": 58963 + }, + { + "icon_id": "36196468", + "name": "icon_file-text_colorful", + "font_class": "icon_file-text_colorful", + "unicode": "e654", + "unicode_decimal": 58964 + }, + { + "icon_id": "36196469", + "name": "icon_file-word_colorful", + "font_class": "icon_file-word_colorful", + "unicode": "e655", + "unicode_decimal": 58965 + }, + { + "icon_id": "36196470", + "name": "icon_file-xmind_colorful", + "font_class": "icon_file-xmind_colorful", + "unicode": "e656", + "unicode_decimal": 58966 + }, + { + "icon_id": "36196471", + "name": "icon_file-sql_colorful", + "font_class": "icon_file-sql_colorful", + "unicode": "e657", + "unicode_decimal": 58967 + }, + { + "icon_id": "36196472", + "name": "icon_file-text_colorful_ash", + "font_class": "icon_file-text_colorful_ash", + "unicode": "e658", + "unicode_decimal": 58968 + }, + { + "icon_id": "36196473", + "name": "icon_file-xmind_colorful_ash", + "font_class": "icon_file-xmind_colorful_ash", + "unicode": "e659", + "unicode_decimal": 58969 + }, + { + "icon_id": "36196937", + "name": "icon_file-unknown_colorful", + "font_class": "icon_file-unknown_colorful", + "unicode": "e660", + "unicode_decimal": 58976 + }, + { + "icon_id": "37227608", + "name": "icon_file- jar_colorful", + "font_class": "a-icon_file-jar_colorful", + "unicode": "e7c5", + "unicode_decimal": 59333 + }, + { + "icon_id": "37243340", + "name": "logo_dingtalk", + "font_class": "logo_dingtalk", + "unicode": "e7cb", + "unicode_decimal": 59339 + }, + { + "icon_id": "37243341", + "name": "logo_lark", + "font_class": "logo_lark", + "unicode": "e7d1", + "unicode_decimal": 59345 + }, + { + "icon_id": "37243383", + "name": "logo_wechat-work", + "font_class": "logo_wechat-work", + "unicode": "e7d2", + "unicode_decimal": 59346 + }, + { + "icon_id": "38747707", + "name": "icon_file- json", + "font_class": "a-icon_file-json", + "unicode": "e7d3", + "unicode_decimal": 59347 + }, + { + "icon_id": "41446178", + "name": "icon_file- JMX", + "font_class": "a-icon_file-JMX", + "unicode": "e69e", + "unicode_decimal": 59038 + }, + { + "icon_id": "41701029", + "name": "icon_file_har", + "font_class": "icon_file_har", + "unicode": "e6a1", + "unicode_decimal": 59041 + }, + { + "icon_id": "43095419", + "name": "icon_filter_clear", + "font_class": "icon_filter_clear", + "unicode": "e7cf", + "unicode_decimal": 59343 + }, + { + "icon_id": "43095418", + "name": "icon_filter", + "font_class": "icon_filter", + "unicode": "e7d0", + "unicode_decimal": 59344 + }, + { + "icon_id": "36196459", + "name": "icon_file-excel_colorful_ash", + "font_class": "icon_file-excel_colorful_ash", + "unicode": "e64a", + "unicode_decimal": 58954 + }, + { + "icon_id": "43088962", + "name": "icon_font_color", + "font_class": "icon_font_color", + "unicode": "e7ce", + "unicode_decimal": 59342 + }, + { + "icon_id": "43088706", + "name": "icon_add_pic", + "font_class": "icon_add_pic", + "unicode": "e7c4", + "unicode_decimal": 59332 + }, + { + "icon_id": "43088703", + "name": "icon_text_underline", + "font_class": "icon_text_underline", + "unicode": "e7c6", + "unicode_decimal": 59334 + }, + { + "icon_id": "43088704", + "name": "icon_text_italic", + "font_class": "icon_text_italic", + "unicode": "e7c7", + "unicode_decimal": 59335 + }, + { + "icon_id": "43088696", + "name": "icon_add_text", + "font_class": "icon_add_text", + "unicode": "e7c8", + "unicode_decimal": 59336 + }, + { + "icon_id": "43088700", + "name": "icon_align_text_right", + "font_class": "icon_align_text_right", + "unicode": "e7c9", + "unicode_decimal": 59337 + }, + { + "icon_id": "43088701", + "name": "icon_align_text_left", + "font_class": "icon_align_text_left", + "unicode": "e7ca", + "unicode_decimal": 59338 + }, + { + "icon_id": "43088699", + "name": "icon_text_bold", + "font_class": "icon_text_bold", + "unicode": "e7cc", + "unicode_decimal": 59340 + }, + { + "icon_id": "43088697", + "name": "icon_align_text_center", + "font_class": "icon_align_text_center", + "unicode": "e7cd", + "unicode_decimal": 59341 + }, + { + "icon_id": "43075208", + "name": "icon_map", + "font_class": "icon_map", + "unicode": "e7bb", + "unicode_decimal": 59323 + }, + { + "icon_id": "43075205", + "name": "icon_multiple_selection _of_departments", + "font_class": "a-icon_multiple_selection_of_departments", + "unicode": "e7bc", + "unicode_decimal": 59324 + }, + { + "icon_id": "43075206", + "name": "icon_calendar", + "font_class": "icon_calendar1", + "unicode": "e7bd", + "unicode_decimal": 59325 + }, + { + "icon_id": "43075207", + "name": "icon_link", + "font_class": "icon_link1", + "unicode": "e7be", + "unicode_decimal": 59326 + }, + { + "icon_id": "43075202", + "name": "icon_check", + "font_class": "a-icon_collectbeifen12", + "unicode": "e7bf", + "unicode_decimal": 59327 + }, + { + "icon_id": "43075204", + "name": "icon_department_single_choice", + "font_class": "icon_department_single_choice", + "unicode": "e7c0", + "unicode_decimal": 59328 + }, + { + "icon_id": "43075203", + "name": "icon_find", + "font_class": "icon_find", + "unicode": "e7c1", + "unicode_decimal": 59329 + }, + { + "icon_id": "43075201", + "name": "icon_handwritten_signature", + "font_class": "icon_handwritten_signature", + "unicode": "e7c2", + "unicode_decimal": 59330 + }, + { + "icon_id": "43075200", + "name": "icon_button", + "font_class": "icon_button", + "unicode": "e7c3", + "unicode_decimal": 59331 + }, + { + "icon_id": "43075224", + "name": "icon_select_data", + "font_class": "icon_select_data", + "unicode": "e7ab", + "unicode_decimal": 59307 + }, + { + "icon_id": "43075223", + "name": "icon_single_line_text", + "font_class": "icon_single_line_text", + "unicode": "e7ac", + "unicode_decimal": 59308 + }, + { + "icon_id": "43075222", + "name": "icon_multiple_choice_of_members", + "font_class": "icon_multiple_choice_of_members", + "unicode": "e7ad", + "unicode_decimal": 59309 + }, + { + "icon_id": "43075221", + "name": "icon_subform", + "font_class": "icon_subform", + "unicode": "e7ae", + "unicode_decimal": 59310 + }, + { + "icon_id": "43075220", + "name": "icon_picture", + "font_class": "icon_picture", + "unicode": "e7af", + "unicode_decimal": 59311 + }, + { + "icon_id": "43075212", + "name": "icon_phone", + "font_class": "icon_phone", + "unicode": "e7b0", + "unicode_decimal": 59312 + }, + { + "icon_id": "43075219", + "name": "icon_local", + "font_class": "icon_local", + "unicode": "e7b1", + "unicode_decimal": 59313 + }, + { + "icon_id": "43075218", + "name": "icon_dividing_line", + "font_class": "icon_dividing_line", + "unicode": "e7b2", + "unicode_decimal": 59314 + }, + { + "icon_id": "43075217", + "name": "icon_multiline", + "font_class": "icon_multiline", + "unicode": "e7b3", + "unicode_decimal": 59315 + }, + { + "icon_id": "43075216", + "name": "icon_member_single_choice", + "font_class": "icon_member_single_choice", + "unicode": "e7b4", + "unicode_decimal": 59316 + }, + { + "icon_id": "43075214", + "name": "icon_radio", + "font_class": "icon_radio", + "unicode": "e7b5", + "unicode_decimal": 59317 + }, + { + "icon_id": "43075215", + "name": "icon_serial_number", + "font_class": "icon_serial_number", + "unicode": "e7b6", + "unicode_decimal": 59318 + }, + { + "icon_id": "43075213", + "name": "icon_text_recognition", + "font_class": "icon_text_recognition", + "unicode": "e7b7", + "unicode_decimal": 59319 + }, + { + "icon_id": "43075211", + "name": "icon_pull_down_single_choice", + "font_class": "icon_pull_down_single_choice", + "unicode": "e7b8", + "unicode_decimal": 59320 + }, + { + "icon_id": "43075210", + "name": "icon_hashtag_key", + "font_class": "icon_hashtag_key", + "unicode": "e7b9", + "unicode_decimal": 59321 + }, + { + "icon_id": "43075209", + "name": "icon_pull_down_multiple_selection", + "font_class": "icon_pull_down_multiple_selection", + "unicode": "e7ba", + "unicode_decimal": 59322 + }, + { + "icon_id": "43002118", + "name": "order-descending", + "font_class": "order-descending", + "unicode": "e7a0", + "unicode_decimal": 59296 + }, + { + "icon_id": "43002117", + "name": "icon_video", + "font_class": "icon_video", + "unicode": "e7a1", + "unicode_decimal": 59297 + }, + { + "icon_id": "43002115", + "name": "icon_zoom_in", + "font_class": "icon_zoom_in", + "unicode": "e7a2", + "unicode_decimal": 59298 + }, + { + "icon_id": "43002116", + "name": "order-ascending", + "font_class": "order-ascending", + "unicode": "e7a3", + "unicode_decimal": 59299 + }, + { + "icon_id": "43002114", + "name": "icon_zoom_out", + "font_class": "icon_zoom_out", + "unicode": "e7a4", + "unicode_decimal": 59300 + }, + { + "icon_id": "43002113", + "name": "icon_usergroup", + "font_class": "icon_usergroup", + "unicode": "e7a5", + "unicode_decimal": 59301 + }, + { + "icon_id": "43002112", + "name": "icon_wifi", + "font_class": "icon_wifi", + "unicode": "e7a6", + "unicode_decimal": 59302 + }, + { + "icon_id": "43002110", + "name": "icon_view_module", + "font_class": "icon_view_module", + "unicode": "e7a7", + "unicode_decimal": 59303 + }, + { + "icon_id": "43002111", + "name": "view-column", + "font_class": "view-column", + "unicode": "e7a8", + "unicode_decimal": 59304 + }, + { + "icon_id": "43002109", + "name": "view-list", + "font_class": "view-list", + "unicode": "e7a9", + "unicode_decimal": 59305 + }, + { + "icon_id": "43002108", + "name": "icon_wallet", + "font_class": "icon_wallet", + "unicode": "e7aa", + "unicode_decimal": 59306 + }, + { + "icon_id": "43001922", + "name": "icon_usb", + "font_class": "icon_usb", + "unicode": "e78c", + "unicode_decimal": 59276 + }, + { + "icon_id": "43001923", + "name": "icon_user_add", + "font_class": "icon_user_add", + "unicode": "e78d", + "unicode_decimal": 59277 + }, + { + "icon_id": "43001924", + "name": "icon_swap_right", + "font_class": "icon_swap_right", + "unicode": "e78e", + "unicode_decimal": 59278 + }, + { + "icon_id": "43001925", + "name": "icon_user_avatar", + "font_class": "icon_user_avatar", + "unicode": "e78f", + "unicode_decimal": 59279 + }, + { + "icon_id": "43001926", + "name": "icon_thumb_up", + "font_class": "icon_thumb_up", + "unicode": "e790", + "unicode_decimal": 59280 + }, + { + "icon_id": "43001927", + "name": "icon_time_filled", + "font_class": "icon_time_filled", + "unicode": "e791", + "unicode_decimal": 59281 + }, + { + "icon_id": "43001928", + "name": "icon_tools", + "font_class": "icon_tools", + "unicode": "e792", + "unicode_decimal": 59282 + }, + { + "icon_id": "43001929", + "name": "icon_user_clear", + "font_class": "icon_user_clear", + "unicode": "e793", + "unicode_decimal": 59283 + }, + { + "icon_id": "43001930", + "name": "icon_time", + "font_class": "icon_time", + "unicode": "e794", + "unicode_decimal": 59284 + }, + { + "icon_id": "43001931", + "name": "icon_unfold_less", + "font_class": "icon_unfold_less", + "unicode": "e795", + "unicode_decimal": 59285 + }, + { + "icon_id": "43001932", + "name": "icon_thumb_down", + "font_class": "icon_thumb_down", + "unicode": "e796", + "unicode_decimal": 59286 + }, + { + "icon_id": "43001933", + "name": "icon_user_talk", + "font_class": "icon_user_talk", + "unicode": "e797", + "unicode_decimal": 59287 + }, + { + "icon_id": "43001934", + "name": "icon_tips", + "font_class": "icon_tips", + "unicode": "e798", + "unicode_decimal": 59288 + }, + { + "icon_id": "43001935", + "name": "icon_unfold_more", + "font_class": "icon_unfold_more", + "unicode": "e799", + "unicode_decimal": 59289 + }, + { + "icon_id": "43001936", + "name": "icon_upload", + "font_class": "icon_upload", + "unicode": "e79a", + "unicode_decimal": 59290 + }, + { + "icon_id": "43001937", + "name": "icon_user", + "font_class": "icon_user", + "unicode": "e79b", + "unicode_decimal": 59291 + }, + { + "icon_id": "43001938", + "name": "icon_usergroup_add", + "font_class": "icon_usergroup_add", + "unicode": "e79c", + "unicode_decimal": 59292 + }, + { + "icon_id": "43001939", + "name": "icon_swap", + "font_class": "icon_swap", + "unicode": "e79d", + "unicode_decimal": 59293 + }, + { + "icon_id": "43001940", + "name": "icon_usergroup_clear", + "font_class": "icon_usergroup_clear", + "unicode": "e79e", + "unicode_decimal": 59294 + }, + { + "icon_id": "43001941", + "name": "icon_user_circle", + "font_class": "icon_user_circle", + "unicode": "e79f", + "unicode_decimal": 59295 + }, + { + "icon_id": "43001843", + "name": "icon_queue", + "font_class": "icon_queue", + "unicode": "e782", + "unicode_decimal": 59266 + }, + { + "icon_id": "43001831", + "name": "icon_refresh", + "font_class": "icon_refresh", + "unicode": "e783", + "unicode_decimal": 59267 + }, + { + "icon_id": "43001841", + "name": "icon_pin", + "font_class": "icon_pin", + "unicode": "e784", + "unicode_decimal": 59268 + }, + { + "icon_id": "43001840", + "name": "icon_poweroff", + "font_class": "icon_poweroff", + "unicode": "e785", + "unicode_decimal": 59269 + }, + { + "icon_id": "43001838", + "name": "icon_photo", + "font_class": "icon_photo", + "unicode": "e786", + "unicode_decimal": 59270 + }, + { + "icon_id": "43001834", + "name": "icon_precise_monitor", + "font_class": "icon_precise_monitor", + "unicode": "e787", + "unicode_decimal": 59271 + }, + { + "icon_id": "43001837", + "name": "icon_print", + "font_class": "icon_print", + "unicode": "e788", + "unicode_decimal": 59272 + }, + { + "icon_id": "43001835", + "name": "icon_play_circle", + "font_class": "icon_play_circle", + "unicode": "e789", + "unicode_decimal": 59273 + }, + { + "icon_id": "43001833", + "name": "icon_play", + "font_class": "icon_play", + "unicode": "e78a", + "unicode_decimal": 59274 + }, + { + "icon_id": "43001832", + "name": "icon_remove", + "font_class": "icon_remove", + "unicode": "e78b", + "unicode_decimal": 59275 + }, + { + "icon_id": "43001880", + "name": "icon_server", + "font_class": "icon_server", + "unicode": "e776", + "unicode_decimal": 59254 + }, + { + "icon_id": "43001878", + "name": "icon_rollfront", + "font_class": "icon_rollfront", + "unicode": "e777", + "unicode_decimal": 59255 + }, + { + "icon_id": "43001877", + "name": "icon_scan", + "font_class": "icon_scan", + "unicode": "e778", + "unicode_decimal": 59256 + }, + { + "icon_id": "43001876", + "name": "icon_rotation", + "font_class": "icon_rotation", + "unicode": "e779", + "unicode_decimal": 59257 + }, + { + "icon_id": "43001847", + "name": "icon_pause_circle", + "font_class": "icon_pause_circle", + "unicode": "e77a", + "unicode_decimal": 59258 + }, + { + "icon_id": "43001850", + "name": "icon_pause", + "font_class": "icon_pause", + "unicode": "e77b", + "unicode_decimal": 59259 + }, + { + "icon_id": "43001846", + "name": "icon_pin_filled", + "font_class": "icon_pin_filled", + "unicode": "e77c", + "unicode_decimal": 59260 + }, + { + "icon_id": "43001849", + "name": "icon_play_circle_filled", + "font_class": "icon_play_circle_filled", + "unicode": "e77d", + "unicode_decimal": 59261 + }, + { + "icon_id": "43001848", + "name": "icon_qrcode", + "font_class": "icon_qrcode", + "unicode": "e77e", + "unicode_decimal": 59262 + }, + { + "icon_id": "43001844", + "name": "icon_previous", + "font_class": "icon_previous", + "unicode": "e77f", + "unicode_decimal": 59263 + }, + { + "icon_id": "43001845", + "name": "icon_rollback", + "font_class": "icon_rollback", + "unicode": "e780", + "unicode_decimal": 59264 + }, + { + "icon_id": "43001842", + "name": "icon_play_circle_stroke", + "font_class": "icon_play_circle_stroke", + "unicode": "e781", + "unicode_decimal": 59265 + }, + { + "icon_id": "43001894", + "name": "icon_swap_left", + "font_class": "icon_swap_left", + "unicode": "e766", + "unicode_decimal": 59238 + }, + { + "icon_id": "43001895", + "name": "icon_stop_circle", + "font_class": "icon_stop_circle", + "unicode": "e767", + "unicode_decimal": 59239 + }, + { + "icon_id": "43001893", + "name": "icon_sound", + "font_class": "icon_sound", + "unicode": "e768", + "unicode_decimal": 59240 + }, + { + "icon_id": "43001890", + "name": "icon_setting", + "font_class": "icon_setting", + "unicode": "e769", + "unicode_decimal": 59241 + }, + { + "icon_id": "43001892", + "name": "icon_share", + "font_class": "icon_share", + "unicode": "e76a", + "unicode_decimal": 59242 + }, + { + "icon_id": "43001884", + "name": "icon_search", + "font_class": "icon_search", + "unicode": "e76b", + "unicode_decimal": 59243 + }, + { + "icon_id": "43001889", + "name": "icon_service", + "font_class": "icon_service", + "unicode": "e76c", + "unicode_decimal": 59244 + }, + { + "icon_id": "43001891", + "name": "icon_star", + "font_class": "icon_star", + "unicode": "e76d", + "unicode_decimal": 59245 + }, + { + "icon_id": "43001887", + "name": "icon_round", + "font_class": "icon_round", + "unicode": "e76e", + "unicode_decimal": 59246 + }, + { + "icon_id": "43001888", + "name": "icon_star_filled", + "font_class": "icon_star_filled", + "unicode": "e76f", + "unicode_decimal": 59247 + }, + { + "icon_id": "43001886", + "name": "icon_shop", + "font_class": "icon_shop", + "unicode": "e770", + "unicode_decimal": 59248 + }, + { + "icon_id": "43001885", + "name": "icon_stop_circle_filled", + "font_class": "icon_stop_circle_filled", + "unicode": "e771", + "unicode_decimal": 59249 + }, + { + "icon_id": "43001883", + "name": "icon_slash", + "font_class": "icon_slash", + "unicode": "e772", + "unicode_decimal": 59250 + }, + { + "icon_id": "43001882", + "name": "icon_save", + "font_class": "icon_save", + "unicode": "e773", + "unicode_decimal": 59251 + }, + { + "icon_id": "43001881", + "name": "icon_secured", + "font_class": "icon_secured", + "unicode": "e774", + "unicode_decimal": 59252 + }, + { + "icon_id": "43001879", + "name": "icon_root_list", + "font_class": "icon_root_list", + "unicode": "e775", + "unicode_decimal": 59253 + }, + { + "icon_id": "43001795", + "name": "icon_menu-application", + "font_class": "icon_menu-application1", + "unicode": "e75f", + "unicode_decimal": 59231 + }, + { + "icon_id": "43001794", + "name": "icon_mobile_vibrate", + "font_class": "icon_mobile_vibrate", + "unicode": "e760", + "unicode_decimal": 59232 + }, + { + "icon_id": "43001792", + "name": "icon_mirror", + "font_class": "icon_mirror", + "unicode": "e761", + "unicode_decimal": 59233 + }, + { + "icon_id": "43001793", + "name": "icon_move", + "font_class": "icon_move", + "unicode": "e762", + "unicode_decimal": 59234 + }, + { + "icon_id": "43001790", + "name": "icon_page_first", + "font_class": "icon_page_first", + "unicode": "e763", + "unicode_decimal": 59235 + }, + { + "icon_id": "43001787", + "name": "icon_notification_filled", + "font_class": "icon_notification_filled", + "unicode": "e764", + "unicode_decimal": 59236 + }, + { + "icon_id": "43001788", + "name": "icon_minus_rectangle", + "font_class": "icon_minus_rectangle1", + "unicode": "e765", + "unicode_decimal": 59237 + }, + { + "icon_id": "43001806", + "name": "icon_menu_unfold", + "font_class": "icon_menu_unfold1", + "unicode": "e752", + "unicode_decimal": 59218 + }, + { + "icon_id": "43001799", + "name": "icon_page_last", + "font_class": "icon_page_last", + "unicode": "e753", + "unicode_decimal": 59219 + }, + { + "icon_id": "43001804", + "name": "icon_pause_circle_filled", + "font_class": "icon_pause_circle_filled", + "unicode": "e754", + "unicode_decimal": 59220 + }, + { + "icon_id": "43001805", + "name": "icon_money_circle", + "font_class": "icon_money_circle", + "unicode": "e755", + "unicode_decimal": 59221 + }, + { + "icon_id": "43001803", + "name": "icon_mail", + "font_class": "icon_mail1", + "unicode": "e756", + "unicode_decimal": 59222 + }, + { + "icon_id": "43001800", + "name": "icon_minus_circle_filled", + "font_class": "icon_minus_circle_filled1", + "unicode": "e757", + "unicode_decimal": 59223 + }, + { + "icon_id": "43001801", + "name": "icon_more", + "font_class": "icon_more", + "unicode": "e758", + "unicode_decimal": 59224 + }, + { + "icon_id": "43001796", + "name": "icon_menu_fold", + "font_class": "icon_menu_fold1", + "unicode": "e759", + "unicode_decimal": 59225 + }, + { + "icon_id": "43001789", + "name": "icon_next", + "font_class": "icon_next", + "unicode": "e75a", + "unicode_decimal": 59226 + }, + { + "icon_id": "43001797", + "name": "icon_notification", + "font_class": "icon_notification", + "unicode": "e75b", + "unicode_decimal": 59227 + }, + { + "icon_id": "43001791", + "name": "icon_minus_circle", + "font_class": "icon_minus_circle1", + "unicode": "e75c", + "unicode_decimal": 59228 + }, + { + "icon_id": "43001802", + "name": "icon_mobile", + "font_class": "icon_mobile", + "unicode": "e75d", + "unicode_decimal": 59229 + }, + { + "icon_id": "43001798", + "name": "icon_order_adjustment_column", + "font_class": "icon_order_adjustment_column", + "unicode": "e75e", + "unicode_decimal": 59230 + }, + { + "icon_id": "42996149", + "name": "icon_jump", + "font_class": "icon_jump", + "unicode": "e740", + "unicode_decimal": 59200 + }, + { + "icon_id": "42996150", + "name": "icon_lock_off", + "font_class": "icon_lock_off", + "unicode": "e741", + "unicode_decimal": 59201 + }, + { + "icon_id": "42996153", + "name": "icon_loading", + "font_class": "icon_loading", + "unicode": "e744", + "unicode_decimal": 59204 + }, + { + "icon_id": "42996154", + "name": "icon_laptop", + "font_class": "icon_laptop", + "unicode": "e745", + "unicode_decimal": 59205 + }, + { + "icon_id": "42996156", + "name": "icon_mail", + "font_class": "icon_mail", + "unicode": "e747", + "unicode_decimal": 59207 + }, + { + "icon_id": "42996157", + "name": "icon_minus_rectangle", + "font_class": "icon_minus_rectangle", + "unicode": "e748", + "unicode_decimal": 59208 + }, + { + "icon_id": "42996158", + "name": "icon_location", + "font_class": "icon_location", + "unicode": "e749", + "unicode_decimal": 59209 + }, + { + "icon_id": "42996159", + "name": "icon_lock_on", + "font_class": "icon_lock_on", + "unicode": "e74a", + "unicode_decimal": 59210 + }, + { + "icon_id": "42996160", + "name": "icon_link_unlink", + "font_class": "icon_link_unlink", + "unicode": "e74b", + "unicode_decimal": 59211 + }, + { + "icon_id": "42996161", + "name": "icon_menu-application", + "font_class": "icon_menu-application", + "unicode": "e74c", + "unicode_decimal": 59212 + }, + { + "icon_id": "42996162", + "name": "icon_minus_circle", + "font_class": "icon_minus_circle", + "unicode": "e74d", + "unicode_decimal": 59213 + }, + { + "icon_id": "42996163", + "name": "icon_logout", + "font_class": "icon_logout", + "unicode": "e74e", + "unicode_decimal": 59214 + }, + { + "icon_id": "42996164", + "name": "icon_link", + "font_class": "icon_link", + "unicode": "e74f", + "unicode_decimal": 59215 + }, + { + "icon_id": "42996165", + "name": "icon_layers", + "font_class": "icon_layers", + "unicode": "e750", + "unicode_decimal": 59216 + }, + { + "icon_id": "42996166", + "name": "icon_login", + "font_class": "icon_login", + "unicode": "e751", + "unicode_decimal": 59217 + }, + { + "icon_id": "42996016", + "name": "icon_file_paste", + "font_class": "icon_file_paste", + "unicode": "e718", + "unicode_decimal": 59160 + }, + { + "icon_id": "42996017", + "name": "icon_error_circle", + "font_class": "icon_error_circle", + "unicode": "e719", + "unicode_decimal": 59161 + }, + { + "icon_id": "42996018", + "name": "icon_file_word", + "font_class": "icon_file_word", + "unicode": "e71a", + "unicode_decimal": 59162 + }, + { + "icon_id": "42996019", + "name": "icon_enter", + "font_class": "icon_enter", + "unicode": "e71b", + "unicode_decimal": 59163 + }, + { + "icon_id": "42996020", + "name": "icon_file", + "font_class": "icon_file", + "unicode": "e71c", + "unicode_decimal": 59164 + }, + { + "icon_id": "42996022", + "name": "icon_fork", + "font_class": "icon_fork", + "unicode": "e71e", + "unicode_decimal": 59166 + }, + { + "icon_id": "42996023", + "name": "icon_file_copy", + "font_class": "icon_file_copy", + "unicode": "e71f", + "unicode_decimal": 59167 + }, + { + "icon_id": "42996024", + "name": "icon_flag", + "font_class": "icon_flag", + "unicode": "e720", + "unicode_decimal": 59168 + }, + { + "icon_id": "42996025", + "name": "icon_folder", + "font_class": "icon_folder", + "unicode": "e721", + "unicode_decimal": 59169 + }, + { + "icon_id": "42996026", + "name": "icon_file_image", + "font_class": "icon_file_image", + "unicode": "e722", + "unicode_decimal": 59170 + }, + { + "icon_id": "42996027", + "name": "icon_file_powerpoint", + "font_class": "icon_file_powerpoint", + "unicode": "e723", + "unicode_decimal": 59171 + }, + { + "icon_id": "42996028", + "name": "icon_file_add", + "font_class": "icon_file_add", + "unicode": "e724", + "unicode_decimal": 59172 + }, + { + "icon_id": "42996029", + "name": "icon_file_unknown", + "font_class": "icon_file_unknown", + "unicode": "e725", + "unicode_decimal": 59173 + }, + { + "icon_id": "42996030", + "name": "icon_error_circle_filled", + "font_class": "icon_error_circle_filled", + "unicode": "e726", + "unicode_decimal": 59174 + }, + { + "icon_id": "42996031", + "name": "icon_help", + "font_class": "icon_help", + "unicode": "e727", + "unicode_decimal": 59175 + }, + { + "icon_id": "42996032", + "name": "icon_fullscreen_exit", + "font_class": "icon_fullscreen_exit", + "unicode": "e728", + "unicode_decimal": 59176 + }, + { + "icon_id": "42996033", + "name": "icon_file_pdf", + "font_class": "icon_file_pdf", + "unicode": "e729", + "unicode_decimal": 59177 + }, + { + "icon_id": "42996034", + "name": "icon_gift", + "font_class": "icon_gift", + "unicode": "e72a", + "unicode_decimal": 59178 + }, + { + "icon_id": "42996035", + "name": "icon_gender_female", + "font_class": "icon_gender_female", + "unicode": "e72b", + "unicode_decimal": 59179 + }, + { + "icon_id": "42996036", + "name": "icon_home", + "font_class": "icon_home1", + "unicode": "e72c", + "unicode_decimal": 59180 + }, + { + "icon_id": "42996037", + "name": "icon_heart_filled", + "font_class": "icon_heart_filled", + "unicode": "e72d", + "unicode_decimal": 59181 + }, + { + "icon_id": "42996038", + "name": "icon_info_circle", + "font_class": "icon_info_circle", + "unicode": "e72e", + "unicode_decimal": 59182 + }, + { + "icon_id": "42996039", + "name": "icon_file_icon", + "font_class": "icon_file_icon", + "unicode": "e72f", + "unicode_decimal": 59183 + }, + { + "icon_id": "42996040", + "name": "icon_heart", + "font_class": "icon_heart", + "unicode": "e730", + "unicode_decimal": 59184 + }, + { + "icon_id": "42996041", + "name": "icon_history", + "font_class": "icon_history", + "unicode": "e731", + "unicode_decimal": 59185 + }, + { + "icon_id": "42996043", + "name": "icon_hourglass", + "font_class": "icon_hourglass", + "unicode": "e733", + "unicode_decimal": 59187 + }, + { + "icon_id": "42996044", + "name": "icon_info_circle_filled", + "font_class": "icon_info_circle_filled", + "unicode": "e734", + "unicode_decimal": 59188 + }, + { + "icon_id": "42996045", + "name": "icon_folder_open", + "font_class": "icon_folder_open", + "unicode": "e735", + "unicode_decimal": 59189 + }, + { + "icon_id": "42996046", + "name": "icon_help_circle_filled", + "font_class": "icon_help_circle_filled", + "unicode": "e736", + "unicode_decimal": 59190 + }, + { + "icon_id": "42996047", + "name": "icon_help_circle", + "font_class": "icon_help_circle", + "unicode": "e737", + "unicode_decimal": 59191 + }, + { + "icon_id": "42996048", + "name": "icon_image", + "font_class": "icon_image", + "unicode": "e738", + "unicode_decimal": 59192 + }, + { + "icon_id": "42996049", + "name": "icon_gender_male", + "font_class": "icon_gender_male", + "unicode": "e739", + "unicode_decimal": 59193 + }, + { + "icon_id": "42996050", + "name": "icon_fullsreen", + "font_class": "icon_fullsreen", + "unicode": "e73a", + "unicode_decimal": 59194 + }, + { + "icon_id": "42996051", + "name": "icon_image_error", + "font_class": "icon_image_error", + "unicode": "e73b", + "unicode_decimal": 59195 + }, + { + "icon_id": "42996052", + "name": "icon_folder_add", + "font_class": "icon_folder_add", + "unicode": "e73c", + "unicode_decimal": 59196 + }, + { + "icon_id": "42996053", + "name": "icon_file_excel", + "font_class": "icon_file_excel", + "unicode": "e73d", + "unicode_decimal": 59197 + }, + { + "icon_id": "42996054", + "name": "icon_forward", + "font_class": "icon_forward", + "unicode": "e73e", + "unicode_decimal": 59198 + }, + { + "icon_id": "42996055", + "name": "icon_internet", + "font_class": "icon_internet", + "unicode": "e73f", + "unicode_decimal": 59199 + }, + { + "icon_id": "42995831", + "name": "icon_check_circle_filled", + "font_class": "icon_check_circle_filled", + "unicode": "e6f1", + "unicode_decimal": 59121 + }, + { + "icon_id": "42995832", + "name": "icon_chevron_right_circle", + "font_class": "icon_chevron_right_circle", + "unicode": "e6f2", + "unicode_decimal": 59122 + }, + { + "icon_id": "42995833", + "name": "icon_creditcard", + "font_class": "icon_creditcard", + "unicode": "e6f3", + "unicode_decimal": 59123 + }, + { + "icon_id": "42995834", + "name": "icon_close_circle_filled", + "font_class": "icon_close_circle_filled", + "unicode": "e6f4", + "unicode_decimal": 59124 + }, + { + "icon_id": "42995835", + "name": "icon_circle", + "font_class": "icon_circle", + "unicode": "e6f5", + "unicode_decimal": 59125 + }, + { + "icon_id": "42995836", + "name": "icon_chevron_left_circle", + "font_class": "icon_chevron_left_circle", + "unicode": "e6f6", + "unicode_decimal": 59126 + }, + { + "icon_id": "42995837", + "name": "icon_chevron_left_double", + "font_class": "icon_chevron_left_double", + "unicode": "e6f7", + "unicode_decimal": 59127 + }, + { + "icon_id": "42995838", + "name": "icon_chevron_right", + "font_class": "icon_chevron_right", + "unicode": "e6f8", + "unicode_decimal": 59128 + }, + { + "icon_id": "42995839", + "name": "icon_control_platform", + "font_class": "icon_control_platform", + "unicode": "e6f9", + "unicode_decimal": 59129 + }, + { + "icon_id": "42995840", + "name": "icon_close", + "font_class": "icon_close", + "unicode": "e6fa", + "unicode_decimal": 59130 + }, + { + "icon_id": "42995841", + "name": "icon_check", + "font_class": "icon_check", + "unicode": "e6fb", + "unicode_decimal": 59131 + }, + { + "icon_id": "42995842", + "name": "icon_chevron_right_double", + "font_class": "icon_chevron_right_double", + "unicode": "e6fc", + "unicode_decimal": 59132 + }, + { + "icon_id": "42995843", + "name": "icon_check_rectangle_filled", + "font_class": "icon_check_rectangle_filled", + "unicode": "e6fd", + "unicode_decimal": 59133 + }, + { + "icon_id": "42995844", + "name": "icon_cloud_upload", + "font_class": "icon_cloud_upload", + "unicode": "e6fe", + "unicode_decimal": 59134 + }, + { + "icon_id": "42995845", + "name": "icon_chevron_up_circle", + "font_class": "icon_chevron_up_circle", + "unicode": "e6ff", + "unicode_decimal": 59135 + }, + { + "icon_id": "42995846", + "name": "icon_chevron_down_circle", + "font_class": "icon_chevron_down_circle", + "unicode": "e700", + "unicode_decimal": 59136 + }, + { + "icon_id": "42995847", + "name": "icon_chevron_up_rectangle", + "font_class": "icon_chevron_up_rectangle", + "unicode": "e701", + "unicode_decimal": 59137 + }, + { + "icon_id": "42995848", + "name": "icon_close_rectangle", + "font_class": "icon_close_rectangle", + "unicode": "e702", + "unicode_decimal": 59138 + }, + { + "icon_id": "42995849", + "name": "icon_chevron_down_rectangle", + "font_class": "icon_chevron_down_rectangle", + "unicode": "e703", + "unicode_decimal": 59139 + }, + { + "icon_id": "42995850", + "name": "icon_chevron_left", + "font_class": "icon_chevron_left", + "unicode": "e704", + "unicode_decimal": 59140 + }, + { + "icon_id": "42995851", + "name": "icon_check_circle", + "font_class": "icon_check_circle", + "unicode": "e705", + "unicode_decimal": 59141 + }, + { + "icon_id": "42995852", + "name": "icon_chevron_right_rectangle", + "font_class": "icon_chevron_right_rectangle", + "unicode": "e706", + "unicode_decimal": 59142 + }, + { + "icon_id": "42995853", + "name": "icon_check_rectangle", + "font_class": "icon_check_rectangle", + "unicode": "e707", + "unicode_decimal": 59143 + }, + { + "icon_id": "42995854", + "name": "icon_close_circle", + "font_class": "icon_close_circle", + "unicode": "e708", + "unicode_decimal": 59144 + }, + { + "icon_id": "42995855", + "name": "icon_cloud", + "font_class": "icon_cloud", + "unicode": "e709", + "unicode_decimal": 59145 + }, + { + "icon_id": "42995856", + "name": "icon_chevron_down", + "font_class": "icon_chevron_down", + "unicode": "e70a", + "unicode_decimal": 59146 + }, + { + "icon_id": "42995857", + "name": "icon_delete", + "font_class": "icon_delete", + "unicode": "e70b", + "unicode_decimal": 59147 + }, + { + "icon_id": "42995858", + "name": "icon_chevron_up", + "font_class": "icon_chevron_up", + "unicode": "e70c", + "unicode_decimal": 59148 + }, + { + "icon_id": "42995859", + "name": "icon_code", + "font_class": "icon_code", + "unicode": "e70d", + "unicode_decimal": 59149 + }, + { + "icon_id": "42995860", + "name": "icon_dashboard", + "font_class": "icon_dashboard", + "unicode": "e70e", + "unicode_decimal": 59150 + }, + { + "icon_id": "42995861", + "name": "icon_clear", + "font_class": "icon_clear", + "unicode": "e70f", + "unicode_decimal": 59151 + }, + { + "icon_id": "42995862", + "name": "icon_chevron_left_rectangle", + "font_class": "icon_chevron_left_rectangle", + "unicode": "e710", + "unicode_decimal": 59152 + }, + { + "icon_id": "42995863", + "name": "icon_cloud_download", + "font_class": "icon_cloud_download", + "unicode": "e711", + "unicode_decimal": 59153 + }, + { + "icon_id": "42995864", + "name": "icon_desktop", + "font_class": "icon_desktop", + "unicode": "e712", + "unicode_decimal": 59154 + }, + { + "icon_id": "42995866", + "name": "icon_ellipsis", + "font_class": "icon_ellipsis", + "unicode": "e713", + "unicode_decimal": 59155 + }, + { + "icon_id": "42995868", + "name": "icon_edit", + "font_class": "icon_edit", + "unicode": "e715", + "unicode_decimal": 59157 + }, + { + "icon_id": "42995869", + "name": "icon_download", + "font_class": "icon_download", + "unicode": "e716", + "unicode_decimal": 59158 + }, + { + "icon_id": "42995870", + "name": "icon_discount_filled", + "font_class": "icon_discount_filled", + "unicode": "e717", + "unicode_decimal": 59159 + }, + { + "icon_id": "42995750", + "name": "icon_caret_down", + "font_class": "icon_caret_down", + "unicode": "e6c9", + "unicode_decimal": 59081 + }, + { + "icon_id": "42995751", + "name": "icon_backtop", + "font_class": "icon_backtop", + "unicode": "e6ca", + "unicode_decimal": 59082 + }, + { + "icon_id": "42995752", + "name": "icon_caret_right", + "font_class": "icon_caret_right", + "unicode": "e6cb", + "unicode_decimal": 59083 + }, + { + "icon_id": "42995753", + "name": "icon_bulletpoint", + "font_class": "icon_bulletpoint", + "unicode": "e6cc", + "unicode_decimal": 59084 + }, + { + "icon_id": "42995754", + "name": "icon_caret_right_small", + "font_class": "icon_caret_right_small", + "unicode": "e6cd", + "unicode_decimal": 59085 + }, + { + "icon_id": "42995755", + "name": "icon_add", + "font_class": "icon_add", + "unicode": "e6ce", + "unicode_decimal": 59086 + }, + { + "icon_id": "42995756", + "name": "icon_caret_left", + "font_class": "icon_caret_left", + "unicode": "e6cf", + "unicode_decimal": 59087 + }, + { + "icon_id": "42995757", + "name": "icon_caret_down_small", + "font_class": "icon_caret_down_small", + "unicode": "e6d0", + "unicode_decimal": 59088 + }, + { + "icon_id": "42995758", + "name": "icon_caret_left_small", + "font_class": "icon_caret_left_small", + "unicode": "e6d1", + "unicode_decimal": 59089 + }, + { + "icon_id": "42995759", + "name": "chart-bar", + "font_class": "chart-bar", + "unicode": "e6d2", + "unicode_decimal": 59090 + }, + { + "icon_id": "42995760", + "name": "icon_arrow_left", + "font_class": "icon_arrow_left", + "unicode": "e6d3", + "unicode_decimal": 59091 + }, + { + "icon_id": "42995761", + "name": "icon_calendar", + "font_class": "icon_calendar", + "unicode": "e6d4", + "unicode_decimal": 59092 + }, + { + "icon_id": "42995762", + "name": "icon_chart_pie", + "font_class": "icon_chart_pie", + "unicode": "e6d5", + "unicode_decimal": 59093 + }, + { + "icon_id": "42995763", + "name": "icon_arrow_right", + "font_class": "icon_arrow_right", + "unicode": "e6d6", + "unicode_decimal": 59094 + }, + { + "icon_id": "42995764", + "name": "icon_caret_up_small", + "font_class": "icon_caret_up_small", + "unicode": "e6d7", + "unicode_decimal": 59095 + }, + { + "icon_id": "42995766", + "name": "icon_browse", + "font_class": "icon_browse", + "unicode": "e6d9", + "unicode_decimal": 59097 + }, + { + "icon_id": "42995768", + "name": "icon_books", + "font_class": "icon_books", + "unicode": "e6db", + "unicode_decimal": 59099 + }, + { + "icon_id": "42995769", + "name": "icon_app", + "font_class": "icon_app", + "unicode": "e6dc", + "unicode_decimal": 59100 + }, + { + "icon_id": "42995770", + "name": "icon_arrow_up", + "font_class": "icon_arrow_up", + "unicode": "e6dd", + "unicode_decimal": 59101 + }, + { + "icon_id": "42995772", + "name": "icon_backward", + "font_class": "icon_backward", + "unicode": "e6df", + "unicode_decimal": 59103 + }, + { + "icon_id": "42995773", + "name": "icon_cart", + "font_class": "icon_cart", + "unicode": "e6e0", + "unicode_decimal": 59104 + }, + { + "icon_id": "42995774", + "name": "icon_attach", + "font_class": "icon_attach", + "unicode": "e6e1", + "unicode_decimal": 59105 + }, + { + "icon_id": "42995775", + "name": "chart", + "font_class": "chart", + "unicode": "e6e2", + "unicode_decimal": 59106 + }, + { + "icon_id": "42995776", + "name": "icon_chat", + "font_class": "icon_chat", + "unicode": "e6e3", + "unicode_decimal": 59107 + }, + { + "icon_id": "42995777", + "name": "icon_add_rectangle", + "font_class": "icon_add_rectangle", + "unicode": "e6e4", + "unicode_decimal": 59108 + }, + { + "icon_id": "42995778", + "name": "icon_add_circle", + "font_class": "icon_add_circle", + "unicode": "e6e5", + "unicode_decimal": 59109 + }, + { + "icon_id": "42995779", + "name": "icon_barcode", + "font_class": "icon_barcode", + "unicode": "e6e6", + "unicode_decimal": 59110 + }, + { + "icon_id": "42995780", + "name": "icon_arrow_down_rectangle", + "font_class": "icon_arrow_down_rectangle", + "unicode": "e6e7", + "unicode_decimal": 59111 + }, + { + "icon_id": "42995781", + "name": "icon_browse_off", + "font_class": "icon_browse_off", + "unicode": "e6e8", + "unicode_decimal": 59112 + }, + { + "icon_id": "42995782", + "name": "icon_backtop_rectangle", + "font_class": "icon_backtop_rectangle", + "unicode": "e6e9", + "unicode_decimal": 59113 + }, + { + "icon_id": "42995784", + "name": "icon_arrow_down", + "font_class": "icon_arrow_down", + "unicode": "e6eb", + "unicode_decimal": 59115 + }, + { + "icon_id": "42995786", + "name": "icon_call", + "font_class": "icon_call", + "unicode": "e6ed", + "unicode_decimal": 59117 + }, + { + "icon_id": "42995787", + "name": "icon_caret_up", + "font_class": "icon_caret_up", + "unicode": "e6ee", + "unicode_decimal": 59118 + }, + { + "icon_id": "42995789", + "name": "icon_chart_bubble", + "font_class": "icon_chart_bubble", + "unicode": "e6f0", + "unicode_decimal": 59120 + }, + { + "icon_id": "42977221", + "name": "icon_data", + "font_class": "icon_data", + "unicode": "e6ae", + "unicode_decimal": 59054 + }, + { + "icon_id": "42977220", + "name": "icon_clue", + "font_class": "icon_clue", + "unicode": "e6af", + "unicode_decimal": 59055 + }, + { + "icon_id": "42977219", + "name": "icon_business_opportunity", + "font_class": "icon_business_opportunity", + "unicode": "e6b0", + "unicode_decimal": 59056 + }, + { + "icon_id": "42977227", + "name": "icon_set_up", + "font_class": "icon_set_up", + "unicode": "e6a8", + "unicode_decimal": 59048 + }, + { + "icon_id": "42977225", + "name": "icon_not_bookmarked", + "font_class": "icon_not_bookmarked", + "unicode": "e6a9", + "unicode_decimal": 59049 + }, + { + "icon_id": "42977226", + "name": "icon_product", + "font_class": "icon_product", + "unicode": "e6aa", + "unicode_decimal": 59050 + }, + { + "icon_id": "42977224", + "name": "icon_customer", + "font_class": "icon_customer", + "unicode": "e6ab", + "unicode_decimal": 59051 + }, + { + "icon_id": "42977223", + "name": "icon_home", + "font_class": "icon_home", + "unicode": "e6ac", + "unicode_decimal": 59052 + }, + { + "icon_id": "42977222", + "name": "icon_collect", + "font_class": "icon_collect", + "unicode": "e6ad", + "unicode_decimal": 59053 + }, + { + "icon_id": "32849880", + "name": "icon-kfckfc", + "font_class": "icon_search-outline_outlined", + "unicode": "e6c2", + "unicode_decimal": 59074 + }, + { + "icon_id": "32849886", + "name": "icon-alarmclock", + "font_class": "icon-alarmclock", + "unicode": "e6c8", + "unicode_decimal": 59080 + } + ] +} diff --git a/frontend/packages/web/src/assets/icon-font/iconfont.svg b/frontend/packages/web/src/assets/icon-font/iconfont.svg new file mode 100644 index 0000000..3ced0a1 --- /dev/null +++ b/frontend/packages/web/src/assets/icon-font/iconfont.svg @@ -0,0 +1,733 @@ + + + + Created by iconfont + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/web/src/assets/icon-font/iconfont.ttf b/frontend/packages/web/src/assets/icon-font/iconfont.ttf new file mode 100644 index 0000000..52ff31b Binary files /dev/null and b/frontend/packages/web/src/assets/icon-font/iconfont.ttf differ diff --git a/frontend/packages/web/src/assets/icon-font/iconfont.woff2 b/frontend/packages/web/src/assets/icon-font/iconfont.woff2 new file mode 100644 index 0000000..26af43a Binary files /dev/null and b/frontend/packages/web/src/assets/icon-font/iconfont.woff2 differ diff --git a/frontend/packages/web/src/assets/style/global.less b/frontend/packages/web/src/assets/style/global.less new file mode 100644 index 0000000..fcf7d5b --- /dev/null +++ b/frontend/packages/web/src/assets/style/global.less @@ -0,0 +1,149 @@ +/* stylelint-disable font-family-no-missing-generic-family-keyword */ +@import url('./var.less'); +* { + @apply box-border; +} +html, +body { + @apply m-0 h-full w-full p-0; + + font-size: 14px; + font-family: 'Helvetica Neue', Arial, 'PingFang SC', 'Source Han Serif' !important; + font-weight: 400; + color: var(--text-n1); + line-height: 22px; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; +} + +// 小投影 +.base-box-shadow { + box-shadow: @box-base-shadow; +} + +// 中层投影 +.middle-box-shadow { + box-shadow: @box-middle-shadow; +} + +// 大投影 +.large-box-shadow { + box-shadow: @box-large-shadow; +} + +/* 单行文本缩略 */ +.one-line-text { + @apply overflow-hidden overflow-ellipsis whitespace-nowrap; + + line-height: normal; +} + +/* 修改进度条的背景颜色 */ +#nprogress { + .bar { + background: var(--primary-8) !important; + } +} + +/* 白色背景的tooltip */ +.crm-tooltip-white { + &.n-tooltip { + color: var(--text-n1); + background-color: var(--text-n10); + box-shadow: @box-middle-shadow; + } + .n-popover-arrow { + z-index: 1; + background-color: var(--text-n10) !important; + } +} + +/* error 背景tooltip */ +.crm-tooltip-error { + &.n-tooltip { + color: var(--error-red); + background-color: var(--error-5); + box-shadow: @box-middle-shadow; + } + .n-popover-arrow { + z-index: 1; + background-color: var(--error-5) !important; + } +} + +/* warning 背景tooltip */ +.crm-tooltip-warning { + &.n-tooltip { + color: var(--warning-red); + background-color: var(--warning-5); + box-shadow: @box-middle-shadow; + } + .n-popover-arrow { + z-index: 1; + background-color: var(--warning-5) !important; + } +} + +/* success 背景tooltip */ +.crm-tooltip-success { + &.n-tooltip { + color: var(--success-red); + background-color: var(--success-5); + box-shadow: @box-middle-shadow; + } + .n-popover-arrow { + z-index: 1; + background-color: var(--success-5) !important; + } +} + +/* primary 背景tooltip */ +.crm-tooltip-primary { + &.n-tooltip { + color: var(--primary-8); + background-color: var(--primary-7); + box-shadow: @box-middle-shadow; + } + .n-popover-arrow { + z-index: 1; + background-color: var(--primary-7) !important; + } +} +// modal 对话框 title +.crm-modal-title { + .crm-modal-title(); +} + +/* 动态表单 */ +.crm-form-create-item-desc { + @apply w-full flex-1; + + margin-bottom: 4px; + font-size: 12px; + color: var(--text-n4); +} + +/* 抽屉 */ +.crm-drawer-content { + background-color: var(--text-n9) !important; +} + +// model表单 +.crm-form-modal { + .n-dialog__content { + margin-bottom: 16px; + } + .n-form-item:last-of-type { + .n-form-item-feedback-wrapper { + overflow: hidden; + min-height: 0; + line-height: 1; + } + } +} +.n-popconfirm, +.n-popover { + .n-popconfirm__action { + margin-top: 0 !important; + } +} diff --git a/frontend/packages/web/src/assets/style/index.less b/frontend/packages/web/src/assets/style/index.less new file mode 100644 index 0000000..cccb072 --- /dev/null +++ b/frontend/packages/web/src/assets/style/index.less @@ -0,0 +1,5 @@ +@import url('@lib/shared/assets/style/themeVariables.less'); +@import url('./var.less'); +@import url('./tailwind.less'); +@import url('./naive-reset.less'); +@import url('./global.less'); diff --git a/frontend/packages/web/src/assets/style/naive-reset.less b/frontend/packages/web/src/assets/style/naive-reset.less new file mode 100644 index 0000000..df7bda8 --- /dev/null +++ b/frontend/packages/web/src/assets/style/naive-reset.less @@ -0,0 +1,581 @@ +@import url('./var.less'); +// 重置默认按钮聚焦 +.n-button { + &.n-button--default-type { + .btn-text-sec-focus(); + .btn-text-sec-hover(); + .btn-text-sec-active(); + .btn-text-sec-disabled(); + } +} +// secondary背景按钮 +.n-button { + &.n-button--default-type { + &.n-button--secondary { + .btn-base-sec-focus(); + .btn-base-sec-hover(); + .btn-base-sec-active(); + .btn-base-sec-disabled(); + } + } +} +// 主要 outline 按钮 +.n-button--tertiary-type { + border: 1px solid var(--primary-8) !important; + color: var(--primary-8) !important; + .btn-outline-primary-hover(); + .btn-outline-primary-active(); + .btn-outline-primary-disabled(); +} + +// 次要边框按钮 +.n-button--default-type.outline--secondary { + .btn-outline-sec-default(); + .btn-outline-sec-hover(); + .btn-outline-sec-active(); + .btn-outline-sec-disabled(); +} + +// 次要文本主题按钮 +.text-btn-primary { + &.n-button--primary-type { + .btn-text-default(); + .btn-text-primary-hover(); + .btn-text-primary-active(); + .btn-text-primary-disabled(); + } +} + +// 文本error按钮 +.text-btn-error { + &.n-button--error-type { + .btn-text-default(); + .btn-text-error-hover(); + .btn-text-error-active(); + .btn-text-error-disabled(); + } +} + +// 文本warning按钮 +.text-btn-warning { + &.n-button--warning-type { + .btn-text-default(); + .btn-text-warning-hover(); + .btn-text-warning-active(); + .btn-text-warning-disabled(); + } +} + +// 文本success按钮 +.text-btn-success { + &.n-button--success-type { + .btn-text-default(); + .btn-text-success-hover(); + .btn-text-success-active(); + .btn-text-success-disabled(); + } +} + +// 文本info按钮 +.text-btn-info { + &.n-button--info-type { + .btn-text-default(); + .btn-text-info-hover(); + .btn-text-info-active(); + .btn-text-info-disabled(); + } +} + +// outline primary 状态按钮 +.n-btn-outline-primary { + &.n-button--primary-type { + .btn-outline-primary-hover(); + .btn-outline-primary-active(); + .btn-outline-primary-disabled(); + } +} + +// outline error状态按钮 +.n-btn-outline-error { + &.n-button--error-type { + .btn-outline-error-hover(); + .btn-outline-error-active(); + .btn-outline-error-disabled(); + } +} + +// outline warning状态按钮 +.n-btn-outline-warning { + &.n-button--warning-type { + .btn-outline-warning-hover(); + .btn-outline-warning-active(); + .btn-outline-warning-disabled(); + } +} + +// outline success状态按钮 +.n-btn-outline-success { + &.n-button--success-type { + .btn-outline-success-hover(); + .btn-outline-success-active(); + .btn-outline-success-disabled(); + } +} + +// outline info 状态按钮 +.n-btn-outline-info { + &.n-button--info-type { + .btn-outline-info-hover(); + .btn-outline-info-active(); + .btn-outline-info-disabled(); + } +} +// small 24px 按钮尺寸 +.n-button--small-type { + height: 24px !important; +} +// medium 32px 按钮尺寸 +.n-button--medium-type { + height: 32px !important; +} +// 消息框 +.n-message, +.n-notification, +.n-dropdown, +.n-popover { + border: 1px solid var(--text-n8) !important; + box-shadow: @box-middle-shadow; +} +// 标签 +.n-tag { + .n-tag__content { + overflow: hidden !important; + max-width: 144px !important; + text-overflow: ellipsis !important; + } +} + +// tooltip 最大限制 +.n-tooltip { + max-width: 300px; + .n-popover__content { + @apply overflow-y-auto break-all; + + max-height: 30vh; + &:hover { + .crm-scroll-bar(); + } + .crm-scroll-bar(); + } +} + +// 表格 +.n-data-table { + .n-data-table-empty { + border-bottom: 1px solid var(--text-n8); + } + .n-data-table-base-table-header { + overflow-y: hidden; + } + .n-data-table-thead { + .n-data-table-tr:not(.n-data-table-tr--summary):hover { + background-color: var(--text-n10); + } + } + .n-data-table-base-table-body .n-scrollbar-rail--horizontal--bottom { + bottom: 0; + } + .n-data-table-th { + border-top: 1px solid var(--text-n8); + white-space: nowrap; + &.n-data-table-th--filterable { + padding-right: 12px !important; + } + &.n-data-table-th--sorting { + background-color: transparent; + } + &.n-data-table-th--sortable { + &:hover { + background-color: transparent; + } + } + .n-data-table-th__title-wrapper { + float: left; // 只有设置这个 排序和过滤才能拼一起 + width: fit-content; + height: 22px; + white-space: normal; + .n-data-table-th__title { + flex: none !important; + } + .n-data-table-sorter { + margin-left: 8px; + } + } + // 过滤的样式 + .n-data-table-filter { + position: relative; + display: inline-flex; + width: 24px; + height: 22px; + &:hover { + background-color: transparent; + } + &.n-data-table-filter--show { + background-color: transparent; + } + &.n-data-table-filter--active { + border-radius: 0 2px 2px 0; + background-color: var(--primary-7); + } + } + // 过滤时标题的样式 + &:has(> .n-data-table-filter--active) { + .n-data-table-th__title-wrapper { + border-radius: 2px 0 0 2px !important; + background-color: var(--primary-7); + } + .n-data-table-th__title { + padding-left: 4px; + color: var(--primary-8); + } + } + // 排序时标题的样式 + &.n-data-table-th--sorting { + .n-data-table-th__title-wrapper { + border-radius: 2px; + background-color: var(--primary-7); + } + .n-data-table-th__title { + padding-left: 4px; + color: var(--primary-8); + } + } + &.n-data-table-th--last { + .n-data-table-resize-button { + display: none; + } + } + &.n-data-table-th--fixed-right { + z-index: 3; + pointer-events: auto; + } + } + .n-data-table-td { + padding-top: 0; + padding-bottom: 0; + height: 46px; + &.n-data-table-td--sorting { + background-color: var(--text-n10); + } + .crm-table-data-draggable-handle { + cursor: move; + + @apply flex items-center justify-center; + } + &.n-data-table-td--fixed-right { + z-index: 3; + } + } + &.crm-data-table-compact { + .n-data-table-th { + padding-top: 5px; + padding-bottom: 5px; + } + .n-data-table-td { + line-height: 36px; + height: 36px; + } + } + &.crm-data-table-loose { + .n-data-table-th { + padding: 10px; + } + .n-data-table-td { + line-height: 46px; + height: 46px; + } + } +} +.n-data-table--bordered { + .n-data-table-th { + border-top: none; + } +} + +// 分页 +.n-pagination { + .n-pagination-quick-jumper { + padding: 2px 8px; + border-radius: 3px 0 0 3px; + background-color: var(--text-n9); + .n-input__input { + text-align: center; + } + } + .n-pagination-suffix { + margin: 0; + padding-right: 8px; + border-radius: 0 3px 3px 0; + color: var(--text-n2); + background-color: var(--text-n9); + } +} +.crm-pagination--medium { + .n-base-selection-label { + height: 32px; + } +} +.crm-pagination--small { + .total { + font-size: 12px; + } + .n-base-selection-label, + .n-pagination-quick-jumper, + .n-pagination-suffix { + height: 24px; + } + .n-input-wrapper { + height: 20px; + } +} + +// 模态框 +.n-modal { + .n-dialog__title { + .crm-modal-title(); + } +} + +// 对话框 +.n-dialog, +.n-drawer { + .n-base-close:not(.n-base-close--disabled) { + &:focus::before { + background: none; + } + &:hover::before { + background: var(--text-n9); + } + &:active::before { + background: var(--text-n8); + } + } +} +// 弹窗的上下比例 +.n-modal-body-wrapper:not(:has(.crm-full-modal)) { + padding: 6vh 0; + box-sizing: border-box; + height: calc(100vh + 12%); + transform: translateY(-10%); +} +// small-modal +.crm-modal-small { + &.n-modal { + width: 480px; + } +} + +// medium-modal +.crm-modal-medium { + &.n-modal { + width: 680px; + } +} + +// large-modal +.crm-modal-large { + &.n-modal { + width: 960px; + } +} + +// tabs +.n-tabs { + &.n-tabs--segment-type { + .n-tabs-tab-pad { + width: 4px; + } + .n-tabs-rail { + padding: 2px; + width: auto; + .n-tabs-capsule { + border-radius: 2px; + } + .n-tabs-tab:hover { + background-color: var(--text-n9); + } + } + &.n-tabs--medium-size { + .n-tabs-tab__label { + line-height: 22px; + } + } + &.n-tabs--small-size { + .n-tabs-tab__label { + line-height: 20px; + } + } + } + &.n-tabs--line-type { + .n-tabs-tab-pad { + width: 0; + } + .n-tabs-nav-scroll-content { + height: 48px; + } + .n-tabs-tab { + padding: 8px; + .n-tabs-tab__label { + padding: 5px 8px; + border-radius: 3px; + } + &:not(.n-tabs-tab--active):hover { + .n-tabs-tab__label { + background-color: var(--text-n9); + } + } + } + } +} +.no-content { + .n-tabs-pane-wrapper { + @apply hidden; + } +} + +// 级联选择器、树形选择器 +.n-cascader, +.n-tree-select, +.n-base-selection-popover { + .n-base-selection-tags { + padding: 4px; + height: 26px; + } + .n-base-selection-tag-wrapper { + padding-bottom: 0; + .n-tag { + background-color: var(--text-n8); + .n-base-close { + font-size: 12px; + color: var(--text-n4); + } + .n-tag__border { + border: none; + } + } + } +} +.n-base-selection-popover { + .n-base-selection-tag-wrapper { + padding-bottom: 8px; + } +} + +// 下拉选择框 +.n-base-selection, +.n-tree-select, +.n-cascader { + --n-border-focus: none !important; + --n-box-shadow-focus: none !important; + .n-base-selection-tags { + padding-left: 4px; + } +} +.n-cascader-menu { + .n-cascader-option:hover { + background-color: var(--primary-7) !important; + } +} +.n-base-selection-placeholder { + padding: 0 8px !important; +} +.n-base-select-option--selected { + background-color: var(--primary-7) !important; + &::before { + @apply hidden; + } +} +.n-base-select-option--pending, +.n-cascader-option--pending { + background-color: var(--primary-7) !important; + &::before { + @apply hidden; + } +} +.n-base-select-menu { + padding-top: 8px; + padding-bottom: 8px; + .v-vl-items { + padding-top: 0 !important; + padding-bottom: 0 !important; + } +} +.n-base-select-option__content { + @apply w-full; +} +// 树 +.n-tree.n-tree--block-line .n-tree-node:not(.n-tree-node--disabled).n-tree-node--pending { + @apply bg-transparent; +} +.n-tree-node--highlight { + .n-tree-node-content__text { + border-bottom: none !important; + } +} +.n-tree-node--selected { + background: var(--n-node-color-active) !important; +} +.n-input__textarea-el { + word-break: break-all !important; +} +// 表单 +.n-form-item-blank { + @apply flex-wrap; +} +.n-form.n-form--inline .n-form-item { + margin-right: 24px; +} +.n-form-item-feedback__line { + font-size: 12px; +} +.n-input--focus { + --n-box-shadow-focus: none !important; +} + +// 滚动条 +.n-scrollbar-rail { + z-index: 100; +} + +// 标记 Badge +.n-badge.n-badge--dot { + .n-badge-sup { + width: 6px; + min-width: 6px; + height: 6px; + } +} +.n-alert { + padding: 16px 24px; + &.n-alert--right-adjust { + .n-alert-body { + padding-right: 36px; + } + } + &.n-alert--show-icon { + .n-alert-body { + padding-left: 28px; + } + } + .n-alert__icon { + left: 16px; + margin-top: 0 !important; + height: 100%; + } + .n-alert__close { + margin: 16px 24px; + } + .n-alert-body { + padding: 0 8px; + } +} diff --git a/frontend/packages/web/src/assets/style/tailwind.less b/frontend/packages/web/src/assets/style/tailwind.less new file mode 100644 index 0000000..20a5e14 --- /dev/null +++ b/frontend/packages/web/src/assets/style/tailwind.less @@ -0,0 +1,6 @@ +/* stylelint-disable at-rule-no-unknown */ + +/* tailWindCss.less */ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/frontend/packages/web/src/assets/style/var.less b/frontend/packages/web/src/assets/style/var.less new file mode 100644 index 0000000..e424e24 --- /dev/null +++ b/frontend/packages/web/src/assets/style/var.less @@ -0,0 +1,503 @@ +/** 圆角 **/ +@border-radius-mini: 2px; +@border-radius-small: 4px; +@border-radius-medium: 6px; +@border-radius-large: 12px; + +@color-white: #fff; +@color-text-5: #aeaeb2; + +/** 投影 **/ +@box-base-shadow: 0 4px 10px -1px rgba(100 103 103 / 15%); +@box-middle-shadow: 0 4px 15px 2px rgba(100 103 103 / 10%); +@box-large-shadow: 0 6px 35px 6px rgba(100 103 103 / 10%); + +/** 按钮状态类 **/ + +// 主要 outline 按钮 +.btn-outline-primary-hover() { + &:not(:disabled):hover { + border-color: var(--primary-1) !important; + color: var(--primary-1) !important; + background-color: var(--primary-6) !important; + } +} + +.btn-outline-primary-active() { + &:not(:disabled):active { + border-color: var(--primary-0) !important; + color: var(--primary-0) !important; + background-color: var(--primary-6) !important; + } +} + +.btn-outline-primary-disabled() { + &:disabled { + border-color: var(--primary-4) !important; + color: var(--primary-4) !important; + background-color: var(--text-n10) !important; + } +} + +// outline error状态按钮 +.btn-outline-error-hover() { + &:not(:disabled):hover { + border-color: var(--error-1) !important; + color: var(--error-1) !important; + background-color: var(--error-5) !important; + } +} + +.btn-outline-error-active() { + &:not(:disabled):active { + border-color: var(--error-0) !important; + color: var(--error-0) !important; + background-color: var(--error-4) !important; + } +} + +.btn-outline-error-disabled() { + &:disabled { + border-color: var(--error-3) !important; + color: var(--error-3) !important; + background-color: var(--text-n9) !important; + } +} + +// outline warning 状态按钮 +.btn-outline-warning-hover() { + &:not(:disabled):hover { + border-color: var(--warning-1) !important; + color: var(--warning-1) !important; + background-color: var(--warning-5) !important; + } +} + +.btn-outline-warning-active() { + &:not(:disabled):active { + border-color: var(--warning-0) !important; + color: var(--warning-0) !important; + background-color: var(--warning-4) !important; + } +} + +.btn-outline-warning-disabled() { + &:disabled { + border-color: var(--warning-3) !important; + color: var(--warning-3) !important; + background-color: var(--text-n9) !important; + } +} + +// outline success 状态按钮 +.btn-outline-success-hover() { + &:not(:disabled):hover { + border-color: var(--success-1) !important; + color: var(--success-1) !important; + background-color: var(--success-5) !important; + } +} + +.btn-outline-success-active() { + &:not(:disabled):active { + border-color: var(--success-0) !important; + color: var(--success-0) !important; + background-color: var(--success-4) !important; + } +} + +.btn-outline-success-disabled() { + &:disabled { + border-color: var(--success-3) !important; + color: var(--success-3) !important; + background-color: var(--text-n9) !important; + } +} + +// outline info 状态按钮 +.btn-outline-info-hover() { + &:not(:disabled):hover { + border-color: var(--info-1) !important; + color: var(--info-1) !important; + background-color: var(--info-5) !important; + } +} + +.btn-outline-info-active() { + &:not(:disabled):active { + border-color: var(--info-0) !important; + color: var(--info-0) !important; + background-color: var(--info-4) !important; + } +} + +.btn-outline-info-disabled() { + &:disabled { + border-color: var(--info-3) !important; + color: var(--info-3) !important; + background-color: var(--text-n9) !important; + } +} + +// 次要outline按钮 +.btn-outline-sec-default() { + color: var(--text-n1) !important; + background-color: var(--text-n10) !important; +} + +.btn-outline-sec-hover() { + &:not(:disabled):hover { + border-color: var(--text-n7) !important; + color: var(--text-n1) !important; + background-color: var(--text-n9) !important; + } +} + +.btn-outline-sec-active() { + &:not(:disabled):active { + border-color: var(--text-n7) !important; + color: var(--text-n1) !important; + background-color: var(--text-n8) !important; + } +} + +.btn-outline-sec-disabled() { + &:disabled { + color: var(--text-n6) !important; + background-color: var(--text-n9) !important; + .n-button__border { + border: solid 1px var(--text-n7) !important; + } + } +} + +// 次要按钮 +.btn-second-default() { + border-color: var(--text-n8) !important; + color: var(--color-text-1) !important; + background-color: var(--text-n8) !important; +} + +.btn-second-hover() { + &:not(:disabled):hover { + border: 1px solid var(--text-n9) !important; + color: var(--text-n1) !important; + background-color: var(--text-n9) !important; + } +} + +.btn-second-active() { + &:not(:disabled):active { + border-color: var(--text-n7) !important; + color: var(--text-n1) !important; + background-color: var(--text-n7) !important; + } +} + +.btn-second-disabled() { + &:disabled { + border: 1px solid var(--text-n9) !important; + color: var(--text-n6) !important; + background-color: var(--text-n9) !important; + } +} + +// danger状态的outline按钮 +.btn-outline-danger-default() { + border-color: var(--error-red) !important; + color: var(--error-red) !important; +} + +.btn-outline-danger-hover() { + &:not(:disabled):hover { + border-color: var(--error-1) !important; + color: var(--error-1) !important; + background-color: var(--error-5) !important; + } +} + +.btn-outline-danger-active() { + &:not(:disabled):active { + border-color: var(--error-0) !important; + color: var(--error-0) !important; + background-color: var(--error-4) !important; + } +} + +.btn-outline-danger-disabled() { + &:disabled { + border-color: var(--error-3) !important; + color: var(--error-3) !important; + background-color: var(--text-n10) !important; + } +} + +// danger状态主要按钮 +.btn-base-danger-hover() { + &:not(:disabled):hover { + background-color: var(--error-1) !important; + } +} + +.btn-base-danger-active() { + &:not(:disabled):active { + background-color: var(--error-0) !important; + } +} + +.btn-base-danger-disabled() { + &:disabled { + background-color: var(--error-3) !important; + } +} + +// danger状态主要按钮 +.btn-base-primary-hover() { + &:not(:disabled):hover { + color: var(--text-n9); + background-color: var(--primary-1) !important; + } +} + +.btn-base-primary-active() { + &:not(:disabled):active { + color: var(--text-n9); + background-color: var(--primary-0) !important; + } +} +.btn-base-primary-focus() { + &:not(:disabled):active { + color: var(--text-n9); + background-color: var(--primary-0) !important; + } +} + +.btn-base-primary-disabled() { + &:disabled { + background-color: var(--primary-4) !important; + } +} + +/** 文本按钮 **/ +// 主要文本按钮 +.btn-text-default { + background-color: var(--text-n10) !important; +} +.btn-text-primary-hover() { + &:not(:disabled):hover { + color: var(--primary-8) !important; + background: var(--primary-7) !important; + } +} + +.btn-text-primary-active() { + &:not(:disabled):active { + color: var(--primary-8) !important; + background: var(--primary-6) !important; + } +} + +.btn-text-primary-disabled() { + &:disabled { + color: var(--primary-4) !important; + background: none !important; + } +} +// 次要文本按钮 +.btn-text-sec-hover() { + &:not(.n-button--disabled):hover { + background: var(--text-n9) !important; + } +} + +.btn-text-sec-active() { + &:not(.n-button--disabled):active { + background: var(--text-n8) !important; + } +} + +.btn-text-sec-focus() { + &:not(.n-button--disabled):focus { + background: none !important; + } +} + +.btn-text-sec-focus() { + &:not(.n-button--disabled):focus { + background: none !important; + } +} + +.btn-text-sec-disabled() { + &:disabled { + color: var(--text-n6) !important; + background: var(--text-n10) !important; + } +} + +// 基础second背景按钮 +.btn-base-sec-default() { + background: var(--text-n8) !important; +} + +.btn-base-sec-hover() { + &:not(.n-button--disabled):hover { + background: var(--text-n9) !important; + } +} + +.btn-base-sec-focus() { + &:not(.n-button--disabled):focus { + background: var(--text-n8) !important; + } +} +.btn-base-sec-active { + &:not(.n-button--disabled):active { + color: var(--text-n1) !important; + background: var(--text-n7) !important; + } +} + +.btn-base-sec-disabled() { + &:disabled { + color: var(--text-n6) !important; + background: var(--text-n9) !important; + } +} + +// error状态的文本按钮 +.btn-text-error-hover() { + &:not(:disabled):hover { + background-color: var(--error-5) !important; + } +} +.btn-text-error-active() { + &:not(:disabled):active { + background-color: var(--error-4) !important; + } +} +.btn-text-error-disabled() { + &:disabled { + color: var(--error-3) !important; + } +} + +// warning状态的文本按钮 +.btn-text-warning-hover() { + &:not(:disabled):hover { + background-color: var(--warning-5) !important; + } +} +.btn-text-warning-active() { + &:not(:disabled):active { + background-color: var(--warning-4) !important; + } +} +.btn-text-warning-disabled() { + &:disabled { + color: var(--warning-3) !important; + } +} + +// success状态的文本按钮 +.btn-text-success-hover() { + &:not(:disabled):hover { + background-color: var(--success-5) !important; + } +} +.btn-text-success-active() { + &:not(:disabled):active { + background-color: var(--success-4) !important; + } +} +.btn-text-success-disabled() { + &:disabled { + color: var(--success-3) !important; + } +} + +// info状态的文本按钮 +.btn-text-info-hover() { + &:not(:disabled):hover { + background-color: var(--info-5) !important; + } +} +.btn-text-info-active() { + &:not(:disabled):active { + background-color: var(--info-4) !important; + } +} +.btn-text-info-disabled() { + &:disabled { + color: var(--info-3) !important; + } +} + +/** 滚动条 **/ +.crm-scroll-bar() { + &::-webkit-scrollbar { + @apply bg-transparent; + + width: 5px; + height: 5px; + } + &::-webkit-scrollbar-thumb { + @apply hidden; + + border-radius: var(--border-radius-medium); + background-color: var(--text-n8); + } + &::-webkit-scrollbar-track { + @apply bg-transparent; + } + &:hover { + &::-webkit-scrollbar-thumb { + @apply block; + } + } +} + +/** 容器内部上下阴影类 **/ +.crm-container--shadow-y() { + @apply relative; + &::before { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 100%; + height: 10px; + background-color: transparent; + box-shadow: none; + transition: box-shadow 0.1s cubic-bezier(0, 0, 1, 1); + content: ''; + pointer-events: none; + } + &-shadow-top::before { + box-shadow: inset 0 10px 6px -10px rgb(0 0 0 / 15%); + } + &::after { + position: absolute; + bottom: 0; + left: 0; + z-index: 1; + width: 100%; + height: 10px; + background-color: transparent; + box-shadow: none; + transition: box-shadow 0.1s cubic-bezier(0, 0, 1, 1); + content: ''; + pointer-events: none; + } + &-shadow-bottom::after { + box-shadow: inset 0 -10px 6px -10px rgb(0 0 0 / 15%); + } +} +// 模态框title +.crm-modal-title() { + width: calc(100% - 16px); + font-size: 16px; + color: var(--text-n1); + + @apply font-semibold; +} diff --git a/frontend/packages/web/src/assets/svg/CORDYS.svg b/frontend/packages/web/src/assets/svg/CORDYS.svg new file mode 100644 index 0000000..cd8939c --- /dev/null +++ b/frontend/packages/web/src/assets/svg/CORDYS.svg @@ -0,0 +1 @@ +CORDYS \ No newline at end of file diff --git a/frontend/packages/web/src/assets/svg/SQLBot.svg b/frontend/packages/web/src/assets/svg/SQLBot.svg new file mode 100644 index 0000000..a0cad9d --- /dev/null +++ b/frontend/packages/web/src/assets/svg/SQLBot.svg @@ -0,0 +1 @@ +SQLBot \ No newline at end of file diff --git a/frontend/packages/web/src/assets/svg/cordysBackground.svg b/frontend/packages/web/src/assets/svg/cordysBackground.svg new file mode 100644 index 0000000..444531c --- /dev/null +++ b/frontend/packages/web/src/assets/svg/cordysBackground.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/web/src/assets/svg/dadan.svg b/frontend/packages/web/src/assets/svg/dadan.svg new file mode 100644 index 0000000..3c92cc4 --- /dev/null +++ b/frontend/packages/web/src/assets/svg/dadan.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/packages/web/src/assets/svg/dataease.svg b/frontend/packages/web/src/assets/svg/dataease.svg new file mode 100644 index 0000000..f61a8a9 --- /dev/null +++ b/frontend/packages/web/src/assets/svg/dataease.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/packages/web/src/assets/svg/icon_bot.svg b/frontend/packages/web/src/assets/svg/icon_bot.svg new file mode 100644 index 0000000..e78f664 --- /dev/null +++ b/frontend/packages/web/src/assets/svg/icon_bot.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/packages/web/src/assets/svg/icon_reset_outlined.svg b/frontend/packages/web/src/assets/svg/icon_reset_outlined.svg new file mode 100644 index 0000000..9288266 --- /dev/null +++ b/frontend/packages/web/src/assets/svg/icon_reset_outlined.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/packages/web/src/assets/svg/logo_CORDYS.svg b/frontend/packages/web/src/assets/svg/logo_CORDYS.svg new file mode 100644 index 0000000..1914870 --- /dev/null +++ b/frontend/packages/web/src/assets/svg/logo_CORDYS.svg @@ -0,0 +1 @@ +CORDYS \ No newline at end of file diff --git a/frontend/packages/web/src/assets/svg/maxKB.svg b/frontend/packages/web/src/assets/svg/maxKB.svg new file mode 100644 index 0000000..a43107e --- /dev/null +++ b/frontend/packages/web/src/assets/svg/maxKB.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/packages/web/src/assets/svg/newClue.svg b/frontend/packages/web/src/assets/svg/newClue.svg new file mode 100644 index 0000000..ab92c2a --- /dev/null +++ b/frontend/packages/web/src/assets/svg/newClue.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/web/src/assets/svg/newContact.svg b/frontend/packages/web/src/assets/svg/newContact.svg new file mode 100644 index 0000000..990cdad --- /dev/null +++ b/frontend/packages/web/src/assets/svg/newContact.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/web/src/assets/svg/newContract.svg b/frontend/packages/web/src/assets/svg/newContract.svg new file mode 100644 index 0000000..1ffb136 --- /dev/null +++ b/frontend/packages/web/src/assets/svg/newContract.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/web/src/assets/svg/newCustomer.svg b/frontend/packages/web/src/assets/svg/newCustomer.svg new file mode 100644 index 0000000..feb4739 --- /dev/null +++ b/frontend/packages/web/src/assets/svg/newCustomer.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/web/src/assets/svg/newInvoice.svg b/frontend/packages/web/src/assets/svg/newInvoice.svg new file mode 100644 index 0000000..92620bb --- /dev/null +++ b/frontend/packages/web/src/assets/svg/newInvoice.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/web/src/assets/svg/newOpportunity.svg b/frontend/packages/web/src/assets/svg/newOpportunity.svg new file mode 100644 index 0000000..14b5f13 --- /dev/null +++ b/frontend/packages/web/src/assets/svg/newOpportunity.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/web/src/assets/svg/newOrder.svg b/frontend/packages/web/src/assets/svg/newOrder.svg new file mode 100644 index 0000000..06950f0 --- /dev/null +++ b/frontend/packages/web/src/assets/svg/newOrder.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/web/src/assets/svg/newPlan.svg b/frontend/packages/web/src/assets/svg/newPlan.svg new file mode 100644 index 0000000..672f59d --- /dev/null +++ b/frontend/packages/web/src/assets/svg/newPlan.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/web/src/assets/svg/newRecord.svg b/frontend/packages/web/src/assets/svg/newRecord.svg new file mode 100644 index 0000000..5420c55 --- /dev/null +++ b/frontend/packages/web/src/assets/svg/newRecord.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/web/src/assets/svg/qichacha.svg b/frontend/packages/web/src/assets/svg/qichacha.svg new file mode 100644 index 0000000..b82f3e0 --- /dev/null +++ b/frontend/packages/web/src/assets/svg/qichacha.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/packages/web/src/assets/svg/success.svg b/frontend/packages/web/src/assets/svg/success.svg new file mode 100644 index 0000000..d2434c0 --- /dev/null +++ b/frontend/packages/web/src/assets/svg/success.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/packages/web/src/assets/svg/uploadfile.svg b/frontend/packages/web/src/assets/svg/uploadfile.svg new file mode 100644 index 0000000..4ced7e0 --- /dev/null +++ b/frontend/packages/web/src/assets/svg/uploadfile.svg @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/packages/web/src/assets/svg/userLogin.svg b/frontend/packages/web/src/assets/svg/userLogin.svg new file mode 100644 index 0000000..a86edb7 --- /dev/null +++ b/frontend/packages/web/src/assets/svg/userLogin.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/packages/web/src/auto-import.d.ts b/frontend/packages/web/src/auto-import.d.ts new file mode 100644 index 0000000..9b894de --- /dev/null +++ b/frontend/packages/web/src/auto-import.d.ts @@ -0,0 +1,70 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +export {} +declare global { + const EffectScope: typeof import('vue')['EffectScope'] + const computed: typeof import('vue')['computed'] + const createApp: typeof import('vue')['createApp'] + const customRef: typeof import('vue')['customRef'] + const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] + const defineComponent: typeof import('vue')['defineComponent'] + const effectScope: typeof import('vue')['effectScope'] + const getCurrentInstance: typeof import('vue')['getCurrentInstance'] + const getCurrentScope: typeof import('vue')['getCurrentScope'] + const h: typeof import('vue')['h'] + const inject: typeof import('vue')['inject'] + const isProxy: typeof import('vue')['isProxy'] + const isReactive: typeof import('vue')['isReactive'] + const isReadonly: typeof import('vue')['isReadonly'] + const isRef: typeof import('vue')['isRef'] + const markRaw: typeof import('vue')['markRaw'] + const nextTick: typeof import('vue')['nextTick'] + const onActivated: typeof import('vue')['onActivated'] + const onBeforeMount: typeof import('vue')['onBeforeMount'] + const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] + const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] + const onDeactivated: typeof import('vue')['onDeactivated'] + const onErrorCaptured: typeof import('vue')['onErrorCaptured'] + const onMounted: typeof import('vue')['onMounted'] + const onRenderTracked: typeof import('vue')['onRenderTracked'] + const onRenderTriggered: typeof import('vue')['onRenderTriggered'] + const onScopeDispose: typeof import('vue')['onScopeDispose'] + const onServerPrefetch: typeof import('vue')['onServerPrefetch'] + const onUnmounted: typeof import('vue')['onUnmounted'] + const onUpdated: typeof import('vue')['onUpdated'] + const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] + const provide: typeof import('vue')['provide'] + const reactive: typeof import('vue')['reactive'] + const readonly: typeof import('vue')['readonly'] + const ref: typeof import('vue')['ref'] + const resolveComponent: typeof import('vue')['resolveComponent'] + const shallowReactive: typeof import('vue')['shallowReactive'] + const shallowReadonly: typeof import('vue')['shallowReadonly'] + const shallowRef: typeof import('vue')['shallowRef'] + const toRaw: typeof import('vue')['toRaw'] + const toRef: typeof import('vue')['toRef'] + const toRefs: typeof import('vue')['toRefs'] + const toValue: typeof import('vue')['toValue'] + const triggerRef: typeof import('vue')['triggerRef'] + const unref: typeof import('vue')['unref'] + const useAttrs: typeof import('vue')['useAttrs'] + const useCssModule: typeof import('vue')['useCssModule'] + const useCssVars: typeof import('vue')['useCssVars'] + const useId: typeof import('vue')['useId'] + const useModel: typeof import('vue')['useModel'] + const useSlots: typeof import('vue')['useSlots'] + const useTemplateRef: typeof import('vue')['useTemplateRef'] + const watch: typeof import('vue')['watch'] + const watchEffect: typeof import('vue')['watchEffect'] + const watchPostEffect: typeof import('vue')['watchPostEffect'] + const watchSyncEffect: typeof import('vue')['watchSyncEffect'] +} +// for type re-export +declare global { + // @ts-ignore + export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + import('vue') +} diff --git a/frontend/packages/web/src/components/business/crm-agent-drawer/index.vue b/frontend/packages/web/src/components/business/crm-agent-drawer/index.vue new file mode 100644 index 0000000..e89958e --- /dev/null +++ b/frontend/packages/web/src/components/business/crm-agent-drawer/index.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/packages/web/src/components/business/crm-agent-drawer/locale/en-US.ts b/frontend/packages/web/src/components/business/crm-agent-drawer/locale/en-US.ts new file mode 100644 index 0000000..877d120 --- /dev/null +++ b/frontend/packages/web/src/components/business/crm-agent-drawer/locale/en-US.ts @@ -0,0 +1,5 @@ +export default { + 'agentDrawer.unAddedAgent': 'No agent added', + 'agentDrawer.noAgent': 'No agent available', + 'agentDrawer.addAgent': 'Add agent', +}; diff --git a/frontend/packages/web/src/components/business/crm-agent-drawer/locale/zh-CN.ts b/frontend/packages/web/src/components/business/crm-agent-drawer/locale/zh-CN.ts new file mode 100644 index 0000000..3d9fea2 --- /dev/null +++ b/frontend/packages/web/src/components/business/crm-agent-drawer/locale/zh-CN.ts @@ -0,0 +1,5 @@ +export default { + 'agentDrawer.unAddedAgent': '未添加智能体', + 'agentDrawer.noAgent': '暂无智能体', + 'agentDrawer.addAgent': '添加智能体', +}; diff --git a/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-approver-content.vue b/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-approver-content.vue new file mode 100644 index 0000000..af98d81 --- /dev/null +++ b/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-approver-content.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-avatar.vue b/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-avatar.vue new file mode 100644 index 0000000..8266a70 --- /dev/null +++ b/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-avatar.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-detail.vue b/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-detail.vue new file mode 100644 index 0000000..c44ab63 --- /dev/null +++ b/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-detail.vue @@ -0,0 +1,740 @@ + + + + + diff --git a/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-line.vue b/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-line.vue new file mode 100644 index 0000000..28492cd --- /dev/null +++ b/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-line.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-popover.vue b/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-popover.vue new file mode 100644 index 0000000..7d93027 --- /dev/null +++ b/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-popover.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-status.vue b/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-status.vue new file mode 100644 index 0000000..ec83605 --- /dev/null +++ b/frontend/packages/web/src/components/business/crm-approval/components/crm-approval-status.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/frontend/packages/web/src/components/business/crm-approval/locale/en-US.ts b/frontend/packages/web/src/components/business/crm-approval/locale/en-US.ts new file mode 100644 index 0000000..7bcc6fe --- /dev/null +++ b/frontend/packages/web/src/components/business/crm-approval/locale/en-US.ts @@ -0,0 +1,47 @@ +export default { + 'crm.approval.record': 'Approval Record', + 'crm.approval.opinion': 'Approval Opinion', + 'crm.approval.rejectConfirm': 'Confirm Rejection?', + 'crm.approval.rejectTip': 'After rejection, the approval process will be terminated. Please proceed with caution!', + 'crm.approval.confirmReject': 'Confirm Rejection', + 'crm.approval.addSignTip1': + 'Before Me: After adding a signature, the process will first go through the additional approver, then to me', + 'crm.approval.addSignTip2': 'After Me: After my approval, the process will continue to the additional approver', + 'crm.approval.addSignOpinion': 'Additional Approval Opinion', + 'crm.approval.addSignMethod': 'Add Method', + 'crm.approval.beforeMethod': 'Before Me', + 'crm.approval.afterMethod': 'After Me', + 'crm.approval.addSignApprover': 'Additional Approver', + 'crm.approval.confirmAddSign': 'Confirm Additional Approval', + 'crm.approval.agreeAndAddSign': 'Agree and Add Signature', + 'crm.approval.fallbackTo': 'Fallback To', + 'crm.approval.fallbackReason': 'Fallback Reason', + 'crm.approval.confirmFallback': 'Confirm Fallback', + 'crm.approval.cancelApprovalApply': 'Cancel Approval', + 'crm.approval.cancelApprovalApplyConfirm': 'Confirm Cancel Approval?', + 'crm.approval.cancelApprovalApplyTip': + 'After cancellation, the approval process will be terminated. Please proceed with caution!', + 'crm.approval.confirmCancelApprovalApply': 'Confirm Cancel', + 'crm.approval.cancelApprovalApplySuccess': 'Approval Canceled', + 'crm.approval.cancelApproval': 'Withdraw Approval', + 'crm.approval.cancelApprovalConfirm': 'Confirm Withdraw Approval?', + 'crm.approval.cancelApprovalTip': + 'After the withdrawal, the approval process will be terminated. Please proceed with caution!', + 'crm.approval.confirmCancelApproval': 'Confirm Withdraw', + 'crm.approval.cancelApprovalSuccess': 'Approval Withdrawn', + 'crm.approval.submit': 'Submit Application', + 'crm.approval.change': 'Change', + 'crm.approval.changeDescription': 'Change Description', + 'crm.approval.confirmChange': 'Save and submit for review', + 'crm.approval.confirmAndSubmitReview': 'Confirm and Submit', + 'crm.approval.approver': 'Approver', + 'crm.approval.addSign': 'Add Signature', + 'crm.approval.addSignSuccess': 'Add Signature Successful', + 'crm.approval.preNode': 'Previous {index} Node', + 'crm.approval.fallbackSuccess': 'Fallback Successful', + 'crm.approval.autoReject': 'Cbot Auto Reject', + 'crm.approval.fileLimitTip': 'You can add up to 10 attachments', + 'crm.approval.reviewDisabledTip': 'No active approval workflow was matched. Please contact the administrator.', + 'crm.approval.copyToTip': 'Copy to {count} people', + 'crm.approval.historyTip': 'This resource is historical data; only the final approval status is retained.', +}; diff --git a/frontend/packages/web/src/components/business/crm-approval/locale/zh-CN.ts b/frontend/packages/web/src/components/business/crm-approval/locale/zh-CN.ts new file mode 100644 index 0000000..5c04544 --- /dev/null +++ b/frontend/packages/web/src/components/business/crm-approval/locale/zh-CN.ts @@ -0,0 +1,44 @@ +export default { + 'crm.approval.record': '审批记录', + 'crm.approval.opinion': '审批意见', + 'crm.approval.rejectConfirm': '确认驳回吗?', + 'crm.approval.rejectTip': '驳回后,审批流将终止,请谨慎操作!', + 'crm.approval.confirmReject': '确认驳回', + 'crm.approval.addSignTip1': '在我之前:加签后,流程先经过加签审批人,再由我审批', + 'crm.approval.addSignTip2': '在我之后:我审批后,流程再到加签审批人', + 'crm.approval.addSignOpinion': '加签意见', + 'crm.approval.addSignMethod': '添加方式', + 'crm.approval.beforeMethod': '在我之前', + 'crm.approval.afterMethod': '在我之后', + 'crm.approval.addSignApprover': '加签审批人', + 'crm.approval.confirmAddSign': '确认加签', + 'crm.approval.agreeAndAddSign': '同意并加签', + 'crm.approval.fallbackTo': '退回至', + 'crm.approval.fallbackReason': '退回原因', + 'crm.approval.confirmFallback': '确认退回', + 'crm.approval.cancelApprovalApply': '撤销审批', + 'crm.approval.cancelApprovalApplyConfirm': '确认撤销审批吗?', + 'crm.approval.cancelApprovalApplyTip': '撤销后,审批流将终止,请谨慎操作!', + 'crm.approval.confirmCancelApprovalApply': '确认撤销', + 'crm.approval.cancelApprovalApplySuccess': '已撤销', + 'crm.approval.cancelApproval': '撤回审批', + 'crm.approval.cancelApprovalConfirm': '确认撤回审批吗?', + 'crm.approval.cancelApprovalTip': '撤回后,审批流将终止审批,请谨慎操作!', + 'crm.approval.confirmCancelApproval': '确认撤回', + 'crm.approval.cancelApprovalSuccess': '已撤回', + 'crm.approval.submit': '提交申请', + 'crm.approval.change': '变更', + 'crm.approval.changeDescription': '变更说明', + 'crm.approval.confirmChange': '保存并提审', + 'crm.approval.confirmAndSubmitReview': '确认并提审', + 'crm.approval.approver': '审批人', + 'crm.approval.addSign': '加签', + 'crm.approval.addSignSuccess': '加签成功', + 'crm.approval.preNode': '前{index}节点', + 'crm.approval.fallbackSuccess': '已退回', + 'crm.approval.autoReject': 'Cbot 自动拒绝', + 'crm.approval.fileLimitTip': '附件最多可添加 10 个', + 'crm.approval.reviewDisabledTip': '未匹配到生效审批流,请联系管理员', + 'crm.approval.copyToTip': '抄送 {count} 人', + 'crm.approval.historyTip': '该资源为历史数据,仅保留最终审批状态', +}; diff --git a/frontend/packages/web/src/components/business/crm-auto-search-select/index.vue b/frontend/packages/web/src/components/business/crm-auto-search-select/index.vue new file mode 100644 index 0000000..747e399 --- /dev/null +++ b/frontend/packages/web/src/components/business/crm-auto-search-select/index.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/frontend/packages/web/src/components/business/crm-avatar/index.vue b/frontend/packages/web/src/components/business/crm-avatar/index.vue new file mode 100644 index 0000000..77fc702 --- /dev/null +++ b/frontend/packages/web/src/components/business/crm-avatar/index.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/frontend/packages/web/src/components/business/crm-batch-edit-modal/components/batchOperationResultModal.vue b/frontend/packages/web/src/components/business/crm-batch-edit-modal/components/batchOperationResultModal.vue new file mode 100644 index 0000000..14dac1c --- /dev/null +++ b/frontend/packages/web/src/components/business/crm-batch-edit-modal/components/batchOperationResultModal.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontend/packages/web/src/components/business/crm-batch-edit-modal/index.vue b/frontend/packages/web/src/components/business/crm-batch-edit-modal/index.vue new file mode 100644 index 0000000..0c39726 --- /dev/null +++ b/frontend/packages/web/src/components/business/crm-batch-edit-modal/index.vue @@ -0,0 +1,446 @@ +